From 15cf48a1c4a39f84c4896f3f67175beffdbb351d Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 30 May 2026 02:05:23 -0700 Subject: [PATCH 01/67] Add feature promp, --- .../Application.SystemTextAwareness.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 .github/Feature-Prompts/Net11/Application.SystemTextAwareness'/Application.SystemTextAwareness.md diff --git a/.github/Feature-Prompts/Net11/Application.SystemTextAwareness'/Application.SystemTextAwareness.md b/.github/Feature-Prompts/Net11/Application.SystemTextAwareness'/Application.SystemTextAwareness.md new file mode 100644 index 00000000000..da7edcf854e --- /dev/null +++ b/.github/Feature-Prompts/Net11/Application.SystemTextAwareness'/Application.SystemTextAwareness.md @@ -0,0 +1,180 @@ +# Task: Create a new API — `Application` system-text-size awareness — and the respective API proposal + +## What to do + +Create a **new API proposal / API review issue in the upstream `dotnet/winforms` repo** +(`origin` = my fork, `upstream` = the Microsoft repo where this lands). Write it per the +WinForms repo conventions and the relevant skills for authoring new-API issues. Apply the +skills; don't ask me for boilerplate. + +This proposal introduces **runtime awareness of the Windows Accessibility text-size +setting** at the `Application` level, plus a per-`Form` change notification. It is the +**foundation** proposal; a companion `TreeView.NodeLeading` proposal references this one. + +## Before you implement anything + +**Verify every premise below against current source before committing to the design.** +Verify, don't trust. If any premise is wrong, stop and tell me. Key files (VMR @ +`96982699e0dd8c046f397541dc0eb235ea8a4958`): + +- `src/winforms/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.ThreadContext.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.ParkingWindow.cs` +- `src/runtime/src/libraries/Microsoft.Win32.SystemEvents/...` (SystemEvents / UserPreferenceCategory) + +## Rationale — surface and complete an existing partial implementation + +This is **not** a net-new feature; it **finishes a half-built one**. WinForms already reads +the Accessibility text-size setting, but only once and only for the default font: + +`ScaleHelper.ScaleToSystemTextSize(Font?)` reads +`HKEY_CURRENT_USER\Software\Microsoft\Accessibility` → value `TextScaleFactor` +(REG_DWORD, clamped 100–225), and returns the font scaled by `TextScaleFactor / 100` +(or `null` if 100, if the font `IsSystemFont`, or if the OS is < Windows 10 1507). It is +documented in-source as the **Settings → Display → Make Text Bigger** setting. + +The gaps: (1) the value is **never surfaced** to developers; (2) it is read **once**, at +default-font construction, and the app **never reacts** when the user changes the setting at +runtime; (3) there is **no notification** mechanism. This proposal fills those three gaps. + +## Three-knob disambiguation (MANDATORY callout — reviewers will conflate these) + +Windows surfaces three *different* sizing mechanisms; the Settings UI even puts two on one +page (`System → Display → Custom scaling` shows a "Custom scaling 100–500%" box AND a +"Text size" link). The proposal MUST state plainly which one it targets: + +1. **Display / Custom scaling (100–500%)** → this is **DPI**. Already handled by + `HighDpiMode` and the DPI events (`WM_DPICHANGED`, `Control.DpiChanged*`). **NOT** this + proposal. +2. **Accessibility → Text size (100–225%)** → registry `TextScaleFactor` under + `HKCU\Software\Microsoft\Accessibility`; WinRT `UISettings.TextScaleFactor` / + `TextScaleFactorChanged`. **THIS is the target.** Independent of DPI. +3. **Legacy pre-Win10 per-element text sizing** (title bars/menus) → **removed** in Windows + 10 1703; the Accessibility slider replaced it. Mentioned only to close the loop. + +State explicitly that `Application.SystemTextSize` reflects **#2 only**, and is orthogonal to +DPI (#1). + +## Proposed API + +### `Application` (process-static) + +- **`public static double SystemTextSize { get; }`** — the current Accessibility text-scale + factor (1.0–2.25; i.e. `TextScaleFactor / 100`). **Live getter** — re-reads the value, does + not cache, because it is a system setting that changes at runtime. Well-defined regardless + of whether any `Form` exists or how many UI threads are running (it is process-global). +- **`public static SystemTextSizeAwareness SystemTextSizeAwareness { get; set; }`** — the + mode. **Enum, not bool**, deliberately, to reserve room for a future `Automatic`: + - `Unaware` (default) — no notification raised; fully back-compatible, nothing changes. + - `Notify` — raise change notifications (see below); the app decides how to respond. + - *(reserved, NOT implemented now: `Automatic` — framework re-flows for you. Reserving the + enum slot now avoids a future breaking bool→enum change, the same lesson `HighDpiMode` + learned.)* +- **`public static event EventHandler? SystemTextSizeChanged`** — fires once per process when + the setting changes (only when awareness is `Notify`). + +### `Form` (instance) + +- **`public event EventHandler? SystemTextSizeChanged`** — instance event, raised on each + top-level `Form` when the setting changes. +- **`protected virtual void OnSystemTextSizeChanged(EventArgs e)`** — overridable, fires the + instance event via the `EventHandlerList` pattern (`Events[s_systemTextSizeChangedEvent]`). + +## The trigger architecture (the leak-critical part — get this exactly right) + +A naive design — a static `Application.SystemTextSizeChanged` that `Form`s/`Control`s +subscribe to — **leaks**: the static event strongly roots every subscriber, so no `Form` +that subscribes is ever collected. Avoid this by **mirroring the DPI architecture**, where +`Control`/`Form` learn of DPI changes from their **own `WndProc`** (`WM_DPICHANGED` → +`OnDpiChanged` → instance event via `EventHandlerList`), **not** from a static subscription. + +Verified facts that constrain the design: + +- **`WM_SETTINGCHANGE` is broadcast** (`HWND_BROADCAST`) and delivered directly to top-level + windows' `WndProc`s — it bypasses the thread message queue, so **`IMessageFilter` does NOT + see it.** Do not use a message filter. +- **The WinForms parking window is message-only** (`CreateParams.Parent = HWND_MESSAGE`). + Message-only windows are **excluded from broadcasts**, so the parking window **cannot** + receive `WM_SETTINGCHANGE`. Do not use it. +- **`Application` has no `MainForm`.** The main form lives on `ApplicationContext` (per-run, + per-UI-thread), can be `null` (tray/loop-only apps), and is mutable (splash→main handoff). + So the main form is **not** a reliable receiver. Do not anchor the app-level event to it. +- **`SystemEvents` already owns a hidden top-level broadcast-receiving window** (its + `.NET-BroadcastEventWindow`), and exposes `UserPreferenceChanged` with a + `UserPreferenceCategory` (the relevant value is `Accessibility`, which is **coarse** — it + covers any accessibility change, so you must re-read `TextScaleFactor` and diff to confirm + it was text-scale). + +**Resulting design:** + +- **App-level:** `Application` makes **one internal, process-lifetime** subscription to + `SystemEvents.UserPreferenceChanged`, filters `Category == Accessibility`, re-reads + `TextScaleFactor`, diffs against the cached value, and if changed raises the static + `SystemTextSizeChanged`. This is a single framework-static→framework-static link — it does + **not** root any user object, so it is **not** the leak hazard. Reuses the existing hidden + broadcast window; **no new HWND** required. +- **Form-level:** each top-level `Form` handles `WM_SETTINGCHANGE` in its **own `WndProc`** + (it is a broadcast — every top-level window receives it), re-reads + diffs, and raises its + **instance** `SystemTextSizeChanged` via `OnSystemTextSizeChanged`. `Form`s do **not** + subscribe to `Application` — no rooting, lifetime = the window. + +Caveat to document: there is **no dedicated `WM_TEXTSCALECHANGED`** message. Both paths must +recognize a *relevant* change by re-reading `TextScaleFactor` and comparing, not by the +message alone. + +## Aids vs. leave-it-to-the-user + +**Notify-only. No automatic re-layout / font-rescaling aids in this proposal.** Reasons: +text scale interacts with `AutoScaleMode`, anchored/docked layout, and explicitly-set fonts +in app-specific ways; a generic "scale all fonts by the factor" helper breaks more than it +fixes. The reserved `Automatic` enum value is exactly where such behavior would live later. +`Notify` gives the developer the factor and the event; they decide. (Consistent with the +companion `NodeLeading` proposal's conservative-default philosophy.) + +## Why this matters across controls (evidence — include the matrix) + +Multiple text-measuring controls derive item/row/tile extents from a `Font` that today only +reacts to the text-size setting once at startup (via `ScaleToSystemTextSize` on the default +font) and never again. So **any single cached height scalar is wrong the moment text size +changes at runtime** — which is the core argument for runtime awareness: + +- **ListBox / ComboBox** — have `MeasureItem` + `OwnerDrawVariable` (a real per-item measure + hatch) and `ItemHeight`. Their gap is the legacy default base calc + the missing runtime + text-scale reaction — i.e. exactly this proposal. +- **TreeView** — has neither `MeasureItem` nor a wrapped native height API; only a uniform + native item height. Worst-positioned for a managed fix; addressed by the companion + `TreeView.NodeLeading` proposal, which depends on this one. +- **ListView (Details)** — no `MeasureItem`, no native row-height message; row height is + comctl-computed from `SmallImageList` + control font, while per-item/subitem fonts are + honored via `NM_CUSTOMDRAW` (`CDRF_NEWFONT`). Userland workarounds (phantom `SmallImageList`; + `LVS_OWNERDRAWFIXED` + one-shot reflected `WM_MEASUREITEM`; "inflate control font / shrink + item fonts") each have holes (header leak, set-once, exhaustive per-item font setting, + owner-draw-all). **No clean complete userland solution exists** — strengthening the case + that text-size reaction belongs in the framework. + +## XML doc requirements + +- Document that `SystemTextSize` is the **Accessibility text-size** factor (Settings → + Display → Make Text Bigger), **not** DPI/display scaling, and is process-global / live. +- Document the `Unaware`/`Notify` semantics and that `Automatic` is reserved for future use. +- Document the no-rooting design note on the static event (so consumers understand instance + vs. static). + +## Open questions for review + +- Should `SystemTextSize` be `double` (1.0–2.25) or expose the raw int percent (100–225)? +- Behavior on OS < Windows 10 1507 (where `ScaleToSystemTextSize` no-ops): `SystemTextSize` + returns 1.0 and no events fire? +- Whether to also expose the value/event on `Application` only, leaving `Form` consumers to + use their own `WndProc` override — or provide the `Form` instance event as proposed + (recommended, for parity with the DPI event model). + +## Output + +The upstream issue per the skills: summary; the "complete a partial implementation" +rationale; the mandatory three-knob disambiguation; the proposed API; the leak-safe trigger +architecture with the four verified constraints (broadcast vs. IMessageFilter, message-only +parking window, no MainForm on Application, SystemEvents reuse); Notify-only stance; the +cross-control matrix; XML-doc requirements; open questions. Flag anything the source +contradicts. From 30d567860d5051e638e812a65e5e0236fda5d618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 17:32:14 -0700 Subject: [PATCH 02/67] Factor system text scale lookup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Windows/Forms/Internals/ScaleHelper.cs | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs index 764a35ca3a1..be410c24340 100644 --- a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs @@ -30,6 +30,12 @@ internal static partial class ScaleHelper private static bool s_processPerMonitorAware; private static Size? s_logicalSmallSystemIconSize; + private const string AccessibilityRegistryKeyPath = "Software\\Microsoft\\Accessibility"; + private const string TextScaleFactorValueName = "TextScaleFactor"; + private const int MinSystemTextScalePercent = 100; + private const int MaxSystemTextScalePercent = 225; + private const double DefaultSystemTextScaleFactor = 1.0; + /// /// The initial primary monitor DPI (logical pixels per inch) for the process. /// @@ -227,20 +233,50 @@ internal static Bitmap ScaleToDpi(Bitmap logicalBitmap, int dpi, bool disposeBit return null; } - // The default(100) and max(225) text scale factor is value what Settings display text scale - // applies and also clamps the text scale factor value between 100 and 225 value. - // See https://docs.microsoft.com/windows/uwp/design/input/text-scaling. - const int MinTextScaleValue = 100; - const int MaxTextScaleValue = 225; + if (!TryGetSystemTextScaleFactor(out double textScaleFactor) || textScaleFactor == DefaultSystemTextScaleFactor) + { + return null; + } + + return font.WithSize(font.Size * (float)textScaleFactor); + } + + /// + /// Gets the current Windows Accessibility text-scale factor as a multiplier. + /// + /// + /// + /// Returns 1.0 when text scaling is unsupported or when the setting cannot be read. + /// + /// + internal static double GetSystemTextScaleFactor() + => TryGetSystemTextScaleFactor(out double textScaleFactor) ? textScaleFactor : DefaultSystemTextScaleFactor; + + /// + /// Attempts to get the current Windows Accessibility text-scale factor as a multiplier. + /// + /// The current text-scale factor. + /// + /// if the value was read successfully; otherwise, . + /// + internal static bool TryGetSystemTextScaleFactor(out double textScaleFactor) + { + textScaleFactor = DefaultSystemTextScaleFactor; + + if (!OsVersion.IsWindows10_1507OrGreater()) + { + return false; + } try { - // Retrieve the text scale factor, which is set via Settings > Display > Make Text Bigger. - using RegistryKey? key = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\Accessibility"); - if (key is not null && key.GetValue("TextScaleFactor") is int textScale) + // Retrieve the text scale factor, which is set via Settings > Accessibility > Text size. + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(AccessibilityRegistryKeyPath); + if (key is not null && key.GetValue(TextScaleFactorValueName) is int textScale) { - textScale = Math.Clamp(textScale, MinTextScaleValue, MaxTextScaleValue); - return textScale == 100 ? null : font.WithSize(font.Size * (textScale / 100.0f)); + textScale = Math.Clamp(textScale, MinSystemTextScalePercent, MaxSystemTextScalePercent); + textScaleFactor = textScale / 100.0; + return true; } } catch @@ -251,7 +287,7 @@ internal static Bitmap ScaleToDpi(Bitmap logicalBitmap, int dpi, bool disposeBit #endif } - return null; + return false; } /// From de9f7208dfde4cf3ebd7af39bfa40cefdd2bf4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 18:16:39 -0700 Subject: [PATCH 03/67] Add system text size APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 9 ++ src/System.Windows.Forms/Resources/SR.resx | 3 + .../Resources/xlf/SR.cs.xlf | 5 + .../Resources/xlf/SR.de.xlf | 5 + .../Resources/xlf/SR.es.xlf | 5 + .../Resources/xlf/SR.fr.xlf | 5 + .../Resources/xlf/SR.it.xlf | 5 + .../Resources/xlf/SR.ja.xlf | 5 + .../Resources/xlf/SR.ko.xlf | 5 + .../Resources/xlf/SR.pl.xlf | 5 + .../Resources/xlf/SR.pt-BR.xlf | 5 + .../Resources/xlf/SR.ru.xlf | 5 + .../Resources/xlf/SR.tr.xlf | 5 + .../Resources/xlf/SR.zh-Hans.xlf | 5 + .../Resources/xlf/SR.zh-Hant.xlf | 5 + .../Forms/Application.SystemTextSize.cs | 131 ++++++++++++++++++ .../Windows/Forms/Form.SystemTextSize.cs | 72 ++++++++++ .../System/Windows/Forms/Form.cs | 5 + .../Windows/Forms/SystemTextSizeAwareness.cs | 31 +++++ 19 files changed, 316 insertions(+) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..5e0f2b1693c 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,9 @@ +static System.Windows.Forms.Application.SetSystemTextSizeAwareness(System.Windows.Forms.SystemTextSizeAwareness awareness) -> void +static System.Windows.Forms.Application.SystemTextSize.get -> double +static System.Windows.Forms.Application.SystemTextSizeAwareness.get -> System.Windows.Forms.SystemTextSizeAwareness +static System.Windows.Forms.Application.SystemTextSizeChanged -> System.EventHandler? +System.Windows.Forms.Form.SystemTextSizeChanged -> System.EventHandler? +System.Windows.Forms.SystemTextSizeAwareness +System.Windows.Forms.SystemTextSizeAwareness.Notify = 1 -> System.Windows.Forms.SystemTextSizeAwareness +System.Windows.Forms.SystemTextSizeAwareness.Unaware = 0 -> System.Windows.Forms.SystemTextSizeAwareness +virtual System.Windows.Forms.Form.OnSystemTextSizeChanged(System.EventArgs! e) -> void \ No newline at end of file diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..d826674f28a 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -6568,6 +6568,9 @@ Stack trace where the illegal operation occurred was: Occurs when form is moved to a monitor with a different resolution and scaling level, or when form's monitor scaling level is changed in the Windows settings. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + System diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..6ea1726da67 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -5600,6 +5600,11 @@ Chcete ho nahradit? Vyvolá se při prvním zobrazení formuláře. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Míra průhlednosti ovládacího prvku v procentech diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..02fafa68f4e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -5600,6 +5600,11 @@ Möchten Sie den Pfad ersetzen? Tritt ein, wenn das Formular anfangs angezeigt wird. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Der Prozentsatz der Durchlässigkeit des Steuerelements. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..fd53492b44e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? Tiene lugar cuando el formulario se muestra por primera vez. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Porcentaje de la opacidad del control. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..69cf8fcbf5a 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -5600,6 +5600,11 @@ Voulez-vous le remplacer ? Se produit lorsque le formulaire est affiché la première fois. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Le pourcentage d'opacité du contrôle. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..eae22352b89 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -5600,6 +5600,11 @@ Sostituirlo? Generato alla prima visualizzazione del form. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. La percentuale di opacità del controllo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..3029942e7b5 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? フォームが最初に表示されたときに発生します。 + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. コントロールの不透明度の割合です。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..d51a633204e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? 폼이 처음 표시될 때마다 발생합니다. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. 컨트롤의 불투명도(%)입니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..1d081c2fa93 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -5600,6 +5600,11 @@ Czy chcesz zastąpić? Występuje zawsze, gdy formant jest pokazywany jako pierwszy. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Stopień nieprzezroczystości formantu. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..92a16e85591 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -5600,6 +5600,11 @@ Deseja substituí-lo? Ocorre sempre que o formulário é mostrado pela primeira vez. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. A porcentagem de opacidade do controle. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..8ea7ed5da2b 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? Возникает при первом отображении формы. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Степень прозрачности элемента управления (в процентах). diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..517ae78a795 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -5600,6 +5600,11 @@ Değiştirmek istiyor musunuz? Formun her ilk görüntülenişinde gerçekleşir. + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. Formun donukluk yüzdesi. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..ad8da4c70db 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? 每当窗体第一次显示时发生。 + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. 控件的不透明度百分比。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..739e72d38a4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -5600,6 +5600,11 @@ Do you want to replace it? 當第一次顯示表單時發生。 + + Occurs when the Windows Accessibility text size setting changes for this top-level form. + Occurs when the Windows Accessibility text size setting changes for this top-level form. + + The opacity percentage of the control. 控制項的透明度百分比。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs new file mode 100644 index 00000000000..136be5d5cc7 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using Microsoft.Win32; + +namespace System.Windows.Forms; + +public sealed partial class Application +{ +#if NET11_0_OR_GREATER + private static readonly object s_eventSystemTextSizeChanged = new(); + + private static SystemTextSizeAwareness s_systemTextSizeAwareness; + private static double s_lastSystemTextSize = 1.0; + private static bool s_systemTextSizeNotificationsInitialized; + + /// + /// Gets the current Windows Accessibility text-scale factor + /// (Settings → Accessibility → Text size), as a multiplier in the range 1.0–2.25. + /// + /// + /// + /// Live getter — re-reads the underlying system value on every access; does not cache. + /// + /// + /// This property is orthogonal to display DPI; see for the DPI-scaling story. + /// + /// + /// On operating systems earlier than Windows 10 version 1507, this property returns 1.0. + /// + /// + public static double SystemTextSize => ScaleHelper.GetSystemTextScaleFactor(); + + /// + /// Gets the application's mode for reacting to changes in the Windows Accessibility text-scale setting. + /// + /// + /// + /// Use to change this value. + /// + /// + public static SystemTextSizeAwareness SystemTextSizeAwareness => s_systemTextSizeAwareness; + + /// + /// Occurs once per process when the Windows Accessibility text-scale setting changes, + /// while is . + /// + /// + /// + /// WinForms detects relevant changes by re-reading the underlying text-scale factor when Windows reports an + /// accessibility preference change. Because there is no dedicated text-scale notification, unrelated accessibility + /// changes do not raise this event unless the factor actually changed. + /// + /// + /// The framework listens through a single internal subscription. + /// Individual instances receive their own + /// notifications from their window procedures and do not subscribe to this static event, avoiding framework-managed + /// static-event rooting of forms. + /// + /// + public static event EventHandler? SystemTextSizeChanged + { + add => AddEventHandler(s_eventSystemTextSizeChanged, value); + remove => RemoveEventHandler(s_eventSystemTextSizeChanged, value); + } + + /// + /// Sets the application's mode for reacting to changes in the Windows Accessibility text-scale setting. + /// + /// + /// One of the enumeration values that specifies how the application reacts to Accessibility text-scale changes. + /// + /// + /// is not a valid value. + /// + public static void SetSystemTextSizeAwareness(SystemTextSizeAwareness awareness) + { + SourceGenerated.EnumValidator.Validate(awareness, nameof(awareness)); + + lock (s_internalSyncObject) + { + EnsureSystemTextSizeNotificationsInitialized(); + s_systemTextSizeAwareness = awareness; + } + } + + private static void EnsureSystemTextSizeNotificationsInitialized() + { + lock (s_internalSyncObject) + { + if (s_systemTextSizeNotificationsInitialized) + { + return; + } + + s_lastSystemTextSize = ScaleHelper.GetSystemTextScaleFactor(); + SystemEvents.UserPreferenceChanged += OnSystemTextSizeUserPreferenceChanged; + s_systemTextSizeNotificationsInitialized = true; + } + } + + private static void OnSystemTextSizeUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) + { + if (e.Category != UserPreferenceCategory.Accessibility + || !ScaleHelper.TryGetSystemTextScaleFactor(out double systemTextSize)) + { + return; + } + + EventHandler? handler = null; + + lock (s_internalSyncObject) + { + if (systemTextSize == s_lastSystemTextSize) + { + return; + } + + s_lastSystemTextSize = systemTextSize; + + if (s_systemTextSizeAwareness == SystemTextSizeAwareness.Notify) + { + handler = s_eventHandlers?[s_eventSystemTextSizeChanged] as EventHandler; + } + } + + handler?.Invoke(null, EventArgs.Empty); + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs new file mode 100644 index 00000000000..38c36b8a999 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class Form +{ +#if NET11_0_OR_GREATER + private static readonly object s_systemTextSizeChangedEvent = new(); + + private double _lastSystemTextSize = ScaleHelper.GetSystemTextScaleFactor(); + + /// + /// Occurs on this top-level when the Windows Accessibility text-scale setting changes, + /// while is . + /// + [SRCategory(nameof(SR.CatLayout))] + [SRDescription(nameof(SR.FormOnSystemTextSizeChangedDescr))] + public event EventHandler? SystemTextSizeChanged + { + add => Events.AddHandler(s_systemTextSizeChangedEvent, value); + remove => Events.RemoveHandler(s_systemTextSizeChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + /// + /// + /// WinForms raises this event only after re-reading the current Windows Accessibility text-scale factor and + /// confirming that the underlying value actually changed. There is no dedicated Windows message for text-scale + /// changes. + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnSystemTextSizeChanged(EventArgs e) + { + if (Events[s_systemTextSizeChangedEvent] is EventHandler handler) + { + handler(this, e); + } + } + + /// + /// Handles the WM_SETTINGCHANGE message. + /// + private void WmSettingChange(ref Message m) + { + base.WndProc(ref m); + + if (!GetTopLevel() || !ScaleHelper.TryGetSystemTextScaleFactor(out double systemTextSize)) + { + return; + } + + if (systemTextSize == _lastSystemTextSize) + { + return; + } + + _lastSystemTextSize = systemTextSize; + + if (Application.SystemTextSizeAwareness == SystemTextSizeAwareness.Notify) + { + OnSystemTextSizeChanged(EventArgs.Empty); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index c3900d09c6b..a924b13fb3b 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -7225,6 +7225,11 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_DPICHANGED: WmDpiChanged(ref m); break; +#if NET11_0_OR_GREATER + case PInvokeCore.WM_SETTINGCHANGE: + WmSettingChange(ref m); + break; +#endif default: base.WndProc(ref m); break; diff --git a/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs new file mode 100644 index 00000000000..2c8ab20ca1f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs @@ -0,0 +1,31 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Specifies how the application reacts to changes in the Windows Accessibility text-scale setting. +/// +/// +/// +/// The current implementation supports only and . A future release may add +/// an automatic reaction mode. +/// +/// +public enum SystemTextSizeAwareness +{ + /// + /// Default. The application does not raise any text-scale-change notification. + /// Fully back-compatible behavior. + /// + Unaware = 0, + + /// + /// The application raises and + /// when the setting changes. + /// The application decides how to respond. + /// + Notify = 1 +} +#endif From 5a0ef4dbc77bbf3a4458c5bca61a3a168c642cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 18:16:49 -0700 Subject: [PATCH 04/67] Add system text size tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Forms/ApplicationTests.SystemTextSize.cs | 58 +++++++++++++++++++ .../System/Windows/Forms/ApplicationTests.cs | 2 +- .../Windows/Forms/FormTests.SystemTextSize.cs | 38 ++++++++++++ .../System/Windows/Forms/FormTests.cs | 2 +- 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs new file mode 100644 index 00000000000..5847634d332 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.ComponentModel; +using Microsoft.DotNet.RemoteExecutor; + +namespace System.Windows.Forms.Tests; + +public partial class ApplicationTests +{ +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Application_SystemTextSize_GetReturnsExpected() + { + Assert.InRange(Application.SystemTextSize, 1.0, 2.25); + } + + [WinFormsFact] + public void Application_SystemTextSizeAwareness_DefaultValueIsUnaware() + { + RemoteExecutor.Invoke(() => + { + Assert.Equal(SystemTextSizeAwareness.Unaware, Application.SystemTextSizeAwareness); + }).Dispose(); + } + + [WinFormsTheory] + [EnumData] + public void Application_SystemTextSizeAwareness_Set_GetReturnsExpected(SystemTextSizeAwareness value) + { + SystemTextSizeAwareness originalValue = Application.SystemTextSizeAwareness; + + try + { + Application.SetSystemTextSizeAwareness(value); + Assert.Equal(value, Application.SystemTextSizeAwareness); + + Application.SetSystemTextSizeAwareness(value); + Assert.Equal(value, Application.SystemTextSizeAwareness); + } + finally + { + Application.SetSystemTextSizeAwareness(originalValue); + } + } + + [WinFormsTheory] + [InvalidEnumData] + public void Application_SetSystemTextSizeAwareness_InvalidValue_ThrowsInvalidEnumArgumentException(SystemTextSizeAwareness value) + { + Assert.Throws( + "awareness", + () => Application.SetSystemTextSizeAwareness(value)); + } +#endif +} diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs index 47013617e97..bf2346ae060 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs @@ -12,7 +12,7 @@ namespace System.Windows.Forms.Tests; -public class ApplicationTests +public partial class ApplicationTests { [WinFormsFact] public void Application_CurrentCulture_Get_ReturnsExpected() diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs new file mode 100644 index 00000000000..9209f9a36f8 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +namespace System.Windows.Forms.Tests; + +public partial class FormTests +{ +#if NET11_0_OR_GREATER + [WinFormsTheory] + [NewAndDefaultData] + public void Form_OnSystemTextSizeChanged_Invoke_CallsSystemTextSizeChanged(EventArgs eventArgs) + { + using SubForm control = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(control, sender); + Assert.Same(eventArgs, e); + callCount++; + }; + + control.SystemTextSizeChanged += handler; + control.OnSystemTextSizeChanged(eventArgs); + Assert.Equal(1, callCount); + + control.SystemTextSizeChanged -= handler; + control.OnSystemTextSizeChanged(eventArgs); + Assert.Equal(1, callCount); + } + + public partial class SubForm + { + public new void OnSystemTextSizeChanged(EventArgs e) => base.OnSystemTextSizeChanged(e); + } +#endif +} diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.cs index 26574491052..d7bae89dd1a 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.cs @@ -2793,7 +2793,7 @@ public ParentingForm(Form targetForm) } } - public class SubForm : Form + public partial class SubForm : Form { public new const int ScrollStateAutoScrolling = ScrollableControl.ScrollStateAutoScrolling; From cc3da37452d35855e937d9be605a1291d256989f Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 8 Jul 2026 14:57:47 -0700 Subject: [PATCH 05/67] Add WinRT UISettings accent-color interop to Core Hand-author the IUISettings3 / UIColor / UIColorType WinRT ABI types and generate the RoActivateInstance / WindowsCreateString / WindowsDeleteString / IInspectable / HSTRING Win32 bindings via CsWin32. The WinRT ABI types are excluded from the .NET Framework build, which does not consume them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Microsoft.Private.Windows.Core.csproj | 4 ++ .../src/NativeMethods.txt | 5 ++ .../Win32/UI/ViewManagement/IUISettings3.cs | 63 ++++++++++++++++++ .../Win32/UI/ViewManagement/UIColor.cs | 37 +++++++++++ .../Win32/UI/ViewManagement/UIColorType.cs | 66 +++++++++++++++++++ 5 files changed, 175 insertions(+) create mode 100644 src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs create mode 100644 src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs create mode 100644 src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs diff --git a/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj b/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj index 028ddf0be71..f63dbaa5496 100644 --- a/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj +++ b/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj @@ -58,6 +58,10 @@ + + + + diff --git a/src/System.Private.Windows.Core/src/NativeMethods.txt b/src/System.Private.Windows.Core/src/NativeMethods.txt index cc17c7c0a45..4dc96ffb830 100644 --- a/src/System.Private.Windows.Core/src/NativeMethods.txt +++ b/src/System.Private.Windows.Core/src/NativeMethods.txt @@ -150,6 +150,7 @@ HINSTANCE HPEN HPROPSHEETPAGE HRGN +HSTRING HWND HWND_* IDataObject @@ -164,6 +165,7 @@ IDropTargetHelper IEnumFORMATETC IEnumUnknown IGlobalInterfaceTable +IInspectable ImageFormat* ImageLockMode INK_SERIALIZED_FORMAT @@ -230,6 +232,7 @@ ReleaseDC ReleaseStgMedium RestoreDC RevokeDragDrop +RoActivateInstance RPC_E_CHANGED_MODE RPC_E_DISCONNECTED RPC_E_SERVERFAULT @@ -277,5 +280,7 @@ WIN32_ERROR WINCODEC_ERR_* WINDOW_LONG_PTR_INDEX WindowFromDC +WindowsCreateString +WindowsDeleteString WM_* WPARAM \ No newline at end of file diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs new file mode 100644 index 00000000000..15028e00045 --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.ViewManagement.IUISettings3. +/// +/// +/// +/// Manually defined as the type lives in WinRT metadata, not Win32 metadata, +/// and we do not want a CsWinRT projection dependency. Slots 3-5 are the +/// IInspectable methods. +/// +/// +internal unsafe struct IUISettings3 : IComIID +{ + private readonly void** _vtbl; + + // {03021BE4-5254-4781-8194-5168F7D06D7B} + public static Guid IID_Guid { get; } = new(0x03021be4, 0x5254, 0x4781, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b); + + static ref readonly Guid IComIID.Guid + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ReadOnlySpan data = + [ + // 0x03021be4, 0x5254, 0x4781, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b + 0xe4, 0x1b, 0x02, 0x03, 0x54, 0x52, 0x81, 0x47, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b + ]; + + return ref Unsafe.As(ref MemoryMarshal.GetReference(data)); + } + } + + public HRESULT QueryInterface(Guid* riid, void** ppvObject) + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[0])(pThis, riid, ppvObject); + } + + public uint AddRef() + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[1])(pThis); + } + + public uint Release() + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[2])(pThis); + } + + // Slots 3-5: IInspectable::GetIids, GetRuntimeClassName, GetTrustLevel (unused). + + public HRESULT GetColorValue(UIColorType desiredColor, UIColor* value) + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[6])(pThis, desiredColor, value); + } +} diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs new file mode 100644 index 00000000000..94c3036ca5b --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.Color, the value returned by +/// . +/// +/// +/// +/// Manually defined to mirror the WinRT ABI layout (four sequential bytes: alpha, red, green, blue) +/// without taking a CsWinRT projection dependency. +/// +/// +internal struct UIColor +{ + /// + /// The alpha channel of the color. + /// + public byte A; + + /// + /// The red channel of the color. + /// + public byte R; + + /// + /// The green channel of the color. + /// + public byte G; + + /// + /// The blue channel of the color. + /// + public byte B; +} diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs new file mode 100644 index 00000000000..9725700b888 --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.ViewManagement.UIColorType, identifying which system color to +/// retrieve through . +/// +/// +/// +/// Manually defined to mirror the WinRT enumeration without taking a CsWinRT projection dependency. +/// +/// +internal enum UIColorType +{ + /// + /// The background color. + /// + Background = 0, + + /// + /// The foreground color. + /// + Foreground = 1, + + /// + /// The darkest of the three accent shades. + /// + AccentDark3 = 2, + + /// + /// The second darkest accent shade. + /// + AccentDark2 = 3, + + /// + /// The lightest of the three dark accent shades. + /// + AccentDark1 = 4, + + /// + /// The base accent color. + /// + Accent = 5, + + /// + /// The darkest of the three light accent shades. + /// + AccentLight1 = 6, + + /// + /// The second lightest accent shade. + /// + AccentLight2 = 7, + + /// + /// The lightest of the three accent shades. + /// + AccentLight3 = 8, + + /// + /// The complement of the accent color. + /// + Complement = 9, +} From aaea80db52db5e13a06a89db5db81db989639804 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 8 Jul 2026 14:57:48 -0700 Subject: [PATCH 06/67] Add Application.GetWindowsAccentColor() Expose the user's current Windows accent color by activating the Windows.UI.ViewManagement.UISettings runtime component and reading UIColorType.Accent. When no accent color is set, Windows returns an OS-defined default, so the method always yields a usable color. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 1 + .../Forms/Application.WindowsAccentColor.cs | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 5e0f2b1693c..be22f5f4e2f 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +static System.Windows.Forms.Application.GetWindowsAccentColor() -> System.Drawing.Color static System.Windows.Forms.Application.SetSystemTextSizeAwareness(System.Windows.Forms.SystemTextSizeAwareness awareness) -> void static System.Windows.Forms.Application.SystemTextSize.get -> double static System.Windows.Forms.Application.SystemTextSizeAwareness.get -> System.Windows.Forms.SystemTextSizeAwareness diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs new file mode 100644 index 00000000000..0fbce5b214b --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Windows.Win32.System.WinRT; +using Windows.Win32.UI.ViewManagement; + +namespace System.Windows.Forms; + +public sealed partial class Application +{ + /// + /// Gets the user's current Windows accent color. + /// + /// + /// The accent color the user has selected in the Windows personalization settings. + /// + /// + /// + /// The value is read from the Windows UISettings runtime component and reflects the color the + /// user picked (or that Windows derived automatically from the desktop background). If the user has not + /// chosen an accent color, Windows returns a default accent color defined by the operating system, so + /// this method always yields a usable color rather than throwing or returning an empty value. + /// + /// + public static unsafe Color GetWindowsAccentColor() + { + HSTRING className = default; + + fixed (char* pClassName = "Windows.UI.ViewManagement.UISettings") + { + PInvokeCore.WindowsCreateString((PCWSTR)pClassName, 36u, &className).ThrowOnFailure(); + } + + try + { + using ComScope inspectable = new(null); + PInvokeCore.RoActivateInstance(className, inspectable).ThrowOnFailure(); + + using ComScope settings = inspectable.TryQuery(out HRESULT hr); + hr.ThrowOnFailure(); + + UIColor color; + settings.Value->GetColorValue(UIColorType.Accent, &color).ThrowOnFailure(); + + return Color.FromArgb(color.A, color.R, color.G, color.B); + } + finally + { + PInvokeCore.WindowsDeleteString(className); + } + } +} From ba8d3b52e5ac58ce34b4a2b6e9ac96e2f1e26f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 17:45:32 -0700 Subject: [PATCH 07/67] Add TreeView.NodeLeading API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 4 + src/System.Windows.Forms/Resources/SR.resx | 6 + .../Resources/xlf/SR.cs.xlf | 10 + .../Resources/xlf/SR.de.xlf | 10 + .../Resources/xlf/SR.es.xlf | 10 + .../Resources/xlf/SR.fr.xlf | 10 + .../Resources/xlf/SR.it.xlf | 10 + .../Resources/xlf/SR.ja.xlf | 10 + .../Resources/xlf/SR.ko.xlf | 10 + .../Resources/xlf/SR.pl.xlf | 10 + .../Resources/xlf/SR.pt-BR.xlf | 10 + .../Resources/xlf/SR.ru.xlf | 10 + .../Resources/xlf/SR.tr.xlf | 10 + .../Resources/xlf/SR.zh-Hans.xlf | 10 + .../Resources/xlf/SR.zh-Hant.xlf | 10 + .../Forms/Controls/TreeView/TreeView.cs | 249 ++++++++++++++++-- 16 files changed, 373 insertions(+), 16 deletions(-) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..a60deb8b46a 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +System.Windows.Forms.TreeView.NodeLeading.get -> float +System.Windows.Forms.TreeView.NodeLeading.set -> void +System.Windows.Forms.TreeView.NodeLeadingChanged -> System.EventHandler? +virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) -> void \ No newline at end of file diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..9fa38101d88 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -6319,6 +6319,9 @@ Stack trace where the illegal operation occurred was: The color of the lines that connect the nodes of the TreeView. + + The uniform row-height leading factor applied to all nodes. + Occurs when a node is clicked with the mouse. @@ -6334,6 +6337,9 @@ Stack trace where the illegal operation occurred was: The sorting comparer for the TreeView. + + Occurs when the value of the NodeLeading property changes. + The string delimiter used for the path returned by a node's FullPath property. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..c9b1a99dc9f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -11019,6 +11019,11 @@ Trasování zásobníku, kde došlo k neplatné operaci: Barva čar spojujících uzly ovládacího prvku TreeView + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Vyvolá se při kliknutí myší na uzel. @@ -11044,6 +11049,11 @@ Trasování zásobníku, kde došlo k neplatné operaci: Kořenové uzly v ovládacím prvku TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Oddělovač řetězců použitý pro cesty vracené vlastností FullPath uzlu. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..44b67dc61ce 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -11019,6 +11019,11 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Die Farbe der Linien, die die Knoten der TreeView verbinden. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Tritt auf, wenn mit der Maus auf einen Knoten geklickt wird. @@ -11044,6 +11049,11 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Die Stammknoten im TreeView-Steuerelement. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Das Zeichenfolgen-Trennzeichen für den Pfad, der von der FullPath-Eigenschaft eines Knotens zurückgegeben wird. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..d299c43e59f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -11019,6 +11019,11 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: Color de las líneas que conectan los nodos en TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Tiene lugar cuando se hace clic con el mouse en un nodo. @@ -11044,6 +11049,11 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: Nodos raíz en el control TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Delimitador de cadena utilizado para la ruta de acceso devuelta por la propiedad FullPath de un nodo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..77b08df3095 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -11019,6 +11019,11 @@ Cette opération non conforme s'est produite sur la trace de la pile : La couleur des lignes qui connectent les nœuds du TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Se produit lors d'un clic de souris sur un nœud. @@ -11044,6 +11049,11 @@ Cette opération non conforme s'est produite sur la trace de la pile : Les nœuds racine dans le contrôle TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Le délimiteur de chaîne utilisé pour le chemin d'accès retourné par la propriété FullPath d'un nœud. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..5aa9f55a1b6 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -11019,6 +11019,11 @@ Analisi dello stack dove si è verificata l'operazione non valida: Il colore delle righe che connettono i nodi del controllo TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Generato quando si fa clic con il mouse su un nodo. @@ -11044,6 +11049,11 @@ Analisi dello stack dove si è verificata l'operazione non valida: I nodi radice nel controllo TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Il delimitatore di stringa utilizzato per il percorso restituito dalla proprietà FullPath di un nodo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..a543df67113 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -11019,6 +11019,11 @@ Stack trace where the illegal operation occurred was: ツリー ビューのノード間を接続する線の色です。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. ノードがマウスでクリックされたときに発生します。 @@ -11044,6 +11049,11 @@ Stack trace where the illegal operation occurred was: TreeView コントロールのルートのノードです。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. ノードの FullPath プロパティにより返されるパスで使用する、文字列の区切り文字です。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..b3167ae7d08 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -11019,6 +11019,11 @@ Stack trace where the illegal operation occurred was: TreeView의 노드를 연결하는 줄의 색입니다. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 마우스로 노드를 클릭할 때 발생합니다. @@ -11044,6 +11049,11 @@ Stack trace where the illegal operation occurred was: TreeView 컨트롤의 루트 노드입니다. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 노드의 FullPath 속성으로 반환된 경로에 사용되는 문자열 구분 기호입니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..ee59caffd9f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -11019,6 +11019,11 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Kolor linii łączących węzły elementu TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Występuje, gdy węzeł zostanie kliknięty przy użyciu myszy. @@ -11044,6 +11049,11 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Węzły główne w formancie TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Ogranicznik ciągu używany w ścieżce zwracanej przez właściwość FullPath węzła. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..a1e156de2aa 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -11019,6 +11019,11 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: A cor das linhas que conectam os nós de TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Ocorre quando o usuário clica com o mouse em um nó. @@ -11044,6 +11049,11 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: Nós raiz no controle TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. O delimitador de cadeia de caracteres usado para o caminho retornado pela propriedade FullPath de um nó. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..dd7c7c79a00 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -11019,6 +11019,11 @@ Stack trace where the illegal operation occurred was: Цвет линий, соединяющих узлы в TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Возникает при щелчке мыши на узле. @@ -11044,6 +11049,11 @@ Stack trace where the illegal operation occurred was: Корневые узлы в элементе управления TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Разделитель строк в пути, возвращаемом свойством FullPath узла. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..596ca5b789c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -11019,6 +11019,11 @@ Geçersiz işlemin gerçekleştiği yığın izi: TreeView'in düğümlerini bağlayan çizgilerin rengi. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Öğe fareyle tıklatıldığında gerçekleşir. @@ -11044,6 +11049,11 @@ Geçersiz işlemin gerçekleştiği yığın izi: TreeView denetiminde kök düğümler. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Düğümün FullPath özelliği tarafından döndürülen yol için kullanılan dize sınırlayıcı. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..dbdb7430953 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -11019,6 +11019,11 @@ Stack trace where the illegal operation occurred was: 连接树视图节点的线条的颜色。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 用鼠标单击节点时发生。 @@ -11044,6 +11049,11 @@ Stack trace where the illegal operation occurred was: TreeView 控件中的根节点。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 用于由节点的 FullPath 属性返回的路径的字符串分隔符。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..3f67e31a2da 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -11019,6 +11019,11 @@ Stack trace where the illegal operation occurred was: 連接樹狀檢視節點的線條色彩。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 以滑鼠按一下節點時發生。 @@ -11044,6 +11049,11 @@ Stack trace where the illegal operation occurred was: TreeView 控制項中的根節點。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 節點 FullPath 屬性用來傳回路徑的字串分隔符號。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs index 4d6a3c53602..5a68e10a41f 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs @@ -43,6 +43,7 @@ public partial class TreeView : Control private TreeViewEventHandler? _onAfterSelect; private ItemDragEventHandler? _onItemDrag; private TreeNodeMouseHoverEventHandler? _onNodeMouseHover; + private EventHandler? _onNodeLeadingChanged; private EventHandler? _onRightToLeftLayoutChanged; internal TreeNode? _selectedNode; @@ -78,6 +79,7 @@ public partial class TreeView : Control private Collections.Specialized.BitVector32 _treeViewState; // see TREEVIEWSTATE_ constants above private static bool s_isScalingInitialized; + private static readonly int s_nodeLeadingProperty = PropertyStore.CreateKey(); private static Size? s_stateImageSize; private static Size? StateImageSize { @@ -117,6 +119,30 @@ internal ImageList.Indexer SelectedImageIndexer } } + private bool RequiresNonEvenHeightStyle + { + get + { +#if NET11_0_OR_GREATER + return _setOddHeight || UsesNodeLeading; +#else + return _setOddHeight; +#endif + } + } + + private bool UsesNodeLeading + { + get + { +#if NET11_0_OR_GREATER + return NodeLeading != 0.0f; +#else + return false; +#endif + } + } + private ImageList? _imageList; private int _indent = -1; private int _itemHeight = -1; @@ -170,6 +196,11 @@ public TreeView() SetStyle(ControlStyles.UserPaint, false); SetStyle(ControlStyles.StandardClick, false); SetStyle(ControlStyles.UseTextForAccessibility, false); + +#if NET11_0_OR_GREATER + FontChanged += HandleNodeLeadingMetricChanged; + DpiChangedAfterParent += HandleNodeLeadingMetricChanged; +#endif } internal override void ReleaseUiaProvider(HWND handle) @@ -366,7 +397,7 @@ protected override CreateParams CreateParams cp.Style |= (int)PInvoke.TVS_FULLROWSELECT; } - if (_setOddHeight) + if (RequiresNonEvenHeightStyle) { cp.Style |= (int)PInvoke.TVS_NONEVENHEIGHT; } @@ -760,6 +791,79 @@ public int Indent } } +#if NET11_0_OR_GREATER + /// + /// Gets or sets the per-node vertical leading factor applied on top of the live height. + /// Default 0.0f selects legacy behavior. + /// + /// + /// A non-negative . 0.0f (default) selects legacy behavior. + /// 1.0f opts in to the honest row-height calculation. Values greater than 1.0f add extra leading. + /// + /// + /// + /// This property applies uniformly to all nodes in the control. It is not a per-node knob, and per-node row + /// heights are not supported by the native control. + /// + /// + /// 0.0f keeps the legacy behavior, including the managed FontHeight + 3 + /// estimate and the default even-height rounding. 1.0f opts in to a row height derived from the live + /// height with TVS_NONEVENHEIGHT enabled. Other positive values scale the + /// honest base for denser or airier rows. + /// + /// + /// The factor is dimensionless. The resulting pixels scale with , whose height already + /// carries the current DPI, so the factor itself does not scale with DPI. + /// + /// + /// "Leading" (pronounced "ledding") is a typesetting term from the strips of lead metal once placed between + /// lines of type to add vertical spacing; it is unrelated to leading or guiding. + /// + /// + /// + /// The value is negative or not finite. + /// + [SRCategory(nameof(SR.CatAppearance))] + [DefaultValue(0.0f)] + [SRDescription(nameof(SR.TreeViewNodeLeadingDescr))] + public float NodeLeading + { + get => Properties.GetValueOrDefault(s_nodeLeadingProperty, 0.0f); + set + { + if (!float.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(value); + + float oldValue = NodeLeading; + if (oldValue != value) + { + bool oldUsesNodeLeading = oldValue != 0.0f; + bool newUsesNodeLeading = value != 0.0f; + + Properties.AddOrRemoveValue(s_nodeLeadingProperty, value, defaultValue: 0.0f); + + if (IsHandleCreated) + { + if (oldUsesNodeLeading != newUsesNodeLeading) + { + RecreateHandle(); + } + else if (newUsesNodeLeading) + { + ApplyItemHeightFromCurrentSettings(); + } + } + + OnNodeLeadingChanged(EventArgs.Empty); + } + } + } +#endif + /// /// The height of every item in the tree view, in pixels. /// @@ -769,6 +873,18 @@ public int ItemHeight { get { +#if NET11_0_OR_GREATER + if (UsesNodeLeading) + { + if (IsHandleCreated) + { + return (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + } + + return GetNodeLeadingItemHeight(); + } +#endif + if (_itemHeight != -1) { return _itemHeight; @@ -798,21 +914,28 @@ public int ItemHeight _itemHeight = value; if (IsHandleCreated) { - if (_itemHeight % 2 != 0) + if (UsesNodeLeading) { - _setOddHeight = true; - try - { - RecreateHandle(); - } - finally + ApplyItemHeightFromCurrentSettings(); + } + else + { + if (_itemHeight % 2 != 0) { - _setOddHeight = false; + _setOddHeight = true; + try + { + RecreateHandle(); + } + finally + { + _setOddHeight = false; + } } - } - PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)value); - _itemHeight = (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)value); + _itemHeight = (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + } } } } @@ -1481,6 +1604,19 @@ public event EventHandler? RightToLeftLayoutChanged remove => _onRightToLeftLayoutChanged -= value; } +#if NET11_0_OR_GREATER + /// + /// Occurs when the value of changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.TreeViewOnNodeLeadingChangedDescr))] + public event EventHandler? NodeLeadingChanged + { + add => _onNodeLeadingChanged += value; + remove => _onNodeLeadingChanged -= value; + } +#endif + /// /// Disables redrawing of the tree view. A call to beginUpdate() must be /// balanced by a following call to endUpdate(). Following a call to @@ -1796,6 +1932,16 @@ private void StateImageListChangedHandle(object? sender, EventArgs e) } } +#if NET11_0_OR_GREATER + private void HandleNodeLeadingMetricChanged(object? sender, EventArgs e) + { + if (UsesNodeLeading) + { + ApplyItemHeightFromCurrentSettings(); + } + } +#endif + /// /// Overridden to handle RETURN key. /// @@ -1913,10 +2059,7 @@ protected override void OnHandleCreated(EventArgs e) PInvokeCore.SendMessage(this, PInvoke.TVM_SETINDENT, (WPARAM)_indent); } - if (_itemHeight != -1) - { - PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)ItemHeight); - } + ApplyItemHeightFromCurrentSettings(); // Essentially we are setting the width to be infinite so that the // TreeView never thinks it needs a scrollbar when the first node is created @@ -1954,6 +2097,7 @@ protected override void OnHandleCreated(EventArgs e) flags); } } + finally { _treeViewState[TREEVIEWSTATE_stopResizeWindowMsgs] = false; @@ -2319,6 +2463,23 @@ protected virtual void OnRightToLeftLayoutChanged(EventArgs e) _onRightToLeftLayoutChanged?.Invoke(this, e); } +#if NET11_0_OR_GREATER + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnNodeLeadingChanged(EventArgs e) + { + if (GetAnyDisposingInHierarchy()) + { + return; + } + + _onNodeLeadingChanged?.Invoke(this, e); + } +#endif + // Refresh the nodes by clearing the tree and adding the nodes back again // private void RefreshNodes() @@ -2349,6 +2510,13 @@ private void ResetItemHeight() RecreateHandle(); } +#if NET11_0_OR_GREATER + /// + /// This resets the node leading factor to the legacy default. + /// + private void ResetNodeLeading() => NodeLeading = 0.0f; +#endif + /// /// Retrieves true if the indent should be persisted in code gen. /// @@ -2359,6 +2527,10 @@ private void ResetItemHeight() /// private bool ShouldSerializeItemHeight() => (_itemHeight != -1); +#if NET11_0_OR_GREATER + private bool ShouldSerializeNodeLeading() => Properties.ContainsKey(s_nodeLeadingProperty); +#endif + private bool ShouldSerializeSelectedImageIndex() { if (_imageList is not null) @@ -2379,6 +2551,51 @@ private bool ShouldSerializeImageIndex() return ImageIndex != ImageList.Indexer.DefaultIndex; } + private void ApplyItemHeightFromCurrentSettings() + { + if (!IsHandleCreated) + { + return; + } + +#if NET11_0_OR_GREATER + if (UsesNodeLeading) + { + int itemHeight = GetNodeLeadingItemHeight(); + if ((int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT) != itemHeight) + { + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)itemHeight); + } + + return; + } +#endif + + if (_itemHeight != -1) + { + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)ItemHeight); + } + } + +#if NET11_0_OR_GREATER + private int GetNodeLeadingItemHeight() + { + double itemHeight = Math.Ceiling(NodeLeading * Font.Height); + + if (itemHeight < 1) + { + return 1; + } + + if (itemHeight >= short.MaxValue) + { + return short.MaxValue - 1; + } + + return (int)itemHeight; + } +#endif + /// /// Updated the sorted order /// From 2fb3a97b5c9a694ee7ded0b3cfce88150e4f933c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 17:45:47 -0700 Subject: [PATCH 08/67] Add TreeView.NodeLeading tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Windows/Forms/TreeViewTests.cs | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/TreeViewTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/TreeViewTests.cs index 230d7b81490..74f0944d9b5 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/TreeViewTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/TreeViewTests.cs @@ -2731,6 +2731,258 @@ public void ItemHeight_Get_ReturnsExpected(Font font, bool checkBoxes, TreeViewD Assert.Equal(expectedHeight, treeView.ItemHeight); } +#if NET11_0_OR_GREATER + public static IEnumerable NodeLeading_Set_TestData() + { + yield return new object[] { 0.25f }; + yield return new object[] { 1.0f }; + yield return new object[] { 1.25f }; + } + + public static IEnumerable NodeLeading_SetWithHandle_TestData() + { + yield return new object[] { 1.0f }; + yield return new object[] { 1.25f }; + } + + public static IEnumerable NodeLeading_SetInvalid_TestData() + { + yield return new object[] { -0.1f }; + yield return new object[] { float.NaN }; + yield return new object[] { float.PositiveInfinity }; + yield return new object[] { float.NegativeInfinity }; + } + + [WinFormsFact] + public void TreeView_NodeLeading_DefaultValue() + { + using SubTreeView control = new(); + + Assert.Equal(0.0f, control.NodeLeading); + Assert.Equal(0, control.CreateParams.Style & (int)PInvoke.TVS_NONEVENHEIGHT); + Assert.False(control.IsHandleCreated); + } + + [WinFormsTheory] + [MemberData(nameof(ItemHeight_Get_TestData))] + public void TreeView_NodeLeadingDefault_ItemHeightGet_ReturnsLegacyExpected(Font font, bool checkBoxes, TreeViewDrawMode drawMode, int expectedHeight) + { + using TreeView control = new() + { + Font = font, + CheckBoxes = checkBoxes, + DrawMode = drawMode, + NodeLeading = 0.0f + }; + + Assert.Equal(expectedHeight, control.ItemHeight); + Assert.False(control.IsHandleCreated); + } + + [WinFormsTheory] + [MemberData(nameof(NodeLeading_Set_TestData))] + public void TreeView_NodeLeading_Set_GetReturnsExpected(float value) + { + using SubTreeView control = new() + { + NodeLeading = value + }; + + Assert.Equal(value, control.NodeLeading); + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, value), control.ItemHeight); + Assert.Equal((int)PInvoke.TVS_NONEVENHEIGHT, control.CreateParams.Style & (int)PInvoke.TVS_NONEVENHEIGHT); + Assert.False(control.IsHandleCreated); + + control.NodeLeading = value; + Assert.Equal(value, control.NodeLeading); + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, value), control.ItemHeight); + Assert.False(control.IsHandleCreated); + } + + [WinFormsTheory] + [MemberData(nameof(NodeLeading_SetWithHandle_TestData))] + public void TreeView_NodeLeading_SetWithHandle_GetReturnsExpected(float value) + { + using SubTreeView control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + int invalidatedCallCount = 0; + control.Invalidated += (sender, e) => invalidatedCallCount++; + int styleChangedCallCount = 0; + control.StyleChanged += (sender, e) => styleChangedCallCount++; + int createdCallCount = 0; + control.HandleCreated += (sender, e) => createdCallCount++; + + control.NodeLeading = value; + Assert.Equal(value, control.NodeLeading); + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, value), control.ItemHeight); + Assert.Equal((int)PInvoke.TVS_NONEVENHEIGHT, control.CreateParams.Style & (int)PInvoke.TVS_NONEVENHEIGHT); + Assert.True(control.IsHandleCreated); + Assert.Equal(0, invalidatedCallCount); + Assert.Equal(0, styleChangedCallCount); + Assert.Equal(1, createdCallCount); + + control.NodeLeading = value; + Assert.Equal(value, control.NodeLeading); + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, value), control.ItemHeight); + Assert.True(control.IsHandleCreated); + Assert.Equal(0, invalidatedCallCount); + Assert.Equal(0, styleChangedCallCount); + Assert.Equal(1, createdCallCount); + } + + [WinFormsTheory] + [MemberData(nameof(NodeLeading_SetInvalid_TestData))] + public void TreeView_NodeLeading_SetInvalid_ThrowsArgumentOutOfRangeException(float value) + { + using TreeView control = new(); + Assert.Throws("value", () => control.NodeLeading = value); + } + + [WinFormsFact] + public void TreeView_NodeLeading_SetWithHandler_CallsNodeLeadingChanged() + { + using TreeView control = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(control, sender); + Assert.Same(EventArgs.Empty, e); + callCount++; + }; + + control.NodeLeadingChanged += handler; + + control.NodeLeading = 1.0f; + Assert.Equal(1, callCount); + + control.NodeLeading = 1.0f; + Assert.Equal(1, callCount); + + control.NodeLeading = 1.25f; + Assert.Equal(2, callCount); + + control.NodeLeadingChanged -= handler; + control.NodeLeading = 0.0f; + Assert.Equal(2, callCount); + } + + [WinFormsTheory] + [NewAndDefaultData] + public void TreeView_OnNodeLeadingChanged_Invoke_CallsNodeLeadingChanged(EventArgs eventArgs) + { + using SubTreeView control = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(control, sender); + Assert.Same(eventArgs, e); + callCount++; + }; + + control.NodeLeadingChanged += handler; + control.OnNodeLeadingChanged(eventArgs); + Assert.Equal(1, callCount); + Assert.False(control.IsHandleCreated); + + control.NodeLeadingChanged -= handler; + control.OnNodeLeadingChanged(eventArgs); + Assert.Equal(1, callCount); + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void TreeView_NodeLeading_PrecedesItemHeightWithHandle() + { + using SubTreeView control = new() + { + ItemHeight = 42 + }; + Assert.NotEqual(IntPtr.Zero, control.Handle); + int createdCallCount = 0; + control.HandleCreated += (sender, e) => createdCallCount++; + + control.NodeLeading = 1.0f; + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, 1.0f), control.ItemHeight); + Assert.Equal((int)PInvoke.TVS_NONEVENHEIGHT, control.CreateParams.Style & (int)PInvoke.TVS_NONEVENHEIGHT); + Assert.Equal(1, createdCallCount); + + control.ItemHeight = 24; + Assert.Equal(GetExpectedNodeLeadingItemHeight(control.Font, 1.0f), control.ItemHeight); + Assert.Equal(1, createdCallCount); + + control.NodeLeading = 0.0f; + Assert.Equal(24, control.ItemHeight); + Assert.Equal(0, control.CreateParams.Style & (int)PInvoke.TVS_NONEVENHEIGHT); + Assert.Equal(2, createdCallCount); + } + + [WinFormsFact] + public void TreeView_NodeLeading_SetFontWithHandle_UpdatesItemHeight() + { + using Font initialFont = new(FontFamily.GenericSansSerif, 8.0f); + using Font updatedFont = new(FontFamily.GenericSansSerif, 20.0f); + using TreeView control = new() + { + Font = initialFont, + NodeLeading = 1.25f + }; + Assert.NotEqual(IntPtr.Zero, control.Handle); + Assert.Equal(GetExpectedNodeLeadingItemHeight(initialFont, 1.25f), control.ItemHeight); + + control.Font = updatedFont; + Assert.Equal(GetExpectedNodeLeadingItemHeight(updatedFont, 1.25f), control.ItemHeight); + } + + [WinFormsFact] + public void TreeView_ResetNodeLeading_Invoke_Success() + { + using TreeView treeView = new() + { + NodeLeading = 1.25f + }; + + var accessor = treeView.TestAccessor; + accessor.Dynamic.ResetNodeLeading(); + + Assert.Equal(0.0f, treeView.NodeLeading); + } + + [WinFormsFact] + public void TreeView_ShouldSerializeNodeLeading_Invoke_ReturnsExpected() + { + using TreeView treeView = new(); + + var accessor = treeView.TestAccessor; + bool result = accessor.Dynamic.ShouldSerializeNodeLeading(); + Assert.False(result); + + treeView.NodeLeading = 1.25f; + result = accessor.Dynamic.ShouldSerializeNodeLeading(); + Assert.True(result); + + accessor.Dynamic.ResetNodeLeading(); + result = accessor.Dynamic.ShouldSerializeNodeLeading(); + Assert.False(result); + } + + private static int GetExpectedNodeLeadingItemHeight(Font font, float nodeLeading) + { + double itemHeight = Math.Ceiling(nodeLeading * font.Height); + + if (itemHeight < 1) + { + return 1; + } + + if (itemHeight >= short.MaxValue) + { + return short.MaxValue - 1; + } + + return (int)itemHeight; + } +#endif + public static IEnumerable ItemHeight_Set_TestData() { yield return new object[] { -1, Control.DefaultFont.Height + 3 }; @@ -7671,6 +7923,10 @@ private class SubTreeView : TreeView public new void OnNodeMouseDoubleClick(TreeNodeMouseClickEventArgs e) => base.OnNodeMouseDoubleClick(e); +#if NET11_0_OR_GREATER + public new void OnNodeLeadingChanged(EventArgs e) => base.OnNodeLeadingChanged(e); +#endif + public new void OnNodeMouseHover(TreeNodeMouseHoverEventArgs e) => base.OnNodeMouseHover(e); public new void OnRightToLeftLayoutChanged(EventArgs e) => base.OnRightToLeftLayoutChanged(e); From a09e0ecd4a791ea18e1dd2f44d318b88b14a8e48 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 13:53:19 -0700 Subject: [PATCH 09/67] Add the prompts for the new Suspend-Positioning-Painting-Form-Apearance API. --- Winforms.sln | 3 - .../Create-Github-API-Proposal-Prompt.md | 0 ...elocationAndPainting-API-Feature-Prompt.md | 76 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md create mode 100644 docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md diff --git a/Winforms.sln b/Winforms.sln index fef423d18a4..b469fab8e51 100644 --- a/Winforms.sln +++ b/Winforms.sln @@ -197,9 +197,6 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.Private.Windows.GdiPlus", "src\System.Private.Windows.GdiPlus\System.Private.Windows.GdiPlus.csproj", "{442C867C-51C0-8CE5-F067-DF065008E3DA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Copilot", "Copilot", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - .github\copilot-instructions.md = .github\copilot-instructions.md - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GDI", "GDI", "{D619FF8C-D99A-48AB-B16B-2F0E819B46D5}" ProjectSection(SolutionItems) = preProject diff --git a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md new file mode 100644 index 00000000000..bcff8090eff --- /dev/null +++ b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md @@ -0,0 +1,76 @@ +# Copilot Prompt 2 — Implement the flicker-free UI mutation APIs + +## Prerequisite + +This prompt consumes the **approved API suggestion** produced by Prompt 1 (and refined +through API review). Before implementing, read that proposal in full and treat its final +`API Proposal` section as the contract. Where the approved proposal and this prompt +disagree, the **approved proposal wins** — note any such conflict explicitly rather than +silently picking one. + +## Your task + +Implement the three sub-features in **dotnet/winforms**, production quality, against the +approved API surface. You have engineering latitude on *internals* — the public surface +is fixed by the proposal, the implementation is yours to do well. + +## Scope + +### A — `ISupportSuspendPainting` / `ISupportSuspendRelocation` +- The two interfaces, `System.Windows.Forms` namespace. +- `Control` implementation: refcounted painting suspension; lazy refcount state via the + existing property-store slot pattern (follow the established `Control` precedent — do + not add an eager field). Layout-suspension methods forward to existing + `SuspendLayout` / `ResumeLayout`. +- Overrides on `ListView`, `ListBox`, `ComboBox`, `TreeView`, `RichTextBox` forwarding + the painting methods to their existing `BeginUpdate` / `EndUpdate`. The existing public + `BeginUpdate` / `EndUpdate` signatures and behavior MUST NOT change. +- The user-facing scope type(s) and extension methods, exactly as the approved proposal + specifies them (`ref struct` vs `class` per the proposal's final decision). +- Unbalanced `End*` must match `ResumeLayout` precedent (the proposal will have settled + throw-vs-no-op; follow it). + +### B — `DeferLocationChange` + `DeferWindowPos` batching +- The scope and its overloads per the approved proposal. +- Win32 batching via `BeginDeferWindowPos` / `DeferWindowPos` / `EndDeferWindowPos`. + Capture and thread the returned `HDWP` correctly on every `DeferWindowPos` call. +- `Dispose` must handle a `NULL` HDWP coherently and must not leak on exception unwind. +- Compose paint suppression from sub-feature A; do not duplicate `WM_SETREDRAW` logic. + +### C — `Application.SetFormAppearanceMode` + `FormAppearanceMode` +- The enum (`Classic = 0`, `Deferred = 1`) and the `Application` configuration API. +- `Deferred` is the runtime default when the API is never called; `Classic` restores + pre-.NET 11 behavior. +- DWM cloaking at top-level form handle creation; uncloak per the timing strategy the + approved proposal settled on. +- Must be inert / safe when the OS does not support the relevant DWM attributes. + +## Engineering requirements + +- Target the C# language version and runtime of the current dotnet/winforms `main`. +- NRTs enabled; assume the repo's global usings. +- Match dotnet/winforms code style, P/Invoke conventions (CsWin32-generated `PInvoke` + surface), and the existing interop patterns — do not hand-roll `DllImport` if a + generated entry point exists. +- All public API gets XML docs. For `FormAppearanceMode.Deferred`, the flash-elimination + benefit goes in ``; the "background only, deep child trees may still update" + caveat goes in ``. +- Public API additions require matching entries in the `*.cs` reference-assembly / + public-API-baseline files the repo uses. +- Thread affinity: all of this assumes the UI thread; add debug assertions where the + repo already does, and do not let them affect release behavior. + +## Tests + +- Unit tests for refcount balance, including nesting and unbalanced-`End`. +- Tests that `ListView` et al. route through their native path and do not double-suspend. +- Tests for `DeferLocationChange` correctness including the `NULL`-HDWP fallback and + exception-unwind path. +- For `FormAppearanceMode`, tests for `Classic` (no behavior change) and `Deferred` + (cloak/uncloak lifecycle), plus the OS-unsupported fallback. + +## Deliverable + +A pull request (or a clear set of commits) implementing the above, with a PR description +that summarizes the change, links the API suggestion, and calls out any place the +implementation revealed a problem with the approved design that review should revisit. From b990c306fd777af1feed8d004ff53f9f77c4bc32 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:34 -0700 Subject: [PATCH 10/67] Add flicker-free mutation API proposal prompt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Create-Github-API-Proposal-Prompt.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md index e69de29bb2d..bc73ee83449 100644 --- a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md +++ b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md @@ -0,0 +1,155 @@ +# Copilot Prompt 1 — Author the WinForms API Suggestion (GitHub issue) + +## Your task + +Write a complete API suggestion for the **dotnet/winforms** repository, ready to be filed +as a GitHub issue with the `api-suggestion` label. The issue covers one cohesive feature +area — *flicker-free UI mutation in WinForms* — composed of **three severable sub-features**. + +You are not transcribing a settled spec. You are an experienced WinForms/.NET API designer +collaborating on this. The sections below give you **settled facts** and **current thinking +with reasoning**. Treat them differently (see "How to treat this briefing"). + +## How to treat this briefing + +- **Settled — do not change:** the three sub-features and their scope; the public API + *names* and *enum values* listed under "Settled API surface" below. These were + argued through already. +- **Current thinking — challenge freely:** every *mechanism*, *risk framing*, + *implementation strategy*, and *open question* below is our current lean with our + reasoning attached. If you find a stronger argument, pivot — and say why. Pitch + approaches as approaches, not gospel. We expect the API review board (and likely + Stephen Toub) to pressure-test the mechanism choices; pre-empt that. +- **Actively look for what we missed.** Compatibility hazards, interaction with existing + WinForms subsystems (data binding, `BindingSource`, `TableLayoutPanel`, MDI, DPI + changes, `Control.RecreateHandle`, accessibility/UIA, designer surface), threading, + trimming/AOT. If something here is wrong or naive, the most useful thing you can do + is say so. + +## Deliverable + +A single Markdown document structured as a fileable `api-suggestion` issue, using exactly +these sections (this is the dotnet/winforms house format): + +- `## Rationale` +- `## API Proposal` (C# signatures in fenced blocks, `namespace` declared) +- `## API Usage` +- `## Alternative Designs` +- `## Risks` +- `## Will this feature affect UI controls?` +- `### Status Checklist` (the standard api-suggestion checklist) + +If during drafting you conclude the three sub-features should be filed as separate issues +rather than one, say so explicitly at the top and structure accordingly — that is a +legitimate pivot. + +--- + +## Sub-feature A — `ISupportSuspendPainting` / `ISupportSuspendRelocation` + +### Settled API surface +- Two free-standing public interfaces, `System.Windows.Forms` namespace: + `ISupportSuspendPainting` with `BeginSuspendPainting()` / `EndSuspendPainting()`; + `ISupportSuspendRelocation` with `BeginSuspendRelocation()` / `EndSuspendRelocation()`. +- `Control` implements both. +- `ListView`, `ListBox`, `ComboBox`, `TreeView`, `RichTextBox` override the *painting* + methods to forward to their existing public `BeginUpdate` / `EndUpdate` (which remain + unchanged in shape and behavior — source and binary compat). +- User-facing scope objects + extension methods (`SuspendPainting()`, + `SuspendRelocation()`). + +### Current thinking — challenge freely +- **Default `Control` painting suspension** via `WM_SETREDRAW`, refcounted; resume edge + calls `Invalidate(true)`. Layout suspension forwards to existing + `SuspendLayout` / `ResumeLayout`. +- **Refcount state** lives lazily on `Control` via the existing property-store slot + pattern (zero cost until used). We considered default interface methods to avoid + touching `Control`; rejected because `WM_SETREDRAW` is not reentrant and DIMs cannot + hold per-instance state without a `ConditionalWeakTable` indirection that is strictly + worse. Re-test this conclusion. +- **Not tied to `IArrangedElement`** — deliberately. `IArrangedElement` is internal, and + `ToolStripItem` (an implementer) has no meaningful painting-suspension story. Future + HWND-less "visuals" should implement these interfaces directly with their own + mechanism. Evaluate whether the *relocation* interface specifically has a better home. +- **Scope type — our lean, expect pushback:** make the scopes `readonly ref struct` + (pattern-based `Dispose`, works with `using`) rather than `class : IDisposable`. + Reasoning: `ref struct` makes "forgot the `using`" / leaked-scope a *compile error*, + which is what lets us honestly downgrade the unbalanced-refcount risk. Tradeoff: no + `async`/iterator/lambda-capture/field storage, and you lose polymorphic `IDisposable` + return. We think that tradeoff is fine for synchronous "mutate now" code paths. + **This is a recommendation we expect to be pressure-tested in review — present both + options with the tradeoff and recommend, do not assert.** +- **Refcount risk framing:** even with `ref struct` scopes, the interface methods stay + `public` (designer-generated `InitializeComponent` must call them, and that code lives + in the user's assembly). So a developer *can* call them directly. The honest claim is + "the ergonomic path makes imbalance hard to hit accidentally; the refcount remains the + correctness backstop" — not "the risk is eliminated." Nested scopes are supported by + design, so the counter is necessary regardless. + +## Sub-feature B — `DeferLocationChange` + `DeferWindowPos` batching + +### Settled API surface +- A recommended user-facing entry point `DeferLocationChange()` returning a disposable + scope, with multi-arg overloads to opt out of individual bundled behaviors + (`suppressRender`, `suspendLayout`). + +### Current thinking — challenge freely +- The scope bundles three things for a "I'm about to move many children" code path: + Win32 `BeginDeferWindowPos` / `DeferWindowPos` / `EndDeferWindowPos` batching; + `SuspendLayout` / `ResumeLayout`; and paint suppression (compose this from + sub-feature A rather than duplicating `WM_SETREDRAW` logic). +- **Perf claim — be precise, do not overclaim.** `DeferWindowPos` improves *throughput*: + one synchronized native move pass instead of N `SetWindowPos` calls, each with its own + `WM_WINDOWPOSCHANGED`/`WM_SIZE`/invalidation/intermediate repaint. The + `SuspendLayout` bundling separately improves *computation*: N `PerformLayout` + invocations collapse to one. Neither speeds up the `LayoutEngine` algorithm itself. + The proposal must keep these two wins distinct and must NOT claim "the layout engine + got faster." +- **`HDWP` lifetime is the sharpest mechanical edge.** `BeginDeferWindowPos` allocates; + each `DeferWindowPos` *returns a new HDWP* (must be captured/threaded); on failure it + returns `NULL` and the *entire batch is lost*. The scope's `Dispose` must handle a + `NULL` HDWP coherently (fall back to individual `SetWindowPos`, or abort cleanly — + never `EndDeferWindowPos` on `NULL`) and must not leak a half-built HDWP if an + exception unwinds through the `using` body. This deserves its own risk bullet. +- Same `ref struct` recommendation as A applies to this scope. Note: if the scope is + `ref struct` it cannot be returned as `IDisposable` — evaluate whether the + multi-overload story still works (it should; `using` is pattern-based). + +## Sub-feature C — `Application.SetFormAppearanceMode` (deferred form display) + +### Settled API surface +- `Application.SetFormAppearanceMode(FormAppearanceMode mode)` — process-wide + configuration API, called early (before the first form), consistent in pattern and + lifecycle with `Application.SetColorMode` and `Application.SetHighDpiMode`. +- `enum FormAppearanceMode { Classic = 0, Deferred = 1 }`. +- `Classic` = pre-.NET 11 behavior (opt-out). `Deferred` = .NET 11 default. +- Note the deliberate split: `Classic` is the enum's *zero value* (conservative + `default`), while `Deferred` is the *runtime default* applied when the API is never + called. Call this out so review does not read it as a contradiction. + +### Current thinking — challenge freely +- Mechanism: cloak top-level forms via DWM (`DWMWA_CLOAK`) at handle creation, uncloak + once the background has been painted, so the form is revealed in one step instead of + flashing a default (white) background — most visible in dark mode. +- **Uncloak timing is genuinely open — this is the part most likely to need a better + idea.** Our naive lean is "uncloak after the first `WM_PAINT` that paints the form + background." Uncloak too early → still flashes; too late → window appears slow to + open. Unlike Edge, WinForms has no single universal "first real frame ready" signal — + it depends on double-buffering, custom `OnPaintBackground`, late-painting child + controls. Evaluate alternatives and recommend; flag remaining uncertainty honestly. +- **Honesty caveat that must survive into the docs:** deferral applies to the *form + background*. A deep tree of late-painting child controls can still produce visible + updates after reveal. The XML doc / proposal must state this so a late-child blink is + not later mis-filed as a regression. (The flash-elimination benefit belongs in the + XML ``; the caveat in ``.) +- Evaluate interaction with: MDI child forms, `Form.Show` vs `ShowDialog`, splash + screens / forms that *want* to appear instantly, owned/tool windows, per-monitor DPI + changes during creation, and `Form.Opacity` / layered windows. + +## Filing instruction + +If your final assessment is that the design is sound, produce the issue body ready to +file with the `api-suggestion` label. If you found a reason to pivot on anything outside +the settled API surface, lead with a short "Deviations from the briefing" note +explaining what you changed and why, then give the proposal. Either way, the proposal +itself is the deliverable. From eba1d0cfb0f8f8b4509584dea6a6cda97b93fe6a Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:45 -0700 Subject: [PATCH 11/67] Add flicker-free UI mutation APIs Implements suspend painting and relocation scopes, deferred child positioning, and form appearance mode infrastructure for .NET 11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/NativeMethods.txt | 6 +- .../PublicAPI.Unshipped.txt | 48 ++++ .../System/Windows/Forms/Application.cs | 40 ++++ .../Windows/Forms/Control.SuspendMutation.cs | 105 +++++++++ .../System/Windows/Forms/Control.cs | 28 ++- .../Forms/ControlMutationExtensions.cs | 28 +++ .../ComboBox/ComboBox.SuspendMutation.cs | 25 ++ .../ListBoxes/ListBox.SuspendMutation.cs | 25 ++ .../ListView/ListView.SuspendMutation.cs | 25 ++ .../RichTextBox.SuspendMutation.cs | 25 ++ .../TreeView/TreeView.SuspendMutation.cs | 25 ++ .../Windows/Forms/DeferLocationChangeScope.cs | 221 ++++++++++++++++++ .../Windows/Forms/Form.AppearanceMode.cs | 64 +++++ .../System/Windows/Forms/Form.cs | 16 ++ .../Windows/Forms/FormAppearanceMode.cs | 28 +++ .../Windows/Forms/ISupportSuspendPainting.cs | 22 ++ .../Forms/ISupportSuspendRelocation.cs | 22 ++ .../Windows/Forms/SuspendPaintingScope.cs | 29 +++ .../Windows/Forms/SuspendRelocationScope.cs | 29 +++ 19 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs diff --git a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt index a56634792b1..751a668cde2 100644 --- a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt +++ b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt @@ -7,6 +7,7 @@ ADVF AreDpiAwarenessContextsEqual ARW_* AUTOCOMPLETEOPTIONS +BeginDeferWindowPos BFFM_* BIF_* BITMAP @@ -62,6 +63,7 @@ DATETIMEPICK_CLASS DeactivateActCtx DefFrameProc DefMDIChildProc +DeferWindowPos DESKTOP_ACCESS_FLAGS DestroyAcceleratorTable DestroyCursor @@ -92,6 +94,7 @@ DTM_* DTN_* DTS_* DuplicateHandle +DWMWINDOWATTRIBUTE DWM_WINDOW_CORNER_PREFERENCE DwmGetWindowAttribute DwmSetWindowAttribute @@ -104,6 +107,7 @@ EN_* EnableMenuItem EnableScrollBar EnableWindow +EndDeferWindowPos EndDialog ENM_* EnumDisplaySettings @@ -697,4 +701,4 @@ WINEVENT_INCONTEXT WSF_VISIBLE XBUTTON1 XBUTTON2 -XFORMCOORDS \ No newline at end of file +XFORMCOORDS diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..7c4f02d4a7f 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,48 @@ +System.Windows.Forms.ControlMutationExtensions +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope +static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope +System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.FormAppearanceMode.Classic = 0 -> System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.FormAppearanceMode.Deferred = 1 -> System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.ISupportSuspendPainting +System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void +System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> void +System.Windows.Forms.ISupportSuspendRelocation +System.Windows.Forms.ISupportSuspendRelocation.BeginSuspendRelocation() -> void +System.Windows.Forms.ISupportSuspendRelocation.EndSuspendRelocation() -> void +System.Windows.Forms.SuspendPaintingScope +System.Windows.Forms.SuspendPaintingScope.Dispose() -> void +System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope() -> void +System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Forms.ISupportSuspendPainting? target) -> void +System.Windows.Forms.SuspendRelocationScope +System.Windows.Forms.SuspendRelocationScope.Dispose() -> void +System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope() -> void +System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope(System.Windows.Forms.ISupportSuspendRelocation? target) -> void +override System.Windows.Forms.ComboBox.BeginSuspendPainting() -> void +override System.Windows.Forms.ComboBox.EndSuspendPainting() -> void +override System.Windows.Forms.ListBox.BeginSuspendPainting() -> void +override System.Windows.Forms.ListBox.EndSuspendPainting() -> void +override System.Windows.Forms.ListView.BeginSuspendPainting() -> void +override System.Windows.Forms.ListView.EndSuspendPainting() -> void +override System.Windows.Forms.RichTextBox.BeginSuspendPainting() -> void +override System.Windows.Forms.RichTextBox.EndSuspendPainting() -> void +override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void +override System.Windows.Forms.TreeView.EndSuspendPainting() -> void +static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode +static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void +System.Windows.Forms.Control.DeferLocationChange() -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.Control.DeferLocationChange(bool suppressRender) -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.Control.DeferLocationChange(bool suppressRender, bool suspendLayout) -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope() -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y) -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y, int width, int height) -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, System.Drawing.Rectangle bounds) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender, bool suspendLayout) -> void +System.Windows.Forms.DeferLocationChangeScope.Dispose() -> void +virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void +virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void +virtual System.Windows.Forms.Control.EndSuspendPainting() -> void +virtual System.Windows.Forms.Control.EndSuspendRelocation() -> void diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index aab496c2744..fd1fc9ba89c 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -44,6 +44,9 @@ public sealed partial class Application private static bool s_useWaitCursor; private static SystemColorMode? s_colorMode; +#if NET11_0_OR_GREATER + private static FormAppearanceMode? s_formAppearanceMode; +#endif private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; private const string DarkModeKey = "AppsUseLightTheme"; @@ -249,6 +252,21 @@ internal static bool CustomThreadExceptionHandlerAttached /// public static SystemColorMode ColorMode => s_colorMode ?? SystemColorMode.Classic; +#if NET11_0_OR_GREATER + /// + /// Gets the configured form appearance mode for the application. + /// + /// + /// + /// If no mode has been configured with , + /// WinForms uses . + /// + /// + public static FormAppearanceMode FormAppearanceMode + => s_formAppearanceMode ?? FormAppearanceMode.Deferred; + +#endif + /// /// True if the has been set at least once. /// @@ -381,6 +399,28 @@ static void NotifySystemEventsOfColorChange() } } +#if NET11_0_OR_GREATER + /// + /// Sets the process-wide form appearance mode. + /// + /// The form appearance mode to use for newly created forms. + /// + /// + /// Set the form appearance mode before creating UI to ensure newly created forms use the intended + /// startup presentation behavior. + /// + /// + /// + /// is not a valid value. + /// + public static void SetFormAppearanceMode(FormAppearanceMode mode) + { + SourceGenerated.EnumValidator.Validate(mode, nameof(mode)); + s_formAppearanceMode = mode; + } + +#endif + internal static Font DefaultFont => s_defaultFontScaled ?? s_defaultFont!; /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs new file mode 100644 index 00000000000..bd4c5c3398d --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -0,0 +1,105 @@ +// 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; + +#if NET11_0_OR_GREATER +public unsafe partial class Control : + ISupportSuspendPainting, + ISupportSuspendRelocation +#else +public unsafe partial class Control +#endif +{ +#if NET11_0_OR_GREATER + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange() + => new(this); + + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange(bool suppressRender) + => new(this, suppressRender); + + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// + /// to suspend layout while changes are deferred; otherwise, + /// . + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange(bool suppressRender, bool suspendLayout) + => new(this, suppressRender, suspendLayout); + + /// + /// Begins a painting suspension region for this control. + /// + public virtual void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + /// Ends a painting suspension region for this control. + /// + public virtual void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(invalidate: true); + } + } + + /// + /// Begins a relocation suspension region for this control. + /// + public virtual void BeginSuspendRelocation() => SuspendLayout(); + + /// + /// Ends a relocation suspension region for this control. + /// + public virtual void EndSuspendRelocation() => ResumeLayout(); + + internal bool BeginSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + Properties.AddOrRemoveValue( + s_suspendPaintingCountProperty, + suspendPaintingCount + 1, + defaultValue: 0); + + return true; + } + + internal bool EndSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + if (suspendPaintingCount == 0) + { + return false; + } + + Properties.AddOrRemoveValue( + s_suspendPaintingCountProperty, + suspendPaintingCount - 1, + defaultValue: 0); + + return true; + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 14efc5014ff..f4961ae73d6 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -227,6 +227,10 @@ public unsafe partial class Control : private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_suspendPaintingCountProperty = PropertyStore.CreateKey(); +#endif + private static readonly int s_updateCountProperty = PropertyStore.CreateKey(); private static bool s_needToLoadComCtl = true; @@ -268,7 +272,6 @@ public unsafe partial class Control : // bits 0-4: BoundsSpecified stored in RequiredScaling property. Bit 5: RequiredScalingEnabled property. private byte _requiredScaling; private TRACKMOUSEEVENT _trackMouseEvent; - private short _updateCount; private LayoutEventArgs? _cachedLayoutEventArgs; private Queue? _threadCallbackList; @@ -4379,12 +4382,16 @@ internal void BeginUpdateInternal() return; } - if (_updateCount == 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount == 0) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); } - _updateCount++; + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount + 1, + defaultValue: 0); } /// @@ -5070,11 +5077,17 @@ public void DrawToBitmap(Bitmap bitmap, Rectangle targetBounds) internal bool EndUpdateInternal(bool invalidate) { - if (_updateCount > 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount > 0) { Debug.Assert(IsHandleCreated, "Handle should be created by now"); - _updateCount--; - if (_updateCount == 0) + updateCount--; + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount, + defaultValue: 0); + + if (updateCount == 0) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)true); if (invalidate) @@ -5349,7 +5362,8 @@ private static bool IsFocusManagingContainerControl(Control ctl) /// by calling "WM_SETREDRAW" even if the control in "Begin - End" update cycle. Using this Function we can guard /// against repetitively redrawing the control. /// - internal bool IsUpdating() => _updateCount > 0; + internal bool IsUpdating() + => Properties.GetValueOrDefault(s_updateCountProperty, 0) > 0; /// /// This is a helper method that is called by ScaleControl to retrieve the bounds diff --git a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs new file mode 100644 index 00000000000..3d2a87fcf82 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs @@ -0,0 +1,28 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Provides extension methods for batching synchronous WinForms UI mutations. +/// +public static class ControlMutationExtensions +{ + /// + /// Suspends painting for the specified target until the returned scope is disposed. + /// + /// The target whose painting should be suspended. + /// A scope that resumes painting when disposed. + public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting target) + => new(target); + + /// + /// Suspends relocation work for the specified target until the returned scope is disposed. + /// + /// The target whose relocation work should be suspended. + /// A scope that resumes relocation work when disposed. + public static SuspendRelocationScope SuspendRelocation(this ISupportSuspendRelocation target) + => new(target); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs new file mode 100644 index 00000000000..348ab18cb24 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ComboBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs new file mode 100644 index 00000000000..cd21cc7e8ee --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs new file mode 100644 index 00000000000..dbee7d27836 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListView +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs new file mode 100644 index 00000000000..213ee92a195 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class RichTextBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs new file mode 100644 index 00000000000..16817872b60 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class TreeView +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs new file mode 100644 index 00000000000..c310dbb9e9d --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs @@ -0,0 +1,221 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Batches child-control location changes until the scope is disposed. +/// +/// +/// +/// This scope uses the Win32 deferred window-position API when possible. If a deferred native batch +/// cannot be created or is lost, the collected changes are applied individually when the scope is disposed. +/// +/// +public readonly ref struct DeferLocationChangeScope +{ + private readonly State? _state; + private readonly SuspendPaintingScope _paintingScope; + private readonly SuspendRelocationScope _relocationScope; + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + public DeferLocationChangeScope(Control parent) + : this(parent, suppressRender: true, suspendLayout: true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + public DeferLocationChangeScope(Control parent, bool suppressRender) + : this(parent, suppressRender, suspendLayout: true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// + /// to suspend layout while changes are deferred; otherwise, + /// . + /// + public DeferLocationChangeScope(Control parent, bool suppressRender, bool suspendLayout) + { + ArgumentNullException.ThrowIfNull(parent); + + _state = new(parent); + _paintingScope = suppressRender + ? new SuspendPaintingScope(parent) + : default; + _relocationScope = suspendLayout + ? new SuspendRelocationScope(parent) + : default; + } + + /// + /// Defers moving a control to the specified location. + /// + /// The control to move. + /// The deferred x-coordinate. + /// The deferred y-coordinate. + /// is . + public void Defer(Control control, int x, int y) + { + ArgumentNullException.ThrowIfNull(control); + Size size = control.Size; + _state?.Defer(control, new Rectangle(x, y, size.Width, size.Height)); + } + + /// + /// Defers moving and resizing a control to the specified bounds. + /// + /// The control to move and resize. + /// The deferred x-coordinate. + /// The deferred y-coordinate. + /// The deferred width. + /// The deferred height. + /// is . + public void Defer(Control control, int x, int y, int width, int height) + { + ArgumentNullException.ThrowIfNull(control); + _state?.Defer(control, new Rectangle(x, y, width, height)); + } + + /// + /// Defers moving and resizing a control to the specified bounds. + /// + /// The control to move and resize. + /// The deferred bounds. + /// is . + public void Defer(Control control, Rectangle bounds) + { + ArgumentNullException.ThrowIfNull(control); + _state?.Defer(control, bounds); + } + + /// + /// Applies the deferred location changes and resumes any bundled suspension scopes. + /// + public void Dispose() + { + _state?.Dispose(); + _relocationScope.Dispose(); + _paintingScope.Dispose(); + } + + /// + /// Holds mutable deferred-position state for . + /// + private sealed class State + { + private readonly Control _parent; + private readonly List _deferredPositions = []; + private HDWP _hdwp; + private bool _batchFailed; + + public State(Control parent) + { + _parent = parent; + _hdwp = parent.IsHandleCreated && parent.Controls.Count > 0 + ? PInvoke.BeginDeferWindowPos(parent.Controls.Count) + : HDWP.Null; + _batchFailed = _hdwp.IsNull || !parent.IsHandleCreated; + } + + public void Defer(Control control, Rectangle bounds) + { + DeferredWindowPosition deferredPosition = new(control, bounds); + _deferredPositions.Add(deferredPosition); + + if (_batchFailed) + { + return; + } + + if (!control.IsHandleCreated) + { + _batchFailed = true; + + return; + } + + HDWP hdwp = PInvoke.DeferWindowPos( + _hdwp, + (HWND)control.Handle, + HWND.Null, + bounds.X, + bounds.Y, + bounds.Width, + bounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + + if (hdwp.IsNull) + { + _hdwp = HDWP.Null; + _batchFailed = true; + + return; + } + + _hdwp = hdwp; + } + + public void Dispose() + { + if (!_batchFailed && !_hdwp.IsNull && PInvoke.EndDeferWindowPos(_hdwp)) + { + return; + } + + if (_batchFailed && !_hdwp.IsNull) + { + PInvoke.EndDeferWindowPos(_hdwp); + } + + foreach (DeferredWindowPosition deferredPosition in _deferredPositions) + { + Control control = deferredPosition.Control; + Rectangle bounds = deferredPosition.Bounds; + if (control.IsHandleCreated) + { + PInvoke.SetWindowPos( + control, + HWND.Null, + bounds.X, + bounds.Y, + bounds.Width, + bounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + } + else + { + control.Bounds = bounds; + } + } + + _parent.Invalidate(invalidateChildren: true); + } + } + + /// + /// Represents a deferred window-position request. + /// + private readonly record struct DeferredWindowPosition(Control Control, Rectangle Bounds); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs new file mode 100644 index 00000000000..853bdd55346 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Windows.Win32.Graphics.Dwm; + +namespace System.Windows.Forms; + +public partial class Form +{ +#if NET11_0_OR_GREATER + private bool DeferredAppearanceCloaked + { + get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); + set => Properties.AddOrRemoveValue(s_propFormAppearanceCloaked, value, defaultValue: false); + } + + private void CloakForDeferredAppearanceIfNeeded() + { + if (!ShouldUseDeferredAppearanceCloak()) + { + return; + } + + if (SetDwmCloak(cloaked: true)) + { + DeferredAppearanceCloaked = true; + } + } + + private void UncloakDeferredAppearanceIfNeeded() + { + if (!DeferredAppearanceCloaked) + { + return; + } + + if (SetDwmCloak(cloaked: false)) + { + DeferredAppearanceCloaked = false; + } + } + + private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; + + private bool ShouldUseDeferredAppearanceCloak() + => Application.FormAppearanceMode == FormAppearanceMode.Deferred + && TopLevel + && !IsMdiChild + && Visible + && IsHandleCreated; + + private unsafe bool SetDwmCloak(bool cloaked) + { + BOOL cloak = cloaked; + HRESULT result = PInvoke.DwmSetWindowAttribute( + HWND, + DWMWINDOWATTRIBUTE.DWMWA_CLOAK, + &cloak, + (uint)sizeof(BOOL)); + + return result.Succeeded; + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index c3900d09c6b..62055889de5 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -137,6 +137,9 @@ public partial class Form : ContainerControl private static readonly int s_propFormCaptionTextColor = PropertyStore.CreateKey(); private static readonly int s_propFormCaptionBackColor = PropertyStore.CreateKey(); private static readonly int s_propFormScreenCaptureMode = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_propFormAppearanceCloaked = PropertyStore.CreateKey(); +#endif // Form per instance members // Note: Do not add anything to this list unless absolutely necessary. @@ -4209,6 +4212,10 @@ protected override void OnHandleCreated(EventArgs e) { SetScreenCaptureModeInternal(FormScreenCaptureMode); } + +#if NET11_0_OR_GREATER + CloakForDeferredAppearanceIfNeeded(); +#endif } /// @@ -4219,6 +4226,9 @@ protected override void OnHandleCreated(EventArgs e) [EditorBrowsable(EditorBrowsableState.Advanced)] protected override void OnHandleDestroyed(EventArgs e) { +#if NET11_0_OR_GREATER + ClearDeferredAppearanceCloakState(); +#endif base.OnHandleDestroyed(e); _formStateEx[s_formStateExUseMdiChildProc] = 0; @@ -7175,6 +7185,12 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_ERASEBKGND: WmEraseBkgnd(ref m); break; + case PInvokeCore.WM_PAINT: + base.WndProc(ref m); +#if NET11_0_OR_GREATER + UncloakDeferredAppearanceIfNeeded(); +#endif + break; case PInvokeCore.WM_NCDESTROY: WmNCDestroy(ref m); diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs new file mode 100644 index 00000000000..f5e76f27151 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs @@ -0,0 +1,28 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Specifies how WinForms presents a form while its initial appearance is prepared. +/// +public enum FormAppearanceMode +{ + /// + /// Uses the classic WinForms form presentation behavior. + /// + Classic = 0, + + /// + /// Defers the initial top-level form presentation to help prevent default-background flash. + /// + /// + /// + /// Deferral applies to the form background. Deep child-control trees can still produce visible + /// updates after the form is shown. + /// + /// + Deferred = 1 +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs new file mode 100644 index 00000000000..77c3ea39ee5 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides methods for temporarily suspending and resuming painting. +/// +public interface ISupportSuspendPainting +{ + /// + /// Begins a painting suspension region. + /// + void BeginSuspendPainting(); + + /// + /// Ends a painting suspension region. + /// + void EndSuspendPainting(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs new file mode 100644 index 00000000000..23338950127 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides methods for temporarily suspending and resuming relocation work. +/// +public interface ISupportSuspendRelocation +{ + /// + /// Begins a relocation suspension region. + /// + void BeginSuspendRelocation(); + + /// + /// Ends a relocation suspension region. + /// + void EndSuspendRelocation(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs new file mode 100644 index 00000000000..1a3bf4a0c4f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -0,0 +1,29 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Suspends painting for a target until the scope is disposed. +/// +public readonly ref struct SuspendPaintingScope +{ + private readonly ISupportSuspendPainting? _target; + + /// + /// Initializes a new instance of the struct. + /// + /// The target whose painting should be suspended. + public SuspendPaintingScope(ISupportSuspendPainting? target) + { + _target = target; + _target?.BeginSuspendPainting(); + } + + /// + /// Resumes painting for the target associated with this scope. + /// + public void Dispose() => _target?.EndSuspendPainting(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs new file mode 100644 index 00000000000..5289fedd284 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs @@ -0,0 +1,29 @@ +// 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; + +#if NET11_0_OR_GREATER +/// +/// Suspends relocation work for a target until the scope is disposed. +/// +public readonly ref struct SuspendRelocationScope +{ + private readonly ISupportSuspendRelocation? _target; + + /// + /// Initializes a new instance of the struct. + /// + /// The target whose relocation work should be suspended. + public SuspendRelocationScope(ISupportSuspendRelocation? target) + { + _target = target; + _target?.BeginSuspendRelocation(); + } + + /// + /// Resumes relocation work for the target associated with this scope. + /// + public void Dispose() => _target?.EndSuspendRelocation(); +} +#endif From 9f399c5fb17c623782726f8335c302337540e638 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:49 -0700 Subject: [PATCH 12/67] Add tests for flicker-free mutation APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Windows/Forms/ApplicationTests.cs | 25 +++++++++ .../Windows/Forms/ControlTests.Methods.cs | 51 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs index 47013617e97..a4eeb2cf967 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs @@ -180,6 +180,31 @@ public void Application_SetColorMode_PlausibilityTests() #pragma warning restore SYSLIB5002 +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Application_FormAppearanceMode_Default_ReturnsDeferred() + { + Assert.Equal(FormAppearanceMode.Deferred, Application.FormAppearanceMode); + } + + [WinFormsTheory] + [InlineData(FormAppearanceMode.Classic)] + [InlineData(FormAppearanceMode.Deferred)] + public void Application_SetFormAppearanceMode_GetReturnsExpected(FormAppearanceMode mode) + { + Application.SetFormAppearanceMode(mode); + Assert.Equal(mode, Application.FormAppearanceMode); + } + + [WinFormsFact] + public void Application_SetFormAppearanceMode_Invalid_ThrowsInvalidEnumArgumentException() + { + Assert.Throws( + "mode", + () => Application.SetFormAppearanceMode((FormAppearanceMode)int.MaxValue)); + } +#endif + [WinFormsFact] public void Application_DefaultFont_ReturnsNull_IfNoFontSet() { diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs index 18ee42ff6be..d2c8d96dba5 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs @@ -15,6 +15,57 @@ namespace System.Windows.Forms.Tests; public partial class ControlTests { +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Control_BeginEndSuspendPainting_InvokeWithoutHandle_Success() + { + using SubControl control = new(); + + control.BeginSuspendPainting(); + control.EndSuspendPainting(); + control.EndSuspendPainting(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_SuspendPainting_ScopeDisposes_Success() + { + using SubControl control = new(); + + using SuspendPaintingScope scope = control.SuspendPainting(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + { + using SubControl control = new(); + + control.BeginSuspendRelocation(); + control.EndSuspendRelocation(); + control.EndSuspendRelocation(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_DeferLocationChange_DisposeWithoutHandles_AppliesBounds() + { + using SubControl parent = new(); + using SubControl child = new(); + parent.Controls.Add(child); + + using (DeferLocationChangeScope scope = parent.DeferLocationChange()) + { + scope.Defer(child, new Rectangle(1, 2, 3, 4)); + } + + Assert.Equal(new Rectangle(1, 2, 3, 4), child.Bounds); + } +#endif + public static IEnumerable AccessibilityNotifyClients_AccessibleEvents_Int_TestData() { yield return new object[] { AccessibleEvents.DescriptionChange, int.MinValue }; From 9eea1a9b0997f086d0c506b292759677ae69f5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 18:13:55 -0700 Subject: [PATCH 13/67] Remove DeferLocationChange; make suspend-mutation hooks explicit-interface Splits DeferLocationChange out of this branch entirely (postponed pending an anchor-layout-engine integration fix; tracked separately in KlausLoeffelmann/winforms#12) and reworks ISupportSuspendPainting / ISupportSuspendRelocation on Control to match the final API shape agreed in dotnet/winforms#14585: - Control implements ISupportSuspendPainting/ISupportSuspendRelocation via explicit interface implementation instead of public virtual methods, so the manual Begin/End pair does not become the primary IntelliSense surface on every Control-derived type. Protected virtual BeginSuspendPaintingCore() / EndSuspendPaintingCore() / BeginSuspendRelocationCore() / EndSuspendRelocationCore() are the new override points. - ListView, ListBox, ComboBox, TreeView, RichTextBox override the ...Core() hooks instead of the old public virtual methods, still routing through their existing BeginUpdate/EndUpdate. - SuspendPaintingScope and SuspendRelocationScope change from readonly ref struct to sealed class : IDisposable, so the scope can span an await in an asynchronous UI event handler (a ref struct cannot be hoisted into an async state machine - this was the flagship usage scenario in the original API proposal, and it did not compile against the ref struct version). Dispose is idempotent. - Deletes DeferLocationChangeScope.cs and the Control.DeferLocationChange overloads entirely. - Updates/removes tests accordingly; updates PublicAPI.Unshipped.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 12 - .../Windows/Forms/Control.SuspendMutation.cs | 59 +++-- .../ComboBox/ComboBox.SuspendMutation.cs | 4 +- .../ListBoxes/ListBox.SuspendMutation.cs | 4 +- .../ListView/ListView.SuspendMutation.cs | 4 +- .../RichTextBox.SuspendMutation.cs | 4 +- .../TreeView/TreeView.SuspendMutation.cs | 4 +- .../Windows/Forms/DeferLocationChangeScope.cs | 221 ------------------ ...m.AppearanceMode.cs => Form.RevealMode.cs} | 0 ...ormAppearanceMode.cs => FormRevealMode.cs} | 0 .../Windows/Forms/SuspendPaintingScope.cs | 22 +- .../Windows/Forms/SuspendRelocationScope.cs | 19 +- .../Windows/Forms/ControlTests.Methods.cs | 42 ++-- 13 files changed, 96 insertions(+), 299 deletions(-) delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs rename src/System.Windows.Forms/System/Windows/Forms/{Form.AppearanceMode.cs => Form.RevealMode.cs} (100%) rename src/System.Windows.Forms/System/Windows/Forms/{FormAppearanceMode.cs => FormRevealMode.cs} (100%) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 7c4f02d4a7f..72180973d18 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -30,18 +30,6 @@ override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void override System.Windows.Forms.TreeView.EndSuspendPainting() -> void static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void -System.Windows.Forms.Control.DeferLocationChange() -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.Control.DeferLocationChange(bool suppressRender) -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.Control.DeferLocationChange(bool suppressRender, bool suspendLayout) -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope() -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y) -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y, int width, int height) -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, System.Drawing.Rectangle bounds) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender, bool suspendLayout) -> void -System.Windows.Forms.DeferLocationChangeScope.Dispose() -> void virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void virtual System.Windows.Forms.Control.EndSuspendPainting() -> void diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs index bd4c5c3398d..0c2d6cae960 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -13,51 +13,46 @@ public unsafe partial class Control { #if NET11_0_OR_GREATER /// - /// Defers child-control location changes until the returned scope is disposed. + /// Begins a painting suspension region for this control. /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange() - => new(this); + void ISupportSuspendPainting.BeginSuspendPainting() => BeginSuspendPaintingCore(); /// - /// Defers child-control location changes until the returned scope is disposed. + /// Ends a painting suspension region for this control. /// - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange(bool suppressRender) - => new(this, suppressRender); + void ISupportSuspendPainting.EndSuspendPainting() => EndSuspendPaintingCore(); /// - /// Defers child-control location changes until the returned scope is disposed. + /// Begins a relocation suspension region for this control. /// - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// - /// to suspend layout while changes are deferred; otherwise, - /// . - /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange(bool suppressRender, bool suspendLayout) - => new(this, suppressRender, suspendLayout); + void ISupportSuspendRelocation.BeginSuspendRelocation() => BeginSuspendRelocationCore(); /// - /// Begins a painting suspension region for this control. + /// Ends a relocation suspension region for this control. /// - public virtual void BeginSuspendPainting() + void ISupportSuspendRelocation.EndSuspendRelocation() => EndSuspendRelocationCore(); + + /// + /// When overridden in a derived class, begins a painting suspension region for this control. + /// + /// + /// + /// The default implementation suppresses native painting through . + /// Controls that already have a public update mechanism (such as ) + /// should override this method to route through that existing mechanism instead of introducing a + /// second, competing suspension path. + /// + /// + protected virtual void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdateInternal(); } /// - /// Ends a painting suspension region for this control. + /// When overridden in a derived class, ends a painting suspension region for this control. /// - public virtual void EndSuspendPainting() + protected virtual void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { @@ -66,14 +61,14 @@ public virtual void EndSuspendPainting() } /// - /// Begins a relocation suspension region for this control. + /// When overridden in a derived class, begins a relocation suspension region for this control. /// - public virtual void BeginSuspendRelocation() => SuspendLayout(); + protected virtual void BeginSuspendRelocationCore() => SuspendLayout(); /// - /// Ends a relocation suspension region for this control. + /// When overridden in a derived class, ends a relocation suspension region for this control. /// - public virtual void EndSuspendRelocation() => ResumeLayout(); + protected virtual void EndSuspendRelocationCore() => ResumeLayout(); internal bool BeginSuspendPaintingScope() { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs index 348ab18cb24..9a90bcda243 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ComboBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs index cd21cc7e8ee..e26361db974 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ListBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs index dbee7d27836..1fbe0660b39 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ListView { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs index 213ee92a195..4625a1cb225 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class RichTextBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdateInternal(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs index 16817872b60..f9fc5d4d629 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class TreeView { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs deleted file mode 100644 index c310dbb9e9d..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs +++ /dev/null @@ -1,221 +0,0 @@ -// 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; - -#if NET11_0_OR_GREATER -/// -/// Batches child-control location changes until the scope is disposed. -/// -/// -/// -/// This scope uses the Win32 deferred window-position API when possible. If a deferred native batch -/// cannot be created or is lost, the collected changes are applied individually when the scope is disposed. -/// -/// -public readonly ref struct DeferLocationChangeScope -{ - private readonly State? _state; - private readonly SuspendPaintingScope _paintingScope; - private readonly SuspendRelocationScope _relocationScope; - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - public DeferLocationChangeScope(Control parent) - : this(parent, suppressRender: true, suspendLayout: true) - { - } - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - public DeferLocationChangeScope(Control parent, bool suppressRender) - : this(parent, suppressRender, suspendLayout: true) - { - } - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// - /// to suspend layout while changes are deferred; otherwise, - /// . - /// - public DeferLocationChangeScope(Control parent, bool suppressRender, bool suspendLayout) - { - ArgumentNullException.ThrowIfNull(parent); - - _state = new(parent); - _paintingScope = suppressRender - ? new SuspendPaintingScope(parent) - : default; - _relocationScope = suspendLayout - ? new SuspendRelocationScope(parent) - : default; - } - - /// - /// Defers moving a control to the specified location. - /// - /// The control to move. - /// The deferred x-coordinate. - /// The deferred y-coordinate. - /// is . - public void Defer(Control control, int x, int y) - { - ArgumentNullException.ThrowIfNull(control); - Size size = control.Size; - _state?.Defer(control, new Rectangle(x, y, size.Width, size.Height)); - } - - /// - /// Defers moving and resizing a control to the specified bounds. - /// - /// The control to move and resize. - /// The deferred x-coordinate. - /// The deferred y-coordinate. - /// The deferred width. - /// The deferred height. - /// is . - public void Defer(Control control, int x, int y, int width, int height) - { - ArgumentNullException.ThrowIfNull(control); - _state?.Defer(control, new Rectangle(x, y, width, height)); - } - - /// - /// Defers moving and resizing a control to the specified bounds. - /// - /// The control to move and resize. - /// The deferred bounds. - /// is . - public void Defer(Control control, Rectangle bounds) - { - ArgumentNullException.ThrowIfNull(control); - _state?.Defer(control, bounds); - } - - /// - /// Applies the deferred location changes and resumes any bundled suspension scopes. - /// - public void Dispose() - { - _state?.Dispose(); - _relocationScope.Dispose(); - _paintingScope.Dispose(); - } - - /// - /// Holds mutable deferred-position state for . - /// - private sealed class State - { - private readonly Control _parent; - private readonly List _deferredPositions = []; - private HDWP _hdwp; - private bool _batchFailed; - - public State(Control parent) - { - _parent = parent; - _hdwp = parent.IsHandleCreated && parent.Controls.Count > 0 - ? PInvoke.BeginDeferWindowPos(parent.Controls.Count) - : HDWP.Null; - _batchFailed = _hdwp.IsNull || !parent.IsHandleCreated; - } - - public void Defer(Control control, Rectangle bounds) - { - DeferredWindowPosition deferredPosition = new(control, bounds); - _deferredPositions.Add(deferredPosition); - - if (_batchFailed) - { - return; - } - - if (!control.IsHandleCreated) - { - _batchFailed = true; - - return; - } - - HDWP hdwp = PInvoke.DeferWindowPos( - _hdwp, - (HWND)control.Handle, - HWND.Null, - bounds.X, - bounds.Y, - bounds.Width, - bounds.Height, - SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); - - if (hdwp.IsNull) - { - _hdwp = HDWP.Null; - _batchFailed = true; - - return; - } - - _hdwp = hdwp; - } - - public void Dispose() - { - if (!_batchFailed && !_hdwp.IsNull && PInvoke.EndDeferWindowPos(_hdwp)) - { - return; - } - - if (_batchFailed && !_hdwp.IsNull) - { - PInvoke.EndDeferWindowPos(_hdwp); - } - - foreach (DeferredWindowPosition deferredPosition in _deferredPositions) - { - Control control = deferredPosition.Control; - Rectangle bounds = deferredPosition.Bounds; - if (control.IsHandleCreated) - { - PInvoke.SetWindowPos( - control, - HWND.Null, - bounds.X, - bounds.Y, - bounds.Width, - bounds.Height, - SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); - } - else - { - control.Bounds = bounds; - } - } - - _parent.Invalidate(invalidateChildren: true); - } - } - - /// - /// Represents a deferred window-position request. - /// - private readonly record struct DeferredWindowPosition(Control Control, Rectangle Bounds); -} -#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs rename to src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs rename to src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs index 1a3bf4a0c4f..c596dfbc452 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -7,12 +7,20 @@ namespace System.Windows.Forms; /// /// Suspends painting for a target until the scope is disposed. /// -public readonly ref struct SuspendPaintingScope +/// +/// +/// This is a sealed class rather than a ref struct so the scope can span an +/// in an asynchronous UI event handler (for example, suspending painting for +/// the duration of an async data reload). is idempotent: disposing the scope more +/// than once only resumes painting once. +/// +/// +public sealed class SuspendPaintingScope : IDisposable { - private readonly ISupportSuspendPainting? _target; + private ISupportSuspendPainting? _target; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The target whose painting should be suspended. public SuspendPaintingScope(ISupportSuspendPainting? target) @@ -24,6 +32,12 @@ public SuspendPaintingScope(ISupportSuspendPainting? target) /// /// Resumes painting for the target associated with this scope. /// - public void Dispose() => _target?.EndSuspendPainting(); + public void Dispose() + { + // Idempotent: only the first Dispose call should resume painting, since the underlying + // refcount on the target was only incremented once, in the constructor. + _target?.EndSuspendPainting(); + _target = null; + } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs index 5289fedd284..a86acb0e42f 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs @@ -7,12 +7,19 @@ namespace System.Windows.Forms; /// /// Suspends relocation work for a target until the scope is disposed. /// -public readonly ref struct SuspendRelocationScope +/// +/// +/// This is a sealed class rather than a ref struct so the scope can span an +/// in an asynchronous UI event handler. is idempotent: +/// disposing the scope more than once only resumes relocation work once. +/// +/// +public sealed class SuspendRelocationScope : IDisposable { - private readonly ISupportSuspendRelocation? _target; + private ISupportSuspendRelocation? _target; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The target whose relocation work should be suspended. public SuspendRelocationScope(ISupportSuspendRelocation? target) @@ -24,6 +31,10 @@ public SuspendRelocationScope(ISupportSuspendRelocation? target) /// /// Resumes relocation work for the target associated with this scope. /// - public void Dispose() => _target?.EndSuspendRelocation(); + public void Dispose() + { + _target?.EndSuspendRelocation(); + _target = null; + } } #endif diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs index d2c8d96dba5..fa6b67eb5a2 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs @@ -20,10 +20,11 @@ public partial class ControlTests public void Control_BeginEndSuspendPainting_InvokeWithoutHandle_Success() { using SubControl control = new(); + ISupportSuspendPainting suspendPainting = control; - control.BeginSuspendPainting(); - control.EndSuspendPainting(); - control.EndSuspendPainting(); + suspendPainting.BeginSuspendPainting(); + suspendPainting.EndSuspendPainting(); + suspendPainting.EndSuspendPainting(); Assert.False(control.IsHandleCreated); } @@ -39,30 +40,39 @@ public void Control_SuspendPainting_ScopeDisposes_Success() } [WinFormsFact] - public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + public void Control_SuspendPainting_ScopeDispose_IsIdempotent() { using SubControl control = new(); - control.BeginSuspendRelocation(); - control.EndSuspendRelocation(); - control.EndSuspendRelocation(); + SuspendPaintingScope scope = control.SuspendPainting(); + scope.Dispose(); + scope.Dispose(); Assert.False(control.IsHandleCreated); } [WinFormsFact] - public void Control_DeferLocationChange_DisposeWithoutHandles_AppliesBounds() + public async Task Control_SuspendPainting_ScopeSpansAwait_Success() { - using SubControl parent = new(); - using SubControl child = new(); - parent.Controls.Add(child); + using SubControl control = new(); - using (DeferLocationChangeScope scope = parent.DeferLocationChange()) - { - scope.Defer(child, new Rectangle(1, 2, 3, 4)); - } + using SuspendPaintingScope scope = control.SuspendPainting(); + await Task.Yield(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + { + using SubControl control = new(); + ISupportSuspendRelocation suspendRelocation = control; - Assert.Equal(new Rectangle(1, 2, 3, 4), child.Bounds); + suspendRelocation.BeginSuspendRelocation(); + suspendRelocation.EndSuspendRelocation(); + suspendRelocation.EndSuspendRelocation(); + + Assert.False(control.IsHandleCreated); } #endif From 90db3db0dad09bf4e929fb53a4c8456aaa32352c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 18:14:53 -0700 Subject: [PATCH 14/67] Rename FormAppearanceMode to FormRevealMode with Inherit ambient sentinel Matches the final API shape agreed in dotnet/winforms#14585: - FormAppearanceMode -> FormRevealMode, adding Inherit = -1 as the ambient sentinel (Classic stays the CLR default value 0, matching the RightToLeft.Inherit / VisualStylesMode.Inherit precedent for ambient enums - the sentinel is a distinct value, not the zero value, so default(FormRevealMode) stays conservative). - Form.FormRevealMode is now a real public virtual, PropertyStore-backed, [AmbientValue(Inherit)] property (previously there was no per-Form property at all - only the flat, process-wide Application.FormAppearanceMode existed). This lets a form such as a splash screen opt itself out of deferred reveal without touching the process-wide default. Resolution is flat (Form only, no Control-parent-chain): DWM cloaking only ever applies to top-level, non-MDI-child windows, so there is no hierarchy to walk, unlike VisualStylesMode's genuine control-nesting cascade. - Application.FormAppearanceMode / SetFormAppearanceMode are replaced by three members: DefaultFormRevealMode (get; may return the unresolved Inherit sentinel, mirroring ColorMode returning the unresolved System value), SetDefaultFormRevealMode (freely reassignable, unlike the write-once SetDefaultVisualStylesMode - the effective default is derived in part from ColorMode/IsDarkModeEnabled, which are themselves mutable for the life of the process), and IsFormRevealDeferred (bool; the fully resolved answer: Deferred, or Inherit + IsDarkModeEnabled). This also fixes a compatibility problem in the original design: the old default was unconditionally Deferred whenever SetFormAppearanceMode was never called, an opt-out behavior change for every existing app; tying the Inherit resolution to dark mode means an app that never touches SystemColorMode sees no behavior change, while the scenario the feature exists for (dark-mode startup flash) is fixed by default. - ShouldUseDeferredAppearanceCloak now reads the resolved Form.FormRevealMode instead of the old flat Application-only check, so a per-Form override is actually honored. - Adds SR.resx/xlf entries for the new property's designer description. - Updates/adds tests; updates PublicAPI.Unshipped.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 34 ++++---- src/System.Windows.Forms/Resources/SR.resx | 3 + .../Resources/xlf/SR.cs.xlf | 5 ++ .../Resources/xlf/SR.de.xlf | 5 ++ .../Resources/xlf/SR.es.xlf | 5 ++ .../Resources/xlf/SR.fr.xlf | 5 ++ .../Resources/xlf/SR.it.xlf | 5 ++ .../Resources/xlf/SR.ja.xlf | 5 ++ .../Resources/xlf/SR.ko.xlf | 5 ++ .../Resources/xlf/SR.pl.xlf | 5 ++ .../Resources/xlf/SR.pt-BR.xlf | 5 ++ .../Resources/xlf/SR.ru.xlf | 5 ++ .../Resources/xlf/SR.tr.xlf | 5 ++ .../Resources/xlf/SR.zh-Hans.xlf | 5 ++ .../Resources/xlf/SR.zh-Hant.xlf | 5 ++ .../System/Windows/Forms/Application.cs | 60 ++++++++++---- .../System/Windows/Forms/Form.RevealMode.cs | 62 ++++++++++++++- .../System/Windows/Forms/FormRevealMode.cs | 20 ++++- .../System/Windows/Forms/ApplicationTests.cs | 45 ++++++++--- .../Windows/Forms/FormTests.RevealMode.cs | 78 +++++++++++++++++++ 20 files changed, 322 insertions(+), 45 deletions(-) create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 72180973d18..aabf184383a 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,6 +1,6 @@ System.Windows.Forms.ControlMutationExtensions -static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope -static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope! +static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope! System.Windows.Forms.FormAppearanceMode System.Windows.Forms.FormAppearanceMode.Classic = 0 -> System.Windows.Forms.FormAppearanceMode System.Windows.Forms.FormAppearanceMode.Deferred = 1 -> System.Windows.Forms.FormAppearanceMode @@ -12,25 +12,23 @@ System.Windows.Forms.ISupportSuspendRelocation.BeginSuspendRelocation() -> void System.Windows.Forms.ISupportSuspendRelocation.EndSuspendRelocation() -> void System.Windows.Forms.SuspendPaintingScope System.Windows.Forms.SuspendPaintingScope.Dispose() -> void -System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope() -> void System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Forms.ISupportSuspendPainting? target) -> void System.Windows.Forms.SuspendRelocationScope System.Windows.Forms.SuspendRelocationScope.Dispose() -> void -System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope() -> void System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope(System.Windows.Forms.ISupportSuspendRelocation? target) -> void -override System.Windows.Forms.ComboBox.BeginSuspendPainting() -> void -override System.Windows.Forms.ComboBox.EndSuspendPainting() -> void -override System.Windows.Forms.ListBox.BeginSuspendPainting() -> void -override System.Windows.Forms.ListBox.EndSuspendPainting() -> void -override System.Windows.Forms.ListView.BeginSuspendPainting() -> void -override System.Windows.Forms.ListView.EndSuspendPainting() -> void -override System.Windows.Forms.RichTextBox.BeginSuspendPainting() -> void -override System.Windows.Forms.RichTextBox.EndSuspendPainting() -> void -override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void -override System.Windows.Forms.TreeView.EndSuspendPainting() -> void static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void -virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void -virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void -virtual System.Windows.Forms.Control.EndSuspendPainting() -> void -virtual System.Windows.Forms.Control.EndSuspendRelocation() -> void +virtual System.Windows.Forms.Control.BeginSuspendPaintingCore() -> void +virtual System.Windows.Forms.Control.BeginSuspendRelocationCore() -> void +virtual System.Windows.Forms.Control.EndSuspendPaintingCore() -> void +virtual System.Windows.Forms.Control.EndSuspendRelocationCore() -> void +override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ComboBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.EndSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.EndSuspendPaintingCore() -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..d4e99f5154c 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -3286,6 +3286,9 @@ Do you want to replace it? Indicates whether the form always appears above all other forms that do not have this property set to true. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + A color which will appear transparent when painted on the form. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..2b625c173a0 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -5420,6 +5420,11 @@ Chcete ho nahradit? Hodnota, kterou tento formulář vrátí, pokud bude zobrazen jako dialogové okno. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Událost aktivovaná při kliknutí na tlačítko nápovědy diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..dda4e94fb6c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -5420,6 +5420,11 @@ Möchten Sie den Pfad ersetzen? Der Wert, den dieses Formular zurückgibt, wenn es als Dialogfeld angezeigt wird. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Das ausgelöste Ereignis, wenn auf die Schaltfläche "Hilfe" geklickt wird. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..2c0d87878de 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? Valor de este formulario si se muestra como un cuadro de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento que se desencadena cuando se hace clic en el botón Ayuda. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..05e2b3ef5bb 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -5420,6 +5420,11 @@ Voulez-vous le remplacer ? La valeur que ce formulaire retourne s'il est affiché en tant que boîte de dialogue. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Événement qui est déclenché à la suite d'un clic sur le bouton d'aide. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..a81d4d556b3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -5420,6 +5420,11 @@ Sostituirlo? Il valore che questo form restituirà se viene visualizzato come finestra di dialogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento generato quando si fa clic sul pulsante ?. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..80348613b82 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? ダイアログ ボックスとして表示された場合に、このフォームが返す値です。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [ヘルプ] ボタンがクリックされたときに発生するイベントです。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..1bc9c1a2274 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 대화 상자로 표시할 경우, 이 폼에서 반환하는 값입니다. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [도움말] 단추를 클릭하면 이벤트가 발생합니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..ab8fa7646e8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -5420,6 +5420,11 @@ Czy chcesz zastąpić? Wartość zwracana przez formularz, gdy jest on wyświetlany jako okno dialogowe. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Zdarzenie wywoływane po kliknięciu przycisku pomocy. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..c3a6f26c36f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -5420,6 +5420,11 @@ Deseja substituí-lo? O valor que este formulário retornará se exibido como uma caixa de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento gerado quando o usuário clica no botão de ajuda. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..7a7d81aee22 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? Значение, возвращаемое этой формой при ее отображении в виде диалогового окна. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Событие возникает при нажатии кнопки справки. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..16eb4fe27c8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -5420,6 +5420,11 @@ Değiştirmek istiyor musunuz? Bu formun iletişim kutusu olarak görüntülendiğinde döndüreceği değer. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Yardım düğmesi tıklatıldığında harekete geçirilen olay. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..de3c271aae3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 此窗体在显示为对话框时将返回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 单击“帮助”按钮时引发的事件。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..d1df8cf8af1 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 如果顯示為對話方塊,此表單會傳回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 按下說明按鈕時引發的事件。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index fd1fc9ba89c..b40d50ad9fc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -45,7 +45,7 @@ public sealed partial class Application private static SystemColorMode? s_colorMode; #if NET11_0_OR_GREATER - private static FormAppearanceMode? s_formAppearanceMode; + private static FormRevealMode? s_defaultFormRevealMode; #endif private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; @@ -254,16 +254,40 @@ internal static bool CustomThreadExceptionHandlerAttached #if NET11_0_OR_GREATER /// - /// Gets the configured form appearance mode for the application. + /// Gets the configured default used as the reveal behavior for + /// top-level forms that do not set explicitly. /// /// /// - /// If no mode has been configured with , - /// WinForms uses . + /// If no mode has been configured with , this + /// returns . Unlike , this + /// getter may return the unresolved sentinel; use + /// for the fully resolved answer. /// /// - public static FormAppearanceMode FormAppearanceMode - => s_formAppearanceMode ?? FormAppearanceMode.Deferred; + public static FormRevealMode DefaultFormRevealMode + => s_defaultFormRevealMode ?? FormRevealMode.Inherit; + + /// + /// Gets a value indicating whether newly created top-level forms use deferred reveal by default. + /// + /// + /// + /// This is the fully resolved answer: when + /// is , or when it is + /// (the default, when + /// has never been called) and is . + /// Accessibility trumps compatibility here: an application that opts into dark mode gets + /// flash-reduced form reveal automatically, without also having to call + /// explicitly. + /// + /// + public static bool IsFormRevealDeferred => DefaultFormRevealMode switch + { + FormRevealMode.Deferred => true, + FormRevealMode.Classic => false, + _ => IsDarkModeEnabled + }; #endif @@ -401,22 +425,30 @@ static void NotifySystemEventsOfColorChange() #if NET11_0_OR_GREATER /// - /// Sets the process-wide form appearance mode. + /// Sets the process-wide default used for top-level forms that do + /// not set explicitly. /// - /// The form appearance mode to use for newly created forms. + /// The default form reveal mode to use for newly created top-level forms. /// /// - /// Set the form appearance mode before creating UI to ensure newly created forms use the intended - /// startup presentation behavior. + /// Set the default form reveal mode before creating UI to ensure newly created forms use the + /// intended startup presentation behavior. Unlike the visual-styles default-mode setter (which is + /// write-once, since rendering-version selection is a static, one-time choice), this method can be + /// called more than once, and is a valid argument (it resets + /// the default back to its own ambient resolution via ). Free + /// reassignment is intentional: the effective default is derived in part from + /// and , which can themselves change for the lifetime of the process; + /// locking this value after first use would make forms created after a later dark-mode change use a + /// stale reveal behavior. /// /// - /// - /// is not a valid value. + /// + /// is not a valid value. /// - public static void SetFormAppearanceMode(FormAppearanceMode mode) + public static void SetDefaultFormRevealMode(FormRevealMode mode) { SourceGenerated.EnumValidator.Validate(mode, nameof(mode)); - s_formAppearanceMode = mode; + s_defaultFormRevealMode = mode; } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs index 853bdd55346..d3eee07dbd0 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using Windows.Win32.Graphics.Dwm; namespace System.Windows.Forms; @@ -8,6 +9,65 @@ namespace System.Windows.Forms; public partial class Form { #if NET11_0_OR_GREATER + private static readonly int s_propFormRevealMode = PropertyStore.CreateKey(); + + /// + /// Gets or sets how this form is presented while its initial appearance is prepared. + /// + /// + /// A value. When not explicitly set, the effective value is + /// or , resolved from + /// . + /// + /// + /// + /// As an ambient property, a form that does not have this value set explicitly resolves it from + /// . Unlike a control-level ambient property that + /// chains through a parent hierarchy, this property does not chain through a parent hierarchy: + /// deferred reveal is a top-level-window-only concept (see remarks on + /// and the DWM cloaking mechanism it relies on), so only + /// itself, and the process-wide default, participate. + /// + /// + /// A splash screen or other form that should always appear instantly, even while the rest of the + /// application defers by default, can set this property on itself to + /// without affecting the process-wide default. + /// + /// + [SRCategory(nameof(SR.CatWindowStyle))] + [AmbientValue(FormRevealMode.Inherit)] + [SRDescription(nameof(SR.FormFormRevealModeDescr))] + public virtual FormRevealMode FormRevealMode + { + get + { + if (!Properties.TryGetValue(s_propFormRevealMode, out FormRevealMode value) + || value == FormRevealMode.Inherit) + { + value = Application.IsFormRevealDeferred ? FormRevealMode.Deferred : FormRevealMode.Classic; + } + + return value; + } + set + { + SourceGenerated.EnumValidator.Validate(value, nameof(value)); + + if (value == FormRevealMode.Inherit) + { + Properties.RemoveValue(s_propFormRevealMode); + } + else + { + Properties.AddValue(s_propFormRevealMode, value); + } + } + } + + private bool ShouldSerializeFormRevealMode() => Properties.ContainsKey(s_propFormRevealMode); + + private void ResetFormRevealMode() => Properties.RemoveValue(s_propFormRevealMode); + private bool DeferredAppearanceCloaked { get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); @@ -43,7 +103,7 @@ private void UncloakDeferredAppearanceIfNeeded() private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; private bool ShouldUseDeferredAppearanceCloak() - => Application.FormAppearanceMode == FormAppearanceMode.Deferred + => FormRevealMode == FormRevealMode.Deferred && TopLevel && !IsMdiChild && Visible diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs index f5e76f27151..076b339a558 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs @@ -5,12 +5,26 @@ namespace System.Windows.Forms; #if NET11_0_OR_GREATER /// -/// Specifies how WinForms presents a form while its initial appearance is prepared. +/// Specifies how WinForms presents a top-level while its initial appearance is +/// prepared. /// -public enum FormAppearanceMode +public enum FormRevealMode { /// - /// Uses the classic WinForms form presentation behavior. + /// The form inherits its effective reveal behavior from . + /// This is the ambient default and is never returned by after + /// resolution. + /// + /// + /// + /// This value is the ambient sentinel: assigning it to clears any + /// local override so the value is inherited from again. + /// + /// + Inherit = -1, + + /// + /// Uses the classic WinForms form presentation behavior. The form is never cloaked. /// Classic = 0, diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs index a4eeb2cf967..979b50df6ed 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs @@ -182,26 +182,53 @@ public void Application_SetColorMode_PlausibilityTests() #if NET11_0_OR_GREATER [WinFormsFact] - public void Application_FormAppearanceMode_Default_ReturnsDeferred() + public void Application_DefaultFormRevealMode_Default_ReturnsInherit() { - Assert.Equal(FormAppearanceMode.Deferred, Application.FormAppearanceMode); + // Run in an isolated child process: DefaultFormRevealMode is process-wide state, and other tests + // in this shared-process test binary may have already called SetDefaultFormRevealMode. + RemoteExecutor.Invoke(() => + { + Assert.Equal(FormRevealMode.Inherit, Application.DefaultFormRevealMode); + }).Dispose(); } [WinFormsTheory] - [InlineData(FormAppearanceMode.Classic)] - [InlineData(FormAppearanceMode.Deferred)] - public void Application_SetFormAppearanceMode_GetReturnsExpected(FormAppearanceMode mode) + [InlineData(FormRevealMode.Classic)] + [InlineData(FormRevealMode.Deferred)] + [InlineData(FormRevealMode.Inherit)] + public void Application_SetDefaultFormRevealMode_GetReturnsExpected(FormRevealMode mode) { - Application.SetFormAppearanceMode(mode); - Assert.Equal(mode, Application.FormAppearanceMode); + Application.SetDefaultFormRevealMode(mode); + Assert.Equal(mode, Application.DefaultFormRevealMode); } [WinFormsFact] - public void Application_SetFormAppearanceMode_Invalid_ThrowsInvalidEnumArgumentException() + public void Application_SetDefaultFormRevealMode_Invalid_ThrowsInvalidEnumArgumentException() { Assert.Throws( "mode", - () => Application.SetFormAppearanceMode((FormAppearanceMode)int.MaxValue)); + () => Application.SetDefaultFormRevealMode((FormRevealMode)int.MaxValue)); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Classic_ReturnsFalse() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Classic); + Assert.False(Application.IsFormRevealDeferred); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Deferred_ReturnsTrue() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + Assert.True(Application.IsFormRevealDeferred); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Inherit_MatchesIsDarkModeEnabled() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Inherit); + Assert.Equal(Application.IsDarkModeEnabled, Application.IsFormRevealDeferred); } #endif diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs new file mode 100644 index 00000000000..9b4aa4ef442 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.ComponentModel; + +namespace System.Windows.Forms.Tests; + +public partial class FormTests +{ +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Form_FormRevealMode_Default_ResolvesFromApplication() + { + using SubForm form = new(); + + Application.SetDefaultFormRevealMode(FormRevealMode.Classic); + Assert.Equal(FormRevealMode.Classic, form.FormRevealMode); + + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + Assert.Equal(FormRevealMode.Deferred, form.FormRevealMode); + } + + [WinFormsTheory] + [InlineData(FormRevealMode.Classic)] + [InlineData(FormRevealMode.Deferred)] + public void Form_FormRevealMode_SetExplicit_OverridesApplicationDefault(FormRevealMode mode) + { + using SubForm form = new(); + FormRevealMode otherMode = mode == FormRevealMode.Classic ? FormRevealMode.Deferred : FormRevealMode.Classic; + + Application.SetDefaultFormRevealMode(otherMode); + form.FormRevealMode = mode; + + Assert.Equal(mode, form.FormRevealMode); + } + + [WinFormsFact] + public void Form_FormRevealMode_SetInherit_ClearsLocalOverride() + { + using SubForm form = new(); + + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + form.FormRevealMode = FormRevealMode.Classic; + Assert.Equal(FormRevealMode.Classic, form.FormRevealMode); + + form.FormRevealMode = FormRevealMode.Inherit; + + Assert.Equal(FormRevealMode.Deferred, form.FormRevealMode); + } + + [WinFormsFact] + public void Form_FormRevealMode_InvalidValue_ThrowsInvalidEnumArgumentException() + { + using SubForm form = new(); + + Assert.Throws( + "value", + () => form.FormRevealMode = (FormRevealMode)int.MaxValue); + } + + [WinFormsFact] + public void Form_FormRevealMode_ShouldSerialize_ResetRoundTrips() + { + using SubForm form = new(); + PropertyDescriptor property = TypeDescriptor.GetProperties(form)[nameof(Form.FormRevealMode)]; + + Assert.False(property.ShouldSerializeValue(form)); + + form.FormRevealMode = FormRevealMode.Classic; + Assert.True(property.ShouldSerializeValue(form)); + + property.ResetValue(form); + Assert.False(property.ShouldSerializeValue(form)); + } +#endif +} From 87e872d0edbe87ac500d390de95893357617425e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 18:15:10 -0700 Subject: [PATCH 15/67] Wire FormRevealMode into the VB Application Framework Adds Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.FormRevealMode, following the exact same pattern already established for ColorMode and HighDpiMode in that class, per dotnet/winforms#14585's API Proposal. WindowsFormsApplicationBase now carries a _formRevealMode shadow field (defaulting to FormRevealMode.Classic, matching the existing conservative default for _colorMode) and a protected FormRevealMode property, feeds it into the ApplyApplicationDefaultsEventArgs constructor alongside MinimumSplashScreenDisplayTime/HighDpiMode/ColorMode, reads back whatever the ApplyApplicationDefaults event handler set, and calls Application.SetDefaultFormRevealMode(_formRevealMode) at the end of OnInitialize alongside the existing Application.SetColorMode(_colorMode) call. This completes work anticipated but never finished in an earlier .NET 9 Visual Styles attempt at this same VB Application Framework extension point (OnInitialize already carried a comment claiming "We feed the defaults for HighDpiMode, ColorMode, VisualStylesMode to the EventArgs", but only HighDpiMode/ColorMode were ever actually wired up). Updates PublicAPI.Unshipped.txt for Microsoft.VisualBasic.Forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplyApplicationDefaultsEventArgs.vb | 10 ++++++- .../WindowsFormsApplicationBase.vb | 27 +++++++++++++++++-- .../src/PublicAPI.Unshipped.txt | 4 +++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb index 91ea19640da..8dc15332326 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb @@ -19,11 +19,13 @@ Namespace Microsoft.VisualBasic.ApplicationServices Friend Sub New(minimumSplashScreenDisplayTime As Integer, highDpiMode As HighDpiMode, - colorMode As SystemColorMode) + colorMode As SystemColorMode, + formRevealMode As FormRevealMode) Me.MinimumSplashScreenDisplayTime = minimumSplashScreenDisplayTime Me.HighDpiMode = highDpiMode Me.ColorMode = colorMode + Me.FormRevealMode = formRevealMode End Sub ''' @@ -32,6 +34,12 @@ Namespace Microsoft.VisualBasic.ApplicationServices ''' Public Property ColorMode As SystemColorMode + ''' + ''' Setting this property inside the event handler determines the default + ''' for newly created top-level forms. + ''' + Public Property FormRevealMode As FormRevealMode + ''' ''' Setting this property inside the event handler causes a ''' new default for Forms and UserControls to be set. diff --git a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb index 6e14d471534..ee64edcc086 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb @@ -69,6 +69,9 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' Note: We aim to expose this to the App Designer in later runtime/VS versions. Private _colorMode As SystemColorMode = SystemColorMode.Classic + ' The FormRevealMode the user assigned to the ApplyApplicationsDefault event. + Private _formRevealMode As FormRevealMode = FormRevealMode.Classic + ' We only need to show the splash screen once. ' Protect the user from himself if they are overriding our app model. Private _didSplashScreen As Boolean @@ -200,6 +203,23 @@ Namespace Microsoft.VisualBasic.ApplicationServices End Set End Property + ''' + ''' Gets or sets the for the Application. + ''' + ''' + ''' The that newly created top-level forms use by + ''' default. + ''' + + Protected Property FormRevealMode As FormRevealMode + Get + Return _formRevealMode + End Get + Set(value As FormRevealMode) + _formRevealMode = value + End Set + End Property + ''' ''' Determines whether this application will use the XP Windows styles for windows, controls, etc. ''' @@ -734,7 +754,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' in a derived class and setting `MyBase.MinimumSplashScreenDisplayTime` there. ' We are picking this (probably) changed value up, and pass it to the ApplyDefaultsEvents ' where it could be modified (again). So event wins over Override over default value (2 seconds). - ' b) We feed the defaults for HighDpiMode, ColorMode, VisualStylesMode to the EventArgs. + ' b) We feed the defaults for HighDpiMode, ColorMode, FormRevealMode to the EventArgs. ' With the introduction of the HighDpiMode property, we changed Project System the chance to reflect ' those default values in the App Designer UI and have it code-generated based on a modified ' Application.myapp, which would result it to be set in the derived constructor. @@ -746,7 +766,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices Dim applicationDefaultsEventArgs As New ApplyApplicationDefaultsEventArgs( MinimumSplashScreenDisplayTime, HighDpiMode, - ColorMode) With + ColorMode, + FormRevealMode) With { .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime } @@ -765,6 +786,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices _highDpiMode = applicationDefaultsEventArgs.HighDpiMode _colorMode = applicationDefaultsEventArgs.ColorMode + _formRevealMode = applicationDefaultsEventArgs.FormRevealMode ' Then, it's applying what we got back as HighDpiMode. Dim dpiSetResult As Boolean = Application.SetHighDpiMode(_highDpiMode) @@ -781,6 +803,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices End If Application.SetColorMode(_colorMode) + Application.SetDefaultFormRevealMode(_formRevealMode) ' We'll handle "/nosplash" for you. If Not (commandLineArgs.Contains("/nosplash") OrElse Me.CommandLineArgs.Contains("-nosplash")) Then diff --git a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt index e69de29bb2d..71b1f47c484 100644 --- a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.FormRevealMode() -> System.Windows.Forms.FormRevealMode +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.FormRevealMode(AutoPropertyValue As System.Windows.Forms.FormRevealMode) -> Void +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.FormRevealMode() -> System.Windows.Forms.FormRevealMode +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.FormRevealMode(value As System.Windows.Forms.FormRevealMode) -> Void From df560793176e561300ce5c2c9fce2d57197e9221 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 13 Jun 2026 13:26:48 -0700 Subject: [PATCH 16/67] Update agent skills: build.cmd build tenet, PublicAPI override tracking, and test cancellation/nullable guidance - building-code: add a top-level TENET to build the solution only with build.cmd (CI parity for PublicAPI/analyzer enforcement and -warnAsError); a plain dotnet build is inner-loop only. - new-control-api: new public/protected overrides must be tracked in PublicAPI.Unshipped.txt with the override prefix; note CS0114 (new keyword) and CS1574 (no cref to cross-assembly internal types). - control-api-tests: async tests must pass CancellationToken (TestContext.Current.CancellationToken, CA2016/xUnit1051) and respect the #nullable context (CS8632). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/building-code/SKILL.md | 78 ++++++++++++++ .github/skills/control-api-tests/SKILL.md | 22 ++++ .github/skills/new-control-api/SKILL.md | 124 +++++++++++++++------- 3 files changed, 187 insertions(+), 37 deletions(-) diff --git a/.github/skills/building-code/SKILL.md b/.github/skills/building-code/SKILL.md index 040dd437081..67fd8c7ba30 100644 --- a/.github/skills/building-code/SKILL.md +++ b/.github/skills/building-code/SKILL.md @@ -11,6 +11,22 @@ metadata: # Building the WinForms Repository +> ## 🛑 TENET — Build the solution ONLY with `build.cmd` +> +> **Never** build, validate, or declare the WinForms solution "clean" with a plain +> `dotnet build` / `dotnet msbuild` of `Winforms.sln`. Only **`build.cmd`** (Arcade) applies the +> repository's CI configuration — the **PublicAPI analyzer (RS0016/RS0017)**, the code-style and +> documentation analyzers, and **`-warnAsError`**. A plain `dotnet build` silently downgrades or +> skips these, so **"0 warnings" there does NOT mean CI is green** — the very same change can fail +> the official build with errors. +> +> * **Full / release / package / "is it clean?" verification → always `build.cmd`** (see §2). +> * A single-project `dotnet build` (see §3) is an **inner-loop convenience only**. It is fine while +> iterating, but you **must re-verify with `build.cmd` before claiming a change builds cleanly**. +> * If `build.cmd` cannot run in your environment, the closest fallback is +> `dotnet build /p:ContinuousIntegrationBuild=true /p:TreatWarningsAsErrors=true` — and +> you must say so explicitly rather than implying a `build.cmd` result. + ## Prerequisites * Windows is required for WinForms runtime scenarios, test execution, and Visual @@ -45,6 +61,12 @@ You can pass any extra `Build.ps1` flags after `Restore.cmd`, e.g. ## 2 Full Solution Build (preferred) +> **Always use `build.cmd` (Arcade) for full, release, and package builds.** Do **not** use a plain +> `dotnet build` of the solution for these — only `build.cmd` guarantees the Arcade-supported build +> options and the download of the correct base SDK (`global.json`) needed to compile. Plain +> `dotnet build` is reserved for the fast single-project inner loop (see Section 3), and even then +> only after at least one successful `build.cmd` / `Restore.cmd`. + ``` .\build.cmd ``` @@ -57,6 +79,56 @@ Under the hood this runs: eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ``` +### 2.1 Full (clean) test build — required workflow + +For a full, clean build of the whole solution, **clean the artifacts first, then build**: + +```powershell +# 1. Clean the artifacts folder. +.\build -clean + +# 2. Build the full solution. +.\build +``` + +**Reporting requirement:** a full build is long-running, so while it runs **report progress back to +the user in the console to bridge the wait and give early orientation.** As assemblies complete, +report which assemblies have been **built successfully** and which **failed and with how many +errors**. Prefer running the build with a binary log (the default `-bl`) and/or stream the console +output so per-project results can be surfaced as they happen rather than only at the end. + +### 2.2 Release build + +```powershell +.\build -configuration release +``` + +### 2.3 Creating packages + +```powershell +# Debug packages +.\build -pack + +# Release packages +.\build -configuration release -pack +``` + +### 2.4 Full `Build.ps1` parameter list + +`build.cmd` forwards every extra argument to `eng\common\Build.ps1`. The full surface is: + +``` +Build.ps1 [-configuration ] [-platform ] [-projects ] + [-verbosity ] [-msbuildEngine ] [-warnAsError ] + [-warnNotAsError ] [-nodeReuse ] [-buildCheck] [-restore] + [-deployDeps] [-build] [-rebuild] [-deploy] [-test] [-integrationTest] + [-performanceTest] [-sign] [-pack] [-publish] [-clean] [-productBuild] + [-fromVMR] [-binaryLog] [-binaryLogName ] [-excludeCIBinarylog] + [-ci] [-prepareMachine] [-runtimeSourceFeed ] + [-runtimeSourceFeedKey ] [-excludePrereleaseVS] + [-nativeToolsOnMachine] [-help] [-properties ] [] +``` + ### Common flags | Flag | Short | Description | @@ -90,6 +162,12 @@ eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ## 3 Optimized Building a Single Project (fast inner-loop) +> **Inner-loop only.** Use plain `dotnet build` of a single project **only** for quick iteration on +> one project, and **only after** at least one successful `.\build.cmd` / `.\Restore.cmd`. It does +> **not** guarantee the Arcade-supported build options or the download of the correct base SDK, so it +> must **never** be used for a full solution build, a release build, packaging, or any build whose +> result you intend to report as authoritative. For those, always use `build.cmd` (Section 2). + Prefer rebuilding just the project(s) with recent changes by using the standard `dotnet build` command, **after** at least one initial successful full restore (via `.\Restore.cmd` or `.\build.cmd`). diff --git a/.github/skills/control-api-tests/SKILL.md b/.github/skills/control-api-tests/SKILL.md index 5badcc2547c..ff4e9304230 100644 --- a/.github/skills/control-api-tests/SKILL.md +++ b/.github/skills/control-api-tests/SKILL.md @@ -66,6 +66,28 @@ The project uses **xUnit** with **FluentAssertions**. Key attributes: These are custom xUnit attributes that ensure tests run on an STA thread, which WinForms requires for COM interop and UI operations. +### 1.4 Async tests: pass a CancellationToken, respect `#nullable` + +The repository runs **xUnit v3** and enforces the relevant analyzers as **errors** under the CI +build (`build.cmd`). Two pitfalls fail CI even though a plain `dotnet build` may not flag them: + +* **CA2016 / xUnit1051 — always pass a `CancellationToken` to async calls.** Methods such as + `Task.Delay` must receive a token so a cancelled test run stops promptly. In xUnit v3 use + `TestContext.Current.CancellationToken`: + + ```csharp + await Task.Delay(25, TestContext.Current.CancellationToken); + ``` + + When you receive a `CancellationToken ct` (e.g. in a callback), **forward it** rather than dropping it. + +* **CS8632 — nullable annotations need a `#nullable` context.** If a test file uses `?` reference + annotations (e.g. `object? sender`) but the project does not enable nullable, add `#nullable enable` + at the top of the file (or remove the annotation). Match the surrounding files' convention. + +> Verify with `build.cmd` (CI parity) — see the `building-code` skill's build tenet. A plain +> single-project `dotnet build` can report these as 0 warnings while CI fails them as errors. + --- ## 2. Test Method Naming diff --git a/.github/skills/new-control-api/SKILL.md b/.github/skills/new-control-api/SKILL.md index ce6c58b6d2e..3030cf92ef7 100644 --- a/.github/skills/new-control-api/SKILL.md +++ b/.github/skills/new-control-api/SKILL.md @@ -148,7 +148,34 @@ System.Windows.Forms.MyEnum.Value2 = 1 -> System.Windows.Forms.MyEnum **Nullable annotations:** `?` = nullable reference, `!` = non-nullable reference. Value types do not carry these markers unless `Nullable`. -### 2.4 Publicly accessible interfaces +### 2.4 New `override` members must be tracked too + +The PublicAPI analyzer (RS0016) treats a **newly introduced `override`** of a public or +protected member as new API surface — even though the base member is already public. Whenever +you **add an `override` that did not previously exist on that type**, add a line for it to +`PublicAPI.Unshipped.txt` with the `override` prefix. This is easy to miss for paint/lifecycle +overrides added to support a feature. Examples: + +```text +override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void +override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +``` + +> **CI catches this, a plain `dotnet build` may not.** RS0016 is enforced as an **error** under +> the CI/Arcade build (`build.cmd`); a single-project `dotnet build` can report it as 0 warnings. +> Always re-verify API tracking with `build.cmd` (see the `building-code` skill's build tenet). + +### 2.5 Related pitfalls when adding members to a control + +* **Hiding an inherited member (CS0114):** if your new member intentionally hides an inherited + one (e.g. a `private new bool ShouldSerializePadding()` shadowing `Control.ShouldSerializePadding()`), + you **must** use the `new` keyword, or the CI build fails. +* **`cref` to internal types in another assembly (CS1574):** XML-doc `` cannot + resolve a type that is `internal` in a *different* assembly (even via `InternalsVisibleTo`). Use + `TypeName` (plain code font) instead of a `cref` for such references. + +### 2.6 Publicly accessible interfaces If a new **public or protected interface** is introduced (or an existing one gains new members), every member that is publicly accessible must also appear @@ -468,51 +495,73 @@ protected virtual void OnMyPropertyChanged(EventArgs e) --- -## 7. .NET Version Guard — Mandatory +## 7. API Stability: Experimental vs. Stable — and Version Guards -All new public APIs **must** be guarded with a preprocessor directive for the -target .NET version. Currently, new APIs target at least **.NET 11**: +### 7.1 New APIs are STABLE by default — do NOT mark them `[Experimental]` -```csharp -#if NET11_0_OR_GREATER - /// - /// Gets or sets the corner radius for the control's border. - /// - public int CornerRadius - { - get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); - set - { - ArgumentOutOfRangeException.ThrowIfNegative(value); +New public APIs ship as **normal, stable APIs by default**. Do **not** add the +`[Experimental(...)]` attribute, a `WFO5xxx` diagnostic ID, or `[WFO5xxx]` +PublicAPI prefixes unless the work item **explicitly** asks for an experimental +API. - if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) - { - Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); - OnCornerRadiusChanged(EventArgs.Empty); - } - } - } -#endif -``` +> **Never make an API experimental implicitly.** Experimental status is a +> deliberate, requested decision (it changes the customer contract and requires a +> diagnostic ID + suppression to consume). If the context does not explicitly call +> for it, the API is stable. + +### 7.2 When an experimental API *is* explicitly requested + +Only when the task explicitly requests an experimental API: + +1. Add (or reuse) a diagnostic ID in the `WFO500x` group in + `src\System.Windows.Forms.Analyzers\src\System\Windows\Forms\Analyzers\Diagnostics\DiagnosticIDs.cs` + (e.g. `ExperimentalDarkMode = "WFO5001"`, `ExperimentalAsync = "WFO5002"`, + `ExperimentalAsyncDropTarget = "WFO5003"`). New IDs continue the sequence. +2. Decorate the API: + ```csharp + [Experimental(DiagnosticIDs.ExperimentalXxx, UrlFormat = DiagnosticIDs.UrlFormat)] + ``` +3. Prefix every PublicAPI entry for that API with the diagnostic ID, e.g. + `[WFO5001]System.Windows.Forms.SomeNewApi.get -> ...`. +4. Add a row to **both** `docs\analyzers\Experimental.Help.md` and + `docs\list-of-diagnostics.md`. +5. Suppress the diagnostic where the framework itself consumes the API + (`#pragma warning disable WFOxxxx` / `#Disable Warning WFOxxxx` in VB). -> **Why?** Version guards ensure new APIs are only available on the .NET version -> they were approved for, preventing accidental use on older runtimes. The guard -> applies to the entire API surface: property, event, `On` method, and any -> associated types. +When the API later **graduates to stable** (typically the next release), reverse +all five steps: remove the attribute, the `[WFOxxxx]` PublicAPI prefixes, the +suppressions, the docs rows, and the unused diagnostic ID. -The matching tests must use the **same** preprocessor guard: +### 7.3 Version guards + +This repository **single-targets the current in-development .NET** (see +`TargetFramework` / `NetCurrent`), so source is **not** wrapped in +`#if NETxx_0_OR_GREATER` guards — there are none in `System.Windows.Forms`. Do +**not** add `#if NET11_0_OR_GREATER` blocks around new APIs. Add the member +directly: ```csharp -#if NET11_0_OR_GREATER - [WinFormsFact] - public void MyControl_CornerRadius_Set_GetReturnsExpected() +/// +/// Gets or sets the corner radius for the control's border. +/// +public int CornerRadius +{ + get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); + set { - using MyControl control = new() { CornerRadius = 5 }; - Assert.Equal(5, control.CornerRadius); + ArgumentOutOfRangeException.ThrowIfNegative(value); + + if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) + { + Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); + OnCornerRadiusChanged(EventArgs.Empty); + } } -#endif +} ``` +Tests do not need a version guard either. + --- ## 8. Checklist Before Submitting @@ -521,7 +570,8 @@ Before considering the implementation complete, verify: * [ ] API proposal issue exists (upstream or fork) with full proposal format * [ ] All new public/protected members are in `PublicAPI.Unshipped.txt` -* [ ] New APIs guarded with `#if NET11_0_OR_GREATER` (or appropriate version) +* [ ] API is **stable** (no `[Experimental]`/`WFO5xxx`) unless experimental was + explicitly requested; no `#if NETxx_0_OR_GREATER` guards * [ ] Property values stored via `PropertyStore` (not backing fields) * [ ] Every property has a CodeDOM serialization strategy * [ ] Every property has `On[Property]Changed` + `[Property]Changed` event @@ -533,7 +583,7 @@ Before considering the implementation complete, verify: * [ ] XML documentation on every new public/protected member * [ ] Naming follows precedent on the control and its base classes * [ ] Publicly accessible interface members are tracked in PublicAPI files -* [ ] Unit tests cover the new API surface (with matching version guard) +* [ ] Unit tests cover the new API surface ### 8.1 API issue checklist From e3991834b3064d01e2d7b92179800c6eab87fa51 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 13 Jun 2026 13:26:48 -0700 Subject: [PATCH 17/67] Update agent skills: build.cmd build tenet, PublicAPI override tracking, and test cancellation/nullable guidance - building-code: add a top-level TENET to build the solution only with build.cmd (CI parity for PublicAPI/analyzer enforcement and -warnAsError); a plain dotnet build is inner-loop only. - new-control-api: new public/protected overrides must be tracked in PublicAPI.Unshipped.txt with the override prefix; note CS0114 (new keyword) and CS1574 (no cref to cross-assembly internal types). - control-api-tests: async tests must pass CancellationToken (TestContext.Current.CancellationToken, CA2016/xUnit1051) and respect the #nullable context (CS8632). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/building-code/SKILL.md | 78 ++++++++++++++ .github/skills/control-api-tests/SKILL.md | 22 ++++ .github/skills/new-control-api/SKILL.md | 124 +++++++++++++++------- 3 files changed, 187 insertions(+), 37 deletions(-) diff --git a/.github/skills/building-code/SKILL.md b/.github/skills/building-code/SKILL.md index 040dd437081..67fd8c7ba30 100644 --- a/.github/skills/building-code/SKILL.md +++ b/.github/skills/building-code/SKILL.md @@ -11,6 +11,22 @@ metadata: # Building the WinForms Repository +> ## 🛑 TENET — Build the solution ONLY with `build.cmd` +> +> **Never** build, validate, or declare the WinForms solution "clean" with a plain +> `dotnet build` / `dotnet msbuild` of `Winforms.sln`. Only **`build.cmd`** (Arcade) applies the +> repository's CI configuration — the **PublicAPI analyzer (RS0016/RS0017)**, the code-style and +> documentation analyzers, and **`-warnAsError`**. A plain `dotnet build` silently downgrades or +> skips these, so **"0 warnings" there does NOT mean CI is green** — the very same change can fail +> the official build with errors. +> +> * **Full / release / package / "is it clean?" verification → always `build.cmd`** (see §2). +> * A single-project `dotnet build` (see §3) is an **inner-loop convenience only**. It is fine while +> iterating, but you **must re-verify with `build.cmd` before claiming a change builds cleanly**. +> * If `build.cmd` cannot run in your environment, the closest fallback is +> `dotnet build /p:ContinuousIntegrationBuild=true /p:TreatWarningsAsErrors=true` — and +> you must say so explicitly rather than implying a `build.cmd` result. + ## Prerequisites * Windows is required for WinForms runtime scenarios, test execution, and Visual @@ -45,6 +61,12 @@ You can pass any extra `Build.ps1` flags after `Restore.cmd`, e.g. ## 2 Full Solution Build (preferred) +> **Always use `build.cmd` (Arcade) for full, release, and package builds.** Do **not** use a plain +> `dotnet build` of the solution for these — only `build.cmd` guarantees the Arcade-supported build +> options and the download of the correct base SDK (`global.json`) needed to compile. Plain +> `dotnet build` is reserved for the fast single-project inner loop (see Section 3), and even then +> only after at least one successful `build.cmd` / `Restore.cmd`. + ``` .\build.cmd ``` @@ -57,6 +79,56 @@ Under the hood this runs: eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ``` +### 2.1 Full (clean) test build — required workflow + +For a full, clean build of the whole solution, **clean the artifacts first, then build**: + +```powershell +# 1. Clean the artifacts folder. +.\build -clean + +# 2. Build the full solution. +.\build +``` + +**Reporting requirement:** a full build is long-running, so while it runs **report progress back to +the user in the console to bridge the wait and give early orientation.** As assemblies complete, +report which assemblies have been **built successfully** and which **failed and with how many +errors**. Prefer running the build with a binary log (the default `-bl`) and/or stream the console +output so per-project results can be surfaced as they happen rather than only at the end. + +### 2.2 Release build + +```powershell +.\build -configuration release +``` + +### 2.3 Creating packages + +```powershell +# Debug packages +.\build -pack + +# Release packages +.\build -configuration release -pack +``` + +### 2.4 Full `Build.ps1` parameter list + +`build.cmd` forwards every extra argument to `eng\common\Build.ps1`. The full surface is: + +``` +Build.ps1 [-configuration ] [-platform ] [-projects ] + [-verbosity ] [-msbuildEngine ] [-warnAsError ] + [-warnNotAsError ] [-nodeReuse ] [-buildCheck] [-restore] + [-deployDeps] [-build] [-rebuild] [-deploy] [-test] [-integrationTest] + [-performanceTest] [-sign] [-pack] [-publish] [-clean] [-productBuild] + [-fromVMR] [-binaryLog] [-binaryLogName ] [-excludeCIBinarylog] + [-ci] [-prepareMachine] [-runtimeSourceFeed ] + [-runtimeSourceFeedKey ] [-excludePrereleaseVS] + [-nativeToolsOnMachine] [-help] [-properties ] [] +``` + ### Common flags | Flag | Short | Description | @@ -90,6 +162,12 @@ eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ## 3 Optimized Building a Single Project (fast inner-loop) +> **Inner-loop only.** Use plain `dotnet build` of a single project **only** for quick iteration on +> one project, and **only after** at least one successful `.\build.cmd` / `.\Restore.cmd`. It does +> **not** guarantee the Arcade-supported build options or the download of the correct base SDK, so it +> must **never** be used for a full solution build, a release build, packaging, or any build whose +> result you intend to report as authoritative. For those, always use `build.cmd` (Section 2). + Prefer rebuilding just the project(s) with recent changes by using the standard `dotnet build` command, **after** at least one initial successful full restore (via `.\Restore.cmd` or `.\build.cmd`). diff --git a/.github/skills/control-api-tests/SKILL.md b/.github/skills/control-api-tests/SKILL.md index 5badcc2547c..ff4e9304230 100644 --- a/.github/skills/control-api-tests/SKILL.md +++ b/.github/skills/control-api-tests/SKILL.md @@ -66,6 +66,28 @@ The project uses **xUnit** with **FluentAssertions**. Key attributes: These are custom xUnit attributes that ensure tests run on an STA thread, which WinForms requires for COM interop and UI operations. +### 1.4 Async tests: pass a CancellationToken, respect `#nullable` + +The repository runs **xUnit v3** and enforces the relevant analyzers as **errors** under the CI +build (`build.cmd`). Two pitfalls fail CI even though a plain `dotnet build` may not flag them: + +* **CA2016 / xUnit1051 — always pass a `CancellationToken` to async calls.** Methods such as + `Task.Delay` must receive a token so a cancelled test run stops promptly. In xUnit v3 use + `TestContext.Current.CancellationToken`: + + ```csharp + await Task.Delay(25, TestContext.Current.CancellationToken); + ``` + + When you receive a `CancellationToken ct` (e.g. in a callback), **forward it** rather than dropping it. + +* **CS8632 — nullable annotations need a `#nullable` context.** If a test file uses `?` reference + annotations (e.g. `object? sender`) but the project does not enable nullable, add `#nullable enable` + at the top of the file (or remove the annotation). Match the surrounding files' convention. + +> Verify with `build.cmd` (CI parity) — see the `building-code` skill's build tenet. A plain +> single-project `dotnet build` can report these as 0 warnings while CI fails them as errors. + --- ## 2. Test Method Naming diff --git a/.github/skills/new-control-api/SKILL.md b/.github/skills/new-control-api/SKILL.md index ce6c58b6d2e..3030cf92ef7 100644 --- a/.github/skills/new-control-api/SKILL.md +++ b/.github/skills/new-control-api/SKILL.md @@ -148,7 +148,34 @@ System.Windows.Forms.MyEnum.Value2 = 1 -> System.Windows.Forms.MyEnum **Nullable annotations:** `?` = nullable reference, `!` = non-nullable reference. Value types do not carry these markers unless `Nullable`. -### 2.4 Publicly accessible interfaces +### 2.4 New `override` members must be tracked too + +The PublicAPI analyzer (RS0016) treats a **newly introduced `override`** of a public or +protected member as new API surface — even though the base member is already public. Whenever +you **add an `override` that did not previously exist on that type**, add a line for it to +`PublicAPI.Unshipped.txt` with the `override` prefix. This is easy to miss for paint/lifecycle +overrides added to support a feature. Examples: + +```text +override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void +override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +``` + +> **CI catches this, a plain `dotnet build` may not.** RS0016 is enforced as an **error** under +> the CI/Arcade build (`build.cmd`); a single-project `dotnet build` can report it as 0 warnings. +> Always re-verify API tracking with `build.cmd` (see the `building-code` skill's build tenet). + +### 2.5 Related pitfalls when adding members to a control + +* **Hiding an inherited member (CS0114):** if your new member intentionally hides an inherited + one (e.g. a `private new bool ShouldSerializePadding()` shadowing `Control.ShouldSerializePadding()`), + you **must** use the `new` keyword, or the CI build fails. +* **`cref` to internal types in another assembly (CS1574):** XML-doc `` cannot + resolve a type that is `internal` in a *different* assembly (even via `InternalsVisibleTo`). Use + `TypeName` (plain code font) instead of a `cref` for such references. + +### 2.6 Publicly accessible interfaces If a new **public or protected interface** is introduced (or an existing one gains new members), every member that is publicly accessible must also appear @@ -468,51 +495,73 @@ protected virtual void OnMyPropertyChanged(EventArgs e) --- -## 7. .NET Version Guard — Mandatory +## 7. API Stability: Experimental vs. Stable — and Version Guards -All new public APIs **must** be guarded with a preprocessor directive for the -target .NET version. Currently, new APIs target at least **.NET 11**: +### 7.1 New APIs are STABLE by default — do NOT mark them `[Experimental]` -```csharp -#if NET11_0_OR_GREATER - /// - /// Gets or sets the corner radius for the control's border. - /// - public int CornerRadius - { - get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); - set - { - ArgumentOutOfRangeException.ThrowIfNegative(value); +New public APIs ship as **normal, stable APIs by default**. Do **not** add the +`[Experimental(...)]` attribute, a `WFO5xxx` diagnostic ID, or `[WFO5xxx]` +PublicAPI prefixes unless the work item **explicitly** asks for an experimental +API. - if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) - { - Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); - OnCornerRadiusChanged(EventArgs.Empty); - } - } - } -#endif -``` +> **Never make an API experimental implicitly.** Experimental status is a +> deliberate, requested decision (it changes the customer contract and requires a +> diagnostic ID + suppression to consume). If the context does not explicitly call +> for it, the API is stable. + +### 7.2 When an experimental API *is* explicitly requested + +Only when the task explicitly requests an experimental API: + +1. Add (or reuse) a diagnostic ID in the `WFO500x` group in + `src\System.Windows.Forms.Analyzers\src\System\Windows\Forms\Analyzers\Diagnostics\DiagnosticIDs.cs` + (e.g. `ExperimentalDarkMode = "WFO5001"`, `ExperimentalAsync = "WFO5002"`, + `ExperimentalAsyncDropTarget = "WFO5003"`). New IDs continue the sequence. +2. Decorate the API: + ```csharp + [Experimental(DiagnosticIDs.ExperimentalXxx, UrlFormat = DiagnosticIDs.UrlFormat)] + ``` +3. Prefix every PublicAPI entry for that API with the diagnostic ID, e.g. + `[WFO5001]System.Windows.Forms.SomeNewApi.get -> ...`. +4. Add a row to **both** `docs\analyzers\Experimental.Help.md` and + `docs\list-of-diagnostics.md`. +5. Suppress the diagnostic where the framework itself consumes the API + (`#pragma warning disable WFOxxxx` / `#Disable Warning WFOxxxx` in VB). -> **Why?** Version guards ensure new APIs are only available on the .NET version -> they were approved for, preventing accidental use on older runtimes. The guard -> applies to the entire API surface: property, event, `On` method, and any -> associated types. +When the API later **graduates to stable** (typically the next release), reverse +all five steps: remove the attribute, the `[WFOxxxx]` PublicAPI prefixes, the +suppressions, the docs rows, and the unused diagnostic ID. -The matching tests must use the **same** preprocessor guard: +### 7.3 Version guards + +This repository **single-targets the current in-development .NET** (see +`TargetFramework` / `NetCurrent`), so source is **not** wrapped in +`#if NETxx_0_OR_GREATER` guards — there are none in `System.Windows.Forms`. Do +**not** add `#if NET11_0_OR_GREATER` blocks around new APIs. Add the member +directly: ```csharp -#if NET11_0_OR_GREATER - [WinFormsFact] - public void MyControl_CornerRadius_Set_GetReturnsExpected() +/// +/// Gets or sets the corner radius for the control's border. +/// +public int CornerRadius +{ + get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); + set { - using MyControl control = new() { CornerRadius = 5 }; - Assert.Equal(5, control.CornerRadius); + ArgumentOutOfRangeException.ThrowIfNegative(value); + + if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) + { + Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); + OnCornerRadiusChanged(EventArgs.Empty); + } } -#endif +} ``` +Tests do not need a version guard either. + --- ## 8. Checklist Before Submitting @@ -521,7 +570,8 @@ Before considering the implementation complete, verify: * [ ] API proposal issue exists (upstream or fork) with full proposal format * [ ] All new public/protected members are in `PublicAPI.Unshipped.txt` -* [ ] New APIs guarded with `#if NET11_0_OR_GREATER` (or appropriate version) +* [ ] API is **stable** (no `[Experimental]`/`WFO5xxx`) unless experimental was + explicitly requested; no `#if NETxx_0_OR_GREATER` guards * [ ] Property values stored via `PropertyStore` (not backing fields) * [ ] Every property has a CodeDOM serialization strategy * [ ] Every property has `On[Property]Changed` + `[Property]Changed` event @@ -533,7 +583,7 @@ Before considering the implementation complete, verify: * [ ] XML documentation on every new public/protected member * [ ] Naming follows precedent on the control and its base classes * [ ] Publicly accessible interface members are tracked in PublicAPI files -* [ ] Unit tests cover the new API surface (with matching version guard) +* [ ] Unit tests cover the new API surface ### 8.1 API issue checklist From 93a34b9f667182aae6baf2d5befaa26708882925 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 13 Jun 2026 13:38:11 -0700 Subject: [PATCH 18/67] Add Visual Styles (.NET 11): VisualStylesMode, animation timer, modern Button and CheckBox toggle renderers Introduces the (non-experimental) Visual Styles versioning API and the first modern renderers gated behind it: - VisualStylesMode enum (Classic/Disabled/Net11/Latest); ambient Control.VisualStylesMode (+event/On-methods); Application.DefaultVisualStylesMode/SetDefaultVisualStylesMode; Appearance.ToggleSwitch; VB framework APIs. - HighPrecisionTimer (internal, Primitives) as the animation frame trigger, with tests. - AnimationManager/AnimatedControlRenderer driven by HighPrecisionTimer. - Conservative dark-mode Standard button (owner-drawn, reachable) + modern WinUI-style Button renderer. - CheckBox Appearance.ToggleSwitch modern toggle switch (animated, flicker-free). - WinformsControlsTest VisualStylesButtons exploratory harness; unit tests. Verified CI-clean with build.cmd: System.Windows.Forms, Microsoft.VisualBasic.Forms and Primitives build with no analyzer/PublicAPI/style errors (the only remaining failure is the pre-existing BuildAssist/AxHosts step, which is an environment limitation unrelated to these changes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplyApplicationDefaultsEventArgs.vb | 6 + .../WindowsFormsApplicationBase.vb | 25 +- .../src/PublicAPI.Unshipped.txt | 4 + .../Forms/Animation/HighPrecisionTimer.cs | 330 ++++++++++++++++++ .../Forms/Animation/HighPrecisionTimerTick.cs | 30 ++ .../Animation/HighPrecisionTimerTests.cs | 220 ++++++++++++ .../PublicAPI.Unshipped.txt | 19 + src/System.Windows.Forms/Resources/SR.resx | 9 + .../Resources/xlf/SR.cs.xlf | 15 + .../Resources/xlf/SR.de.xlf | 15 + .../Resources/xlf/SR.es.xlf | 15 + .../Resources/xlf/SR.fr.xlf | 15 + .../Resources/xlf/SR.it.xlf | 15 + .../Resources/xlf/SR.ja.xlf | 15 + .../Resources/xlf/SR.ko.xlf | 15 + .../Resources/xlf/SR.pl.xlf | 15 + .../Resources/xlf/SR.pt-BR.xlf | 15 + .../Resources/xlf/SR.ru.xlf | 15 + .../Resources/xlf/SR.tr.xlf | 15 + .../Resources/xlf/SR.zh-Hans.xlf | 15 + .../Resources/xlf/SR.zh-Hant.xlf | 15 + .../System/Windows/Forms/Application.cs | 57 +++ .../System/Windows/Forms/Control.cs | 140 ++++++++ .../Forms/Controls/Buttons/Appearance.cs | 13 +- .../Windows/Forms/Controls/Buttons/Button.cs | 34 -- .../Forms/Controls/Buttons/ButtonBase.cs | 16 + .../DarkMode/ButtonDarkModeAdapter.cs | 14 +- .../DarkMode/ButtonDarkModeRendererBase.cs | 11 + .../DarkMode/DarkModeAdapterFactory.cs | 13 +- .../DarkMode/ModernButtonDarkModeRenderer.cs | 199 +++++++++++ .../Forms/Controls/Buttons/CheckBox.cs | 100 +++++- .../Forms/Controls/TextBox/TextBoxBase.cs | 11 +- .../Animation/AnimatedControlRenderer.cs | 152 ++++++++ .../Rendering/Animation/AnimationCycle.cs | 11 + .../AnimationManager.AnimationRendererItem.cs | 25 ++ .../Rendering/Animation/AnimationManager.cs | 154 ++++++++ .../CheckBox/AnimatedToggleSwitchRenderer.cs | 140 ++++++++ .../Rendering/CheckBox/ModernCheckBoxStyle.cs | 10 + .../System/Windows/Forms/VisualStylesMode.cs | 41 +++ .../WinformsControlsTest/Buttons.cs | 8 + .../VisualStylesButtons.cs | 193 ++++++++++ .../Forms/AnimatedControlRendererTests.cs | 89 +++++ .../Windows/Forms/ButtonVisualStylesTests.cs | 76 ++++ .../Forms/CheckBoxToggleSwitchTests.cs | 84 +++++ .../Forms/ControlTests.VisualStylesMode.cs | 143 ++++++++ 45 files changed, 2525 insertions(+), 47 deletions(-) create mode 100644 src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs create mode 100644 src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs create mode 100644 src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs create mode 100644 src/test/integration/WinformsControlsTest/VisualStylesButtons.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/AnimatedControlRendererTests.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/ButtonVisualStylesTests.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/CheckBoxToggleSwitchTests.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.VisualStylesMode.cs diff --git a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb index 91ea19640da..aebcd29b546 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb @@ -32,6 +32,12 @@ Namespace Microsoft.VisualBasic.ApplicationServices ''' Public Property ColorMode As SystemColorMode + ''' + ''' Setting this property inside the event handler determines the + ''' for the application. + ''' + Public Property VisualStylesMode As VisualStylesMode + ''' ''' Setting this property inside the event handler causes a ''' new default for Forms and UserControls to be set. diff --git a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb index 6e14d471534..f29d8a4816e 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb @@ -69,6 +69,9 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' Note: We aim to expose this to the App Designer in later runtime/VS versions. Private _colorMode As SystemColorMode = SystemColorMode.Classic + ' The VisualStylesMode (renderer version) the user assigned to the ApplyApplicationDefaults event. + Private _visualStylesMode As VisualStylesMode = VisualStylesMode.Classic + ' We only need to show the splash screen once. ' Protect the user from himself if they are overriding our app model. Private _didSplashScreen As Boolean @@ -200,6 +203,22 @@ Namespace Microsoft.VisualBasic.ApplicationServices End Set End Property + ''' + ''' Gets or sets the (renderer version) for the application. + ''' + ''' + ''' The that the application uses to render its controls. + ''' + + Protected Property VisualStylesMode As VisualStylesMode + Get + Return _visualStylesMode + End Get + Set(value As VisualStylesMode) + _visualStylesMode = value + End Set + End Property + ''' ''' Determines whether this application will use the XP Windows styles for windows, controls, etc. ''' @@ -748,7 +767,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices HighDpiMode, ColorMode) With { - .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime + .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime, + .VisualStylesMode = VisualStylesMode } RaiseEvent ApplyApplicationDefaults(Me, applicationDefaultsEventArgs) @@ -765,6 +785,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices _highDpiMode = applicationDefaultsEventArgs.HighDpiMode _colorMode = applicationDefaultsEventArgs.ColorMode + _visualStylesMode = applicationDefaultsEventArgs.VisualStylesMode ' Then, it's applying what we got back as HighDpiMode. Dim dpiSetResult As Boolean = Application.SetHighDpiMode(_highDpiMode) @@ -780,6 +801,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices Application.EnableVisualStyles() End If + Application.SetDefaultVisualStylesMode(_visualStylesMode) + Application.SetColorMode(_colorMode) ' We'll handle "/nosplash" for you. diff --git a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt index e69de29bb2d..31142494a9b 100644 --- a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.VisualStylesMode() -> System.Windows.Forms.VisualStylesMode +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.VisualStylesMode(AutoPropertyValue As System.Windows.Forms.VisualStylesMode) -> Void +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.VisualStylesMode() -> System.Windows.Forms.VisualStylesMode +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.VisualStylesMode(value As System.Windows.Forms.VisualStylesMode) -> Void diff --git a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs new file mode 100644 index 00000000000..9c6b1bb7cca --- /dev/null +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace System.Windows.Forms.Animation; + +/// +/// A high-precision static timer designed to trigger WinForms control animations +/// at 60 Hz (or 30 Hz on systems without high-resolution timer support). +/// Controls register callbacks that are marshalled back to the UI thread via +/// the captured . +/// +internal static partial class HighPrecisionTimer +{ + // Target frame intervals. + private const double TargetFrameTimeMs60Hz = 16.667; + private const double TargetFrameTimeMs30Hz = 33.333; + + // We aim the PeriodicTimer earlier than the target frame time to allow + // for spin-wait refinement to hit the target precisely. + private const double TimerTickMs60Hz = 14.0; + private const double TimerTickMs30Hz = 30.0; + + // Maximum drift before we assert (20% of target frame time sustained over 10 frames). + private const int MaxDriftFrames = 10; + private const double MaxDriftThresholdRatio = 0.20; + + private static readonly Lock s_lock = new(); + private static readonly ConcurrentDictionary s_registrations = new(); + private static long s_nextId; + private static CancellationTokenSource? s_cts; + private static Task? s_loopTask; + private static bool s_highResolutionAvailable; + private static double s_targetFrameTimeMs; + private static double s_timerTickMs; + + /// + /// Gets the current target frame time in milliseconds. + /// + internal static double TargetFrameTimeMs => s_targetFrameTimeMs; + + /// + /// Gets whether high-resolution timing (60 Hz) is available on this system. + /// + internal static bool IsHighResolutionAvailable => s_highResolutionAvailable; + + /// + /// Registers a callback to be invoked on each animation frame tick. + /// The current is captured and used + /// to marshal the callback to the appropriate thread. + /// + /// + /// The async callback invoked each frame. Receives timing information and a cancellation token. + /// + /// A that must be disposed to unregister. + /// + /// Thrown when no is available on the current thread. + /// + internal static TimerRegistration Register(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + + SynchronizationContext? syncContext = SynchronizationContext.Current + ?? throw new InvalidOperationException( + "A SynchronizationContext must be available on the calling thread. " + + "Ensure registration is performed from a UI thread."); + + long id = Interlocked.Increment(ref s_nextId); + Registration registration = new(id, callback, syncContext); + s_registrations.TryAdd(id, registration); + + EnsureRunning(); + + return new TimerRegistration(id); + } + + /// + /// Unregisters a previously registered callback. + /// + internal static void Unregister(long registrationId) + { + s_registrations.TryRemove(registrationId, out _); + + if (s_registrations.IsEmpty) + { + StopTimer(); + } + } + + private static void EnsureRunning() + { + lock (s_lock) + { + if (s_loopTask is not null) + { + return; + } + + s_highResolutionAvailable = TrySetHighResolutionTimerMode(); + s_targetFrameTimeMs = s_highResolutionAvailable ? TargetFrameTimeMs60Hz : TargetFrameTimeMs30Hz; + s_timerTickMs = s_highResolutionAvailable ? TimerTickMs60Hz : TimerTickMs30Hz; + + s_cts = new CancellationTokenSource(); + CancellationToken cancellationToken = s_cts.Token; + s_loopTask = Task.Factory.StartNew( + () => TimerLoopAsync(cancellationToken), + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap(); + } + } + + private static void StopTimer() + { + CancellationTokenSource? cts; + + lock (s_lock) + { + cts = s_cts; + s_cts = null; + s_loopTask = null; + } + + if (cts is not null) + { + cts.Cancel(); + cts.Dispose(); + } + + // Best-effort: restore timer resolution. + if (s_highResolutionAvailable) + { + ResetTimerResolution(); + } + } + + private static async Task TimerLoopAsync(CancellationToken cancellationToken) + { + using PeriodicTimer periodicTimer = new(TimeSpan.FromMilliseconds(s_timerTickMs)); + Stopwatch stopwatch = Stopwatch.StartNew(); + long lastTickTimestamp = 0; + int consecutiveDriftFrames = 0; + + try + { + while (await periodicTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + // Spin-wait refinement: if we woke up early, spin until target time. + double targetMs = lastTickTimestamp + s_targetFrameTimeMs; + SpinToTarget(stopwatch, targetMs); + + long currentTimestamp = stopwatch.ElapsedMilliseconds; + double elapsed = currentTimestamp - lastTickTimestamp; + + // Drift detection: check if we are consistently overshooting. + double drift = elapsed - s_targetFrameTimeMs; + if (Math.Abs(drift) > s_targetFrameTimeMs * MaxDriftThresholdRatio) + { + consecutiveDriftFrames++; + Debug.Assert( + consecutiveDriftFrames < MaxDriftFrames, + $"HighPrecisionTimer: Excessive drift detected. " + + $"Drift: {drift:F2}ms over {consecutiveDriftFrames} consecutive frames."); + } + else + { + consecutiveDriftFrames = 0; + } + + lastTickTimestamp = currentTimestamp; + + // Dispatch to all registered callbacks. + DispatchCallbacks( + TimeSpan.FromMilliseconds(currentTimestamp), + TimeSpan.FromMilliseconds(elapsed), + cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Normal shutdown. + } + } + + private static void SpinToTarget(Stopwatch stopwatch, double targetMs) + { + SpinWait spinner = default; + + while (stopwatch.Elapsed.TotalMilliseconds < targetMs) + { + // SpinOnce with sleep1Threshold=-1 ensures we stay in PAUSE/yield + // mode and never escalate to Thread.Sleep(1). + spinner.SpinOnce(sleep1Threshold: -1); + } + } + + private static void DispatchCallbacks( + TimeSpan timestamp, + TimeSpan elapsed, + CancellationToken cancellationToken) + { + foreach (KeyValuePair kvp in s_registrations) + { + Registration registration = kvp.Value; + + // Skip if previous callback is still in flight (frame coalescing). + if (Interlocked.CompareExchange(ref registration.InFlight, 1, 0) != 0) + { + Interlocked.Increment(ref registration.DroppedFrames); + continue; + } + + long frameIndex = Interlocked.Increment(ref registration.FrameIndex) - 1; + int dropped = Interlocked.Exchange(ref registration.DroppedFrames, 0); + + HighPrecisionTimerTick tick = new() + { + Timestamp = timestamp, + Elapsed = elapsed, + DroppedFrames = dropped, + FrameIndex = frameIndex + }; + + registration.SyncContext.Post( + _ => _ = InvokeCallbackAsync(registration, tick, cancellationToken), + null); + } + } + + private static async Task InvokeCallbackAsync( + Registration registration, + HighPrecisionTimerTick tick, + CancellationToken cancellationToken) + { + try + { + await registration.Callback(tick, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Debug.Fail($"HighPrecisionTimer: Unhandled exception in callback: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref registration.InFlight, 0); + } + } + + [SupportedOSPlatform("windows10.0.17134.0")] + private static bool TrySetHighResolutionTimerMode() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134)) + { + return false; + } + + try + { + // Use timeBeginPeriod for documented, reliable high-resolution timing. + return NativeMethods.TimeBeginPeriod(1) == 0; // TIMERR_NOERROR + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + return false; + } + } + + private static void ResetTimerResolution() + { + try + { + NativeMethods.TimeEndPeriod(1); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + // Best effort. + } + } + + private static partial class NativeMethods + { + [LibraryImport("winmm.dll")] + internal static partial int TimeBeginPeriod(int uPeriod); + + [LibraryImport("winmm.dll")] + internal static partial int TimeEndPeriod(int uPeriod); + } + + private sealed class Registration( + long id, + Func callback, + SynchronizationContext syncContext) + { + public long Id { get; } = id; + public Func Callback { get; } = callback; + public SynchronizationContext SyncContext { get; } = syncContext; + public int InFlight; + public int DroppedFrames; + public long FrameIndex; + } + + /// + /// Represents a timer registration. Dispose to unregister. + /// + internal readonly struct TimerRegistration : IDisposable + { + private readonly long _id; + + internal TimerRegistration(long id) => _id = id; + + /// Gets the registration identifier. + public long Id => _id; + + /// Unregisters this callback from the timer. + public void Dispose() => Unregister(_id); + } + + /// + /// Resets internal state. For testing purposes only. + /// + internal static void Reset() + { + StopTimer(); + s_registrations.Clear(); + s_nextId = 0; + } +} diff --git a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs new file mode 100644 index 00000000000..3b6557e2643 --- /dev/null +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs @@ -0,0 +1,30 @@ +// 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.Animation; + +/// +/// Provides timing information for a single animation frame tick. +/// +internal readonly struct HighPrecisionTimerTick +{ + /// + /// The absolute timestamp of this tick from the timer's epoch. + /// + public TimeSpan Timestamp { get; init; } + + /// + /// The elapsed time since the last tick delivered to this registration. + /// + public TimeSpan Elapsed { get; init; } + + /// + /// The number of frames that were dropped (coalesced) since the last delivered tick. + /// + public int DroppedFrames { get; init; } + + /// + /// The zero-based frame index for this registration. + /// + public long FrameIndex { get; init; } +} diff --git a/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs b/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs new file mode 100644 index 00000000000..b577b820494 --- /dev/null +++ b/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Windows.Forms.Animation; + +namespace System.Windows.Forms.Primitives.Tests.Animation; + +/// +/// A test synchronization context that executes posted callbacks immediately +/// on the thread pool, simulating a UI message pump for testing purposes. +/// +internal sealed class TestSynchronizationContext : SynchronizationContext +{ + public override void Post(SendOrPostCallback d, object? state) => ThreadPool.QueueUserWorkItem(_ => d(state)); + + public override void Send(SendOrPostCallback d, object? state) => d(state); +} + +// The timer is process-wide static; disable parallelization so timing-sensitive +// assertions are not perturbed by concurrently running tests. +[Collection(nameof(HighPrecisionTimerTests))] +[CollectionDefinition(nameof(HighPrecisionTimerTests), DisableParallelization = true)] +public sealed class HighPrecisionTimerTests : IDisposable +{ + private readonly SynchronizationContext? _originalContext; + + public HighPrecisionTimerTests() + { + _originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + } + + public void Dispose() + { + HighPrecisionTimer.Reset(); + SynchronizationContext.SetSynchronizationContext(_originalContext); + } + + [Fact] + public async Task SingleConsumer_ReceivesTicksAtApproximatelyExpectedRate() + { + ConcurrentBag intervals = []; + Stopwatch stopwatch = Stopwatch.StartNew(); + double lastTick = 0; + int tickCount = 0; + const int TargetTicks = 30; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + double now = stopwatch.Elapsed.TotalMilliseconds; + if (lastTick > 0) + { + intervals.Add(now - lastTick); + } + + lastTick = now; + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount >= TargetTicks); + + List sorted = [.. intervals.OrderBy(x => x)]; + double targetMs = HighPrecisionTimer.TargetFrameTimeMs; + + // Relaxed bounds to remain robust on loaded CI machines: the median must be in a + // sane band around the target frame time. + double median = Percentile(sorted, 0.50); + median.Should().BeLessThan(targetMs * 3.0, "the median frame interval should stay near the target"); + } + + [Fact] + public async Task MultipleConsumers_AllReceiveTicksIndependently() + { + const int ConsumerCount = 5; + const int TargetTicks = 15; + int[] tickCounts = new int[ConsumerCount]; + HighPrecisionTimer.TimerRegistration[] registrations = new HighPrecisionTimer.TimerRegistration[ConsumerCount]; + + for (int i = 0; i < ConsumerCount; i++) + { + int index = i; + registrations[i] = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref tickCounts[index]); + return ValueTask.CompletedTask; + }); + } + + await WaitForAsync(() => tickCounts.Min() >= TargetTicks); + + foreach (HighPrecisionTimer.TimerRegistration registration in registrations) + { + registration.Dispose(); + } + + tickCounts.Should().OnlyContain(count => count >= TargetTicks); + } + + [Fact] + public async Task SlowConsumer_DropsFramesInsteadOfQueuing() + { + ConcurrentBag ticks = []; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + async (tick, ct) => + { + ticks.Add(tick); + // Simulate slow rendering (well over one frame time). + await Task.Delay((int)(HighPrecisionTimer.TargetFrameTimeMs * 3), ct); + }); + + await WaitForAsync(() => ticks.Sum(t => t.DroppedFrames) > 0, timeoutMs: 4000); + + ticks.Sum(t => t.DroppedFrames).Should().BeGreaterThan(0, "a slow consumer should report dropped frames"); + } + + [Fact] + public async Task Registration_Disposal_StopsCallbacks() + { + int tickCount = 0; + + HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount > 0); + registration.Dispose(); + int ticksAfterDispose = Volatile.Read(ref tickCount); + + await Task.Delay(200, TestContext.Current.CancellationToken); + + // At most a couple of in-flight callbacks may land right after disposal. + (Volatile.Read(ref tickCount) - ticksAfterDispose).Should().BeLessThanOrEqualTo(2); + } + + [Fact] + public void Registration_WithoutSyncContext_Throws() + { + SynchronizationContext? original = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + + try + { + Action act = () => HighPrecisionTimer.Register((tick, ct) => ValueTask.CompletedTask); + act.Should().Throw(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + } + + [Fact] + public async Task TimerTick_ProvidesElapsedAndIncreasingFrameIndex() + { + ConcurrentBag elapsedValues = []; + long lastFrameIndex = -1; + bool frameIndexMonotonic = true; + int tickCount = 0; + const int TargetTicks = 15; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + if (tick.FrameIndex <= lastFrameIndex) + { + frameIndexMonotonic = false; + } + + lastFrameIndex = tick.FrameIndex; + + if (tick.FrameIndex > 0) + { + elapsedValues.Add(tick.Elapsed.TotalMilliseconds); + } + + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount >= TargetTicks); + + frameIndexMonotonic.Should().BeTrue("frame indices should increase monotonically"); + elapsedValues.Should().NotBeEmpty(); + elapsedValues.Should().OnlyContain(value => value > 0, "elapsed time between ticks should be positive"); + } + + private static double Percentile(List sortedValues, double percentile) + { + if (sortedValues.Count == 0) + { + return 0; + } + + int index = (int)Math.Ceiling(percentile * sortedValues.Count) - 1; + return sortedValues[Math.Max(0, index)]; + } + + private static async Task WaitForAsync(Func condition, int timeoutMs = 5000) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + if (stopwatch.ElapsedMilliseconds > timeoutMs) + { + throw new TimeoutException("Timed out waiting for the expected timer ticks."); + } + + await Task.Delay(25, TestContext.Current.CancellationToken); + } + } +} diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..aa75c9962cb 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,19 @@ +#nullable enable +override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void +override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.CheckBox.OnVisualStylesModeChanged(System.EventArgs! e) -> void +static System.Windows.Forms.Application.DefaultVisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +static System.Windows.Forms.Application.SetDefaultVisualStylesMode(System.Windows.Forms.VisualStylesMode styleSetting) -> void +System.Windows.Forms.Appearance.ToggleSwitch = 2 -> System.Windows.Forms.Appearance +System.Windows.Forms.Control.VisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.Control.VisualStylesMode.set -> void +System.Windows.Forms.Control.VisualStylesModeChanged -> System.EventHandler? +System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Classic = 0 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Disabled = 1 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Latest = 32767 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Net11 = 2 -> System.Windows.Forms.VisualStylesMode +virtual System.Windows.Forms.Control.DefaultVisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +virtual System.Windows.Forms.Control.OnParentVisualStylesModeChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.Control.OnVisualStylesModeChanged(System.EventArgs! e) -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..168cb5ba2ae 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -171,6 +171,9 @@ Application exception mode cannot be changed once any Controls are created in the application. + + The default visual styles mode can only be set once and cannot be changed afterwards. + &Apply @@ -6931,6 +6934,12 @@ Stack trace where the illegal operation occurred was: Occurs when the value of the DataContext property changes. + + Determines how the control renders itself when visual styles are applied. + + + Occurs when the value of the VisualStylesMode property changes. + Gets or sets the parameter that is passed to the Command property's object on execution or on execution context request. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..a4decd0c852 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -242,6 +242,11 @@ Režim výjimky vlákna nelze změnit po vytvoření ovládacích prvků ve vlákně. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Použít @@ -2172,6 +2177,16 @@ Určuje, zda je ovládací prvek viditelný nebo skrytý. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Šířka ovládacího prvku, v souřadnicích kontejneru. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..7236f7cccdf 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -242,6 +242,11 @@ Der Threadausnahmemodus kann nicht mehr geändert werden, sobald Steuerelemente in dem Thread erstellt wurden. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Anwenden @@ -2172,6 +2177,16 @@ Bestimmt, ob das Steuerelement sichtbar oder ausgeblendet ist. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Die Breite des Steuerelements (in Containerkoordinaten). diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..f8a1528ce7d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -242,6 +242,11 @@ El modo de excepción del subproceso no se puede cambiar una vez que se creen Controles en el subproceso. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Aplicar @@ -2172,6 +2177,16 @@ Determina si el control está visible u oculto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Ancho del control, en las coordenadas del contenedor. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..772318b29b4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -242,6 +242,11 @@ Impossible de modifier le mode d'exceptions du thread une fois que des Controls ont été créés sur le thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Appliquer @@ -2172,6 +2177,16 @@ Détermine si le contrôle est visible ou masqué. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. La largeur du contrôle, en coordonnées conteneur. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..8e75e890990 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -242,6 +242,11 @@ Una volta creati controlli nel thread, non è possibile modificare la modalità eccezioni del thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Applica @@ -2172,6 +2177,16 @@ Determina se il controllo è visibile o nascosto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. La larghezza del controllo, nelle coordinate del contenitore. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..91ca033cf05 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -242,6 +242,11 @@ スレッドでコントロールが作成された後、スレッド例外モードを変更することはできません。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply 適用(&A) @@ -2172,6 +2177,16 @@ コントロールの表示、非表示を示します。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. コンテナー座標で表したコントロールの幅です。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..612beb8b44f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -242,6 +242,11 @@ 스레드에서 컨트롤이 만들어진 이후에는 스레드 예외 모드를 변경할 수 없습니다. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply 적용(&A) @@ -2172,6 +2177,16 @@ 컨트롤을 표시할지 여부를 결정합니다. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 컨테이너 좌표로 표시한 컨트롤의 너비입니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..ea628ae265e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -242,6 +242,11 @@ Trybu wyjątków wątku nie można zmienić po utworzeniu jakichkolwiek formantów w aplikacji. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Zastosuj @@ -2172,6 +2177,16 @@ Określa, czy formant jest widoczny, czy ukryty. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Szerokość formantu we współrzędnych kontenera. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..c550a374866 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -242,6 +242,11 @@ Não é possível alterar o modo de exceção de thread após a criação de qualquer controle no thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Aplicar @@ -2172,6 +2177,16 @@ Determina se o controle está visível ou oculto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. A largura do controle, nas coordenadas do recipiente. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..5cc2555a79c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -242,6 +242,11 @@ Режим исключений потоков нельзя изменить, если в потоке были созданы элементы управления. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Применить @@ -2172,6 +2177,16 @@ Определяет, отображается или скрыт данный элемент управления. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Ширина элемента управления в координатах контейнера. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..d14fe6ca79c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -242,6 +242,11 @@ İş parçacığında herhangi bir Denetim oluşturulduktan sonra iş parçacığı özel durum modu değiştirilemez. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply &Uygula @@ -2172,6 +2177,16 @@ Denetimin görünür mü yoksa gizli mi olduğunu belirler. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Denetimin genişliği (kapsayıcı koordinatlarında). diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..c4c47d14f36 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -242,6 +242,11 @@ 只要在线程上创建了任何控件,则线程异常模式将不能再有任何更改。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply 应用(&A) @@ -2172,6 +2177,16 @@ 确定该控件是可见的还是隐藏的。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 控件的宽度(以容器坐标表示)。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..a87ab568b67 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -242,6 +242,11 @@ 一旦在執行緒上建立了控制項,就不能變更執行緒例外狀況模式。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + &Apply 套用(&A) @@ -2172,6 +2177,16 @@ 決定控制項是可見或隱藏。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 容器座標中控制項的寬度。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index aab496c2744..08c6e7349fa 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -45,6 +45,8 @@ public sealed partial class Application private static SystemColorMode? s_colorMode; + private static VisualStylesMode? s_defaultVisualStylesMode; + private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; private const string DarkModeKey = "AppsUseLightTheme"; private const int SystemDarkModeDisabled = 1; @@ -592,6 +594,54 @@ public static void RegisterMessageLoop(MessageLoopCallback? callback) public static bool RenderWithVisualStyles => ComCtlSupportsVisualStyles && VisualStyleRenderer.IsSupported; + /// + /// Gets the default used as the rendering style guideline for the + /// application's controls. + /// + /// + /// The used as the rendering style guideline for the application's + /// controls. This is when visual styles are enabled and + /// otherwise, unless it has been changed by a call to + /// . + /// + /// + /// + /// The default value is so that applications that simply + /// recompile against a newer framework keep their existing look. Opt in to a newer renderer by + /// calling . + /// + /// + public static VisualStylesMode DefaultVisualStylesMode + => s_defaultVisualStylesMode ??= + UseVisualStyles ? VisualStylesMode.Classic : VisualStylesMode.Disabled; + + /// + /// Sets the default used as the rendering style guideline for the + /// application's controls. + /// + /// The version of the visual styles renderer to use by default. + /// + /// The default visual styles mode has already been set to a different value. It can only be set once. + /// + /// + /// + /// Call this method before creating any window. If visual styles have not been enabled through + /// , the effective mode remains . + /// Passing has the same effect as not calling + /// . + /// + /// + public static void SetDefaultVisualStylesMode(VisualStylesMode styleSetting) + { + if (s_defaultVisualStylesMode is { } current && current != styleSetting) + { + throw new InvalidOperationException(SR.Application_VisualStylesModeCanOnlyBeSetOnce); + } + + // Without visual styles enabled, the only effective mode is Disabled. + s_defaultVisualStylesMode = UseVisualStyles ? styleSetting : VisualStylesMode.Disabled; + } + /// /// Gets or sets the format string to apply to top level window captions /// when they are displayed with a warning banner. @@ -980,6 +1030,13 @@ public static void EnableVisualStyles() Debug.Assert(UseVisualStyles, "Enable Visual Styles failed"); s_comCtlSupportsVisualStylesInitialized = false; + + // Keep the default visual styles mode in sync when EnableVisualStyles is called after + // SetDefaultVisualStylesMode(VisualStylesMode.Disabled): an explicitly disabled mode becomes Classic. + if (UseVisualStyles && s_defaultVisualStylesMode == VisualStylesMode.Disabled) + { + s_defaultVisualStylesMode = VisualStylesMode.Classic; + } } /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 14efc5014ff..a45099b428c 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -146,6 +146,7 @@ public unsafe partial class Control : private protected static readonly object s_paddingChangedEvent = new(); private static readonly object s_previewKeyDownEvent = new(); private static readonly object s_dataContextEvent = new(); + private static readonly object s_visualStylesModeChangedEvent = new(); private static MessageId s_threadCallbackMessage; private static ContextCallback? s_invokeMarshaledCallbackHelperDelegate; @@ -224,6 +225,7 @@ public unsafe partial class Control : private static readonly int s_cacheTextFieldProperty = PropertyStore.CreateKey(); private static readonly int s_ambientPropertiesServiceProperty = PropertyStore.CreateKey(); private static readonly int s_dataContextProperty = PropertyStore.CreateKey(); + private static readonly int s_visualStylesModeProperty = PropertyStore.CreateKey(); private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); @@ -856,6 +858,80 @@ private bool ShouldSerializeDataContext() private void ResetDataContext() => Properties.RemoveValue(s_dataContextProperty); + /// + /// Gets or sets how the control renders itself when visual styles are applied. This is an ambient property. + /// + /// + /// The for the control. When not explicitly set, the value is inherited + /// from the parent control, or, for a top-level control, from . + /// + /// + /// + /// As an ambient property, a control that does not have its set explicitly + /// inherits the value from its parent, or, if it has no parent, from + /// . Derived controls can override + /// to pin themselves to a specific renderer version for backward + /// compatibility (see for an example). + /// + /// + [SRCategory(nameof(SR.CatAppearance))] + [EditorBrowsable(EditorBrowsableState.Always)] + [SRDescription(nameof(SR.ControlVisualStylesModeDescr))] + public VisualStylesMode VisualStylesMode + { + get => Properties.TryGetValue(s_visualStylesModeProperty, out VisualStylesMode value) + ? value + : ParentInternal?.VisualStylesMode ?? DefaultVisualStylesMode; + set + { + // Can't use the source generated enum validator here, since it cannot deal with [Experimental]. + _ = value switch + { + VisualStylesMode.Classic => value, + VisualStylesMode.Disabled => value, + VisualStylesMode.Net11 => value, + VisualStylesMode.Latest => value, + _ => throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(VisualStylesMode)) + }; + + if (value == VisualStylesMode) + { + return; + } + + // When VisualStylesMode differed from its parent before but is about to become the same, + // we remove it altogether so it can again inherit the value from its parent. + if (Properties.ContainsKey(s_visualStylesModeProperty) && ParentInternal?.VisualStylesMode == value) + { + Properties.RemoveValue(s_visualStylesModeProperty); + OnVisualStylesModeChanged(EventArgs.Empty); + return; + } + + Properties.AddValue(s_visualStylesModeProperty, value); + OnVisualStylesModeChanged(EventArgs.Empty); + } + } + + private bool ShouldSerializeVisualStylesMode() + => Properties.ContainsKey(s_visualStylesModeProperty); + + private void ResetVisualStylesMode() + => Properties.RemoveValue(s_visualStylesModeProperty); + + /// + /// Gets the default for the control, which is ambient to + /// . + /// + /// The default visual styles mode for the control. + /// + /// + /// Derived controls can override this property to pin themselves to a specific renderer version when their + /// rendering or layout depends on it, independent of the application-wide default. + /// + /// + protected virtual VisualStylesMode DefaultVisualStylesMode => Application.DefaultVisualStylesMode; + /// /// The background color of this control. This is an ambient property and /// will always return a non-null value. @@ -3739,6 +3815,19 @@ public event EventHandler? DataContextChanged remove => Events.RemoveHandler(s_dataContextEvent, value); } + /// + /// Occurs when the value of the property changes. + /// + [SRCategory(nameof(SR.CatAppearance))] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Advanced)] + [SRDescription(nameof(SR.ControlVisualStylesModeChangedDescr))] + public event EventHandler? VisualStylesModeChanged + { + add => Events.AddHandler(s_visualStylesModeChangedEvent, value); + remove => Events.RemoveHandler(s_visualStylesModeChangedEvent, value); + } + [SRCategory(nameof(SR.CatDragDrop))] [SRDescription(nameof(SR.ControlOnDragDropDescr))] public event DragEventHandler? DragDrop @@ -6829,6 +6918,34 @@ protected virtual void OnDataContextChanged(EventArgs e) } } + /// + /// Raises the event. Inheriting classes should override this method + /// to handle the event, and call . + /// to forward the event to any registered listeners. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnVisualStylesModeChanged(EventArgs e) + { + if (GetAnyDisposingInHierarchy()) + { + return; + } + + if (Events[s_visualStylesModeChangedEvent] is EventHandler eventHandler) + { + eventHandler(this, e); + } + + if (ChildControls is { } children) + { + for (int i = 0; i < children.Count; i++) + { + children[i].OnParentVisualStylesModeChanged(e); + } + } + } + [EditorBrowsable(EditorBrowsableState.Advanced)] protected virtual void OnDockChanged(EventArgs e) { @@ -7042,6 +7159,29 @@ protected virtual void OnParentDataContextChanged(EventArgs e) OnDataContextChanged(e); } + /// + /// Occurs when the property of the parent of this control changes. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnParentVisualStylesModeChanged(EventArgs e) + { + if (Properties.ContainsKey(s_visualStylesModeProperty) + && Properties.GetValueOrDefault(s_visualStylesModeProperty) == Parent?.VisualStylesMode) + { + // Same as the parent value, make it ambient again by removing it. + Properties.RemoveValue(s_visualStylesModeProperty); + + // Even though internally we don't store it any longer, and the value we had stored + // therefore changed, technically the value remains the same, so we don't raise the + // VisualStylesModeChanged event. + return; + } + + // In every other case we're going to raise the event. + OnVisualStylesModeChanged(e); + } + [EditorBrowsable(EditorBrowsableState.Advanced)] protected virtual void OnParentEnabledChanged(EventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs index fb144cac774..83aacaaf244 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs @@ -1,4 +1,4 @@ -// 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. namespace System.Windows.Forms; @@ -17,4 +17,15 @@ public enum Appearance /// The appearance of a Windows button. /// Button = 1, + + /// + /// The appearance of a modern UI toggle switch. + /// + /// + /// + /// This value has no effect when is set to + /// or . + /// + /// + ToggleSwitch = 2 } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs index e76dd755f29..5843696f0cb 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs @@ -148,40 +148,6 @@ public virtual DialogResult DialogResult } } - /// - /// Defines, whether the control is owner-drawn. Based on this, - /// the UserPaint flags get set, which in turn makes it later - /// a Win32 controls, which we wrap (OwnerDraw == false) or not, and then - /// we draw ourselves. If the user wants to opt out of DarkMode, we can no - /// longer force (wrapping) System-Painting for FlatStyle.Standard, and we - /// need this then also here and now, before CreateParams is called. - /// - private protected override bool OwnerDraw - { - get - { - if (Application.IsDarkModeEnabled - - // The SystemRenderer cannot render images. So, we flip to our - // own DarkMode renderer, if we need to render images, except if... - && Image is null - // ...or a BackgroundImage, except if... - && BackgroundImage is null - // ...the user wants to opt out of implicit DarkMode rendering. - && DarkModeRequestState is true - - // And all of this only counts for FlatStyle.Standard. For the - // rest, we're using specific renderers anyway, which check - // themselves on demand, if they need to apply Light- or DarkMode. - && FlatStyle == FlatStyle.Standard) - { - return false; - } - - return base.OwnerDraw; - } - } - internal override bool SupportsUiaProviders => true; /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs index 26c3292e175..f7fe95af5ff 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs @@ -1256,6 +1256,22 @@ protected override void OnPaint(PaintEventArgs pevent) base.OnPaint(pevent); } + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + + // The button adapter is selected based on VisualStylesMode, so drop the cached adapter and + // force it to be recreated (and the button repainted) on the next paint. + _adapter = null; + _cachedAdapterType = (FlatStyle)(-1); + + if (IsHandleCreated) + { + Invalidate(); + } + } + protected override void OnParentChanged(EventArgs e) { base.OnParentChanged(e); 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..8db1ce8d28b 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 @@ -12,14 +12,22 @@ internal class ButtonDarkModeAdapter : ButtonBaseAdapter internal ButtonDarkModeAdapter(ButtonBase control) : base(control) { + bool modern = control.VisualStylesMode >= VisualStylesMode.Net11; + _buttonDarkModeRenderer = control.FlatStyle switch { - FlatStyle.Standard => new FlatButtonDarkModeRenderer(), - FlatStyle.Flat => new FlatButtonDarkModeRenderer(), - FlatStyle.Popup => new PopupButtonDarkModeRenderer(), + // With VisualStyles (.NET 11+) the modern, WinUI-inspired renderer is used for the owner-drawn + // styles. Otherwise FlatStyle.Standard renders with a conservative owner-drawn renderer that mimics + // the dark-mode system button (instead of delegating to the Win32 control); this makes the owner-drawn + // path reachable and lets Standard buttons support images, focus cues, etc. + FlatStyle.Standard => modern ? new ModernButtonDarkModeRenderer() : new SystemButtonDarkModeRenderer(), + FlatStyle.Flat => modern ? new ModernButtonDarkModeRenderer() : new FlatButtonDarkModeRenderer(), + FlatStyle.Popup => modern ? new ModernButtonDarkModeRenderer() : new PopupButtonDarkModeRenderer(), FlatStyle.System => new SystemButtonDarkModeRenderer(), _ => throw new ArgumentOutOfRangeException(nameof(control)) }; + + _buttonDarkModeRenderer.DeviceDpi = control.DeviceDpi; } private ButtonDarkModeRendererBase ButtonDarkModeRenderer => diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs index 9d655a618b0..b0834ee0070 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs @@ -14,6 +14,17 @@ internal abstract partial class ButtonDarkModeRendererBase : IButtonRenderer // Define padding values for each renderer type private protected abstract Padding PaddingCore { get; } + /// + /// The device DPI of the control being rendered. Set by the adapter before each paint so renderers + /// can DPI-scale their logical (96-DPI) constants. Defaults to 96 (100%). + /// + internal int DeviceDpi { get; set; } = 96; + + /// + /// Scales a logical (96-DPI) value to the current . + /// + private protected int Scale(int logicalValue) => (int)Math.Round(logicalValue * (DeviceDpi / 96.0)); + /// /// Clears the background with the parent's background color or the control's background color if no parent is available. /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs index 55552647bce..bf7f7144dfa 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs @@ -5,18 +5,25 @@ namespace System.Windows.Forms.ButtonInternal; internal static class DarkModeAdapterFactory { + // The owner-drawn dark/modern adapter is used when dark mode is enabled (conservative renderer) or when + // the control opts into the modern .NET 11 visual styles (modern renderer, in either dark or light scheme). + private static bool UseOwnerDrawnAdapter(ButtonBase control) + { + return Application.IsDarkModeEnabled || control.VisualStylesMode >= VisualStylesMode.Net11; + } + public static ButtonBaseAdapter CreateFlatAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonFlatAdapter(control); public static ButtonBaseAdapter CreateStandardAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonStandardAdapter(control); public static ButtonBaseAdapter CreatePopupAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonPopupAdapter(control); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs new file mode 100644 index 00000000000..ac1ea0c013e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs @@ -0,0 +1,199 @@ +// 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; +using System.Drawing.Drawing2D; +using System.Windows.Forms.VisualStyles; + +namespace System.Windows.Forms; + +/// +/// Modern, WinUI-inspired push button renderer used when is +/// or later. It supports both dark and light color schemes and draws a +/// rounded button area, an optional dark gap ring, and a rounded focus ring for the focused button. +/// +/// +/// +/// The visual is intentionally driven by a small set of DPI-scaled constants so the appearance can be +/// fine-tuned during exploratory testing. All widths are expressed in logical (96-DPI) pixels. +/// +/// +internal sealed class ModernButtonDarkModeRenderer : ButtonDarkModeRendererBase +{ + // Logical (96-DPI) layout constants. DPI-scaled via the base Scale() helper. + private const int FocusRingThicknessLogical = 2; + private const int FocusGapThicknessLogical = 1; + private const int OuterBreathingLogical = 1; + private const int CornerRadiusLogical = 6; + private const int ContentInsetLogical = 4; + + // Dark scheme - default (accept) button area. + private static readonly Color s_darkDefaultNormal = Color.FromArgb(0x4C, 0xC2, 0xFF); + private static readonly Color s_darkDefaultHover = Color.FromArgb(0x47, 0xB1, 0xE8); + private static readonly Color s_darkDefaultPressed = Color.FromArgb(0x42, 0xA1, 0xD2); + + // Dark scheme - normal button area. + private static readonly Color s_darkNormal = Color.FromArgb(0x2D, 0x2D, 0x2D); + private static readonly Color s_darkNormalHover = Color.FromArgb(0x32, 0x32, 0x32); + private static readonly Color s_darkNormalPressed = Color.FromArgb(0x2A, 0x2A, 0x2A); + private static readonly Color s_darkDisabled = Color.FromArgb(0x25, 0x25, 0x25); + + private static readonly Color s_darkDefaultText = Color.Black; + private static readonly Color s_darkNormalText = Color.FromArgb(0xF0, 0xF0, 0xF0); + private static readonly Color s_darkDisabledText = Color.FromArgb(0x88, 0x88, 0x88); + + private static readonly Color s_darkGap = Color.FromArgb(0x0A, 0x0A, 0x0A); + private static readonly Color s_darkFocusRing = Color.White; + + // Light (WinUI) scheme - normal button area. + private static readonly Color s_lightNormal = Color.FromArgb(0xFB, 0xFB, 0xFB); + private static readonly Color s_lightNormalHover = Color.FromArgb(0xF9, 0xF9, 0xF9); + private static readonly Color s_lightNormalPressed = Color.FromArgb(0xF5, 0xF5, 0xF5); + private static readonly Color s_lightDisabled = Color.FromArgb(0xFA, 0xFA, 0xFA); + private static readonly Color s_lightBorder = Color.FromArgb(0xD0, 0xD0, 0xD0); + + private static readonly Color s_lightNormalText = Color.FromArgb(0x1A, 0x1A, 0x1A); + private static readonly Color s_lightDisabledText = Color.FromArgb(0xA0, 0xA0, 0xA0); + private static readonly Color s_lightDefaultText = Color.White; + + private static bool IsDark => Application.IsDarkModeEnabled; + + private int FocusRingThickness => Math.Max(1, Scale(FocusRingThicknessLogical)); + + private int FocusGapThickness => Math.Max(1, Scale(FocusGapThicknessLogical)); + + private int CornerRadius => Math.Max(1, Scale(CornerRadiusLogical)); + + private protected override Padding PaddingCore + => new(FocusRingThickness + FocusGapThickness + Math.Max(0, Scale(OuterBreathingLogical))); + + public override Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + Color backColor) + { + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + int radius = CornerRadius; + using (var brush = backColor.GetCachedSolidBrushScope()) + { + graphics.FillRoundedRectangle(brush, bounds, new Size(radius, radius)); + } + + // A subtle border for the light, non-default button, matching the WinUI neutral button. + if (!IsDark && !isDefault && state != PushButtonState.Disabled) + { + using var borderPen = s_lightBorder.GetCachedPenScope(); + Rectangle borderRect = new(bounds.X, bounds.Y, bounds.Width - 1, bounds.Height - 1); + graphics.DrawRoundedRectangle(borderPen, borderRect, new Size(radius, radius)); + } + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + + int inset = Scale(ContentInsetLogical); + return Rectangle.Inflate(bounds, -inset, -inset); + } + + public override void DrawFocusIndicator(Graphics graphics, Rectangle bounds, bool isDefault) + { + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + int radius = CornerRadius + FocusGapThickness + FocusRingThickness; + + // Dark gap ring between the focus ring and the button area. + Color gapColor = IsDark ? s_darkGap : SystemColors.Window; + int gapInset = FocusRingThickness + (FocusGapThickness / 2); + Rectangle gapRect = Rectangle.Inflate(bounds, -gapInset, -gapInset); + gapRect.Width -= 1; + gapRect.Height -= 1; + using (var gapPen = gapColor.GetCachedPenScope(FocusGapThickness)) + { + graphics.DrawRoundedRectangle(gapPen, gapRect, new Size(radius, radius)); + } + + // Outer rounded focus ring. + Color ringColor = IsDark ? s_darkFocusRing : SystemColors.WindowText; + int ringInset = FocusRingThickness / 2; + Rectangle ringRect = Rectangle.Inflate(bounds, -ringInset, -ringInset); + ringRect.Width -= 1; + ringRect.Height -= 1; + using var ringPen = ringColor.GetCachedPenScope(FocusRingThickness); + graphics.DrawRoundedRectangle(ringPen, ringRect, new Size(radius + ringInset, radius + ringInset)); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + } + + public override Color GetTextColor(PushButtonState state, bool isDefault) + { + if (state == PushButtonState.Disabled) + { + return IsDark ? s_darkDisabledText : s_lightDisabledText; + } + + if (isDefault) + { + return IsDark ? s_darkDefaultText : s_lightDefaultText; + } + + return IsDark ? s_darkNormalText : s_lightNormalText; + } + + public override Color GetBackgroundColor(PushButtonState state, bool isDefault) + { + if (state == PushButtonState.Disabled) + { + return IsDark ? s_darkDisabled : s_lightDisabled; + } + + if (isDefault) + { + return IsDark + ? state switch + { + PushButtonState.Hot => s_darkDefaultHover, + PushButtonState.Pressed => s_darkDefaultPressed, + _ => s_darkDefaultNormal + } + : state switch + { + PushButtonState.Hot => ControlPaint.Light(SystemColors.Highlight, 0.1f), + PushButtonState.Pressed => ControlPaint.Dark(SystemColors.Highlight, 0.1f), + _ => SystemColors.Highlight + }; + } + + return IsDark + ? state switch + { + PushButtonState.Hot => s_darkNormalHover, + PushButtonState.Pressed => s_darkNormalPressed, + _ => s_darkNormal + } + : state switch + { + PushButtonState.Hot => s_lightNormalHover, + PushButtonState.Pressed => s_lightNormalPressed, + _ => s_lightNormal + }; + } +} 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..fd532dd4d7b 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 @@ -29,6 +29,8 @@ public partial class CheckBox : ButtonBase private CheckState _checkState; private Appearance _appearance; + private Rendering.CheckBox.AnimatedToggleSwitchRenderer? _toggleSwitchRenderer; + private int _flatSystemStylePaddingWidth; private int _flatSystemStyleMinimumHeight; @@ -80,6 +82,7 @@ public Appearance Appearance // the handle if they differ. Since we hijack FlatStyle.Standard for DarkMode, the transition // between Normal and Button appearance is critical for updating the OwnerDraw flag. UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); // If handle wasn't recreated (OwnerDraw state didn't change), refresh the appearance. if (OwnerDraw) @@ -97,16 +100,44 @@ public Appearance Appearance } private protected override bool OwnerDraw => + // The modern toggle switch is always owner-drawn (so UserPaint is enabled for it). + IsToggleSwitchAppearance + || // 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 + ((!Application.IsDarkModeEnabled || Appearance != Appearance.Button || FlatStyle != FlatStyle.Standard) - && base.OwnerDraw; + && base.OwnerDraw); + + /// + /// Gets a value indicating whether the check box should render as the modern, animated toggle switch. + /// + private bool IsToggleSwitchAppearance + { + get + { + return Appearance == Appearance.ToggleSwitch + && VisualStylesMode >= VisualStylesMode.Net11 + && !ThreeState; + } + } + + private Rendering.CheckBox.AnimatedToggleSwitchRenderer ToggleSwitchRenderer => + _toggleSwitchRenderer ??= new(this, Rendering.CheckBox.ModernCheckBoxStyle.Rounded); + + private void UpdateToggleSwitchStyles() + { + if (IsToggleSwitchAppearance) + { + // Owner-paint with WinForms double buffering for a flicker-free, fluent animation. + SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + } + } [SRCategory(nameof(SR.CatPropertyChanged))] [SRDescription(nameof(SR.CheckBoxOnAppearanceChangedDescr))] @@ -199,6 +230,14 @@ public CheckState CheckState return; } + // For the animated toggle switch, stop any in-flight animation (leaving the thumb at its current + // position) before the state changes, then start a fresh animation toward the new position. + bool animateToggleSwitch = IsToggleSwitchAppearance && IsHandleCreated; + if (animateToggleSwitch) + { + ToggleSwitchRenderer.StopAnimation(); + } + bool oldChecked = Checked; _checkState = value; @@ -218,6 +257,11 @@ public CheckState CheckState _notifyAccessibilityStateChangedNeeded = !checkedChanged; OnCheckStateChanged(EventArgs.Empty); _notifyAccessibilityStateChangedNeeded = false; + + if (animateToggleSwitch) + { + ToggleSwitchRenderer.StartAnimation(); + } } } @@ -288,6 +332,17 @@ private void ScaleConstants() internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (IsToggleSwitchAppearance) + { + int dpiScale = (int)(DeviceDpi / 96f); + Size toggleTextSize = TextRenderer.MeasureText(Text, Font); + int switchWidth = 50 * dpiScale; + int switchHeight = 25 * dpiScale; + int totalWidth = toggleTextSize.Width + switchWidth + (20 * dpiScale); + int totalHeight = Math.Max(toggleTextSize.Height, switchHeight); + return new Size(totalWidth, totalHeight); + } + if (Appearance == Appearance.Button) { ButtonStandardAdapter adapter = new(this); @@ -497,6 +552,47 @@ protected override void OnHandleCreated(EventArgs e) { PInvokeCore.SendMessage(this, PInvoke.BM_SETCHECK, (WPARAM)(int)_checkState); } + + UpdateToggleSwitchStyles(); + } + + /// + protected override void OnPaint(PaintEventArgs pevent) + { + if (IsToggleSwitchAppearance) + { + using GraphicsStateScope scope = new(pevent.Graphics); + ToggleSwitchRenderer.RenderControl(pevent.Graphics); + return; + } + + base.OnPaint(pevent); + } + + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + + // Entering or leaving the modern toggle-switch appearance changes whether the control is owner-drawn. + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); + + if (IsHandleCreated) + { + Invalidate(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _toggleSwitchRenderer?.Dispose(); + _toggleSwitchRenderer = null; + } + + base.Dispose(disposing); } /// 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 0ec7ce5fdb5..bec38ade229 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 @@ -755,15 +755,20 @@ public event EventHandler? MultilineChanged remove => Events.RemoveHandler(s_multilineChangedEvent, value); } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] public new Padding Padding { get => base.Padding; set => base.Padding = value; } + private new bool ShouldSerializePadding() + => Padding != DefaultPadding; + + private void ResetPadding() + => Padding = DefaultPadding; + [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs new file mode 100644 index 00000000000..7a44245ebb0 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs @@ -0,0 +1,152 @@ +// 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.Rendering.Animation; + +/// +/// Represents an abstract base class for animated control renderers. +/// +/// The control associated with the renderer. +internal abstract class AnimatedControlRenderer(Control control) : IDisposable +{ + private bool _disposedValue; + protected float AnimationProgress = 1; + + /// + /// Callback for the animation progress. This method is called by the animation manager on each + /// frame tick delivered by HighPrecisionTimer. + /// + /// A fraction between 0 and 1 representing the animation progress. + public virtual void AnimationProc(float animationProgress) + { + AnimationProgress = animationProgress; + } + + /// + /// Called when the control needs to be painted. + /// + /// The to paint the control. + public abstract void RenderControl(Graphics graphics); + + /// + /// Invalidates the control, causing it to be redrawn, which in turns triggers + /// . + /// + public void Invalidate() => control.Invalidate(); + + /// + /// Starts the animation and gets the animation parameters. + /// + public void StartAnimation() + { + if (IsRunning) + { + return; + } + + // Get the animation parameters. + (int animationDuration, AnimationCycle animationCycle) = OnAnimationStarted(); + + // Register the renderer with the animation manager. + AnimationManager.RegisterOrUpdateAnimationRenderer( + this, + animationDuration, + animationCycle); + + IsRunning = true; + } + + internal void StopAnimationInternal() => IsRunning = false; + + public void RestartAnimation() + { + if (IsRunning) + { + StopAnimation(); + } + + StartAnimation(); + } + + /// + /// Called in a derived class when the animation starts. The derived class returns the animation duration and cycle type. + /// + /// + /// Tuple containing the animation duration and cycle type. + /// + protected abstract (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted(); + + /// + /// Called by the animation manager when the animation ends. + /// + internal void EndAnimation() + { + OnAnimationEnded(); + } + + /// + /// Called in a derived class when the animation ends. + /// The derived class can perform any cleanup or state change operations. + /// + protected abstract void OnAnimationEnded(); + + /// + /// Can be called by an implementing control, when the animation needs to be stopped or restarted. + /// + public void StopAnimation() + { + AnimationManager.Suspend(this); + OnAnimationStopped(); + } + + /// + /// Called in the derived class when the animation is stopped. + /// The derived class can perform any cleanup or state change operations. + /// + protected abstract void OnAnimationStopped(); + + /// + /// Gets the DPI scale of the control. + /// + protected int DpiScale => (int)(control.DeviceDpi / 96f); + + /// + /// Gets a value indicating whether the animation is running. + /// + public bool IsRunning { get; private set; } + + /// + /// Gets the control associated with the renderer. + /// + protected Control Control => control; + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// to release both managed and unmanaged resources; to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + // Remove the renderer from the animation manager. + AnimationManager.UnregisterAnimationRenderer(this); + } + + _disposedValue = true; + } + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method. + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs new file mode 100644 index 00000000000..0dfac5f12f6 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs @@ -0,0 +1,11 @@ +// 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.Rendering.Animation; + +internal enum AnimationCycle +{ + Once, + Loop, + Bounce +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs new file mode 100644 index 00000000000..dae4505a21b --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Animation; + +internal partial class AnimationManager +{ + private class AnimationRendererItem + { + public long StopwatchTarget; + + public AnimationRendererItem(AnimatedControlRenderer renderer, int animationDuration, AnimationCycle animationCycle) + { + Renderer = renderer; + AnimationDuration = animationDuration; + AnimationCycle = animationCycle; + } + + public AnimatedControlRenderer Renderer { get; } + public int AnimationDuration { get; set; } + public int FrameCount { get; set; } + public AnimationCycle AnimationCycle { get; set; } + public int FrameOffset { get; set; } = 1; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs new file mode 100644 index 00000000000..9cfc01ce919 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Windows.Forms.Animation; + +namespace System.Windows.Forms.Rendering.Animation; + +/// +/// Process-wide dispatcher that drives all instances from a single +/// HighPrecisionTimer registration. +/// +/// +/// +/// The frame cadence is provided by HighPrecisionTimer (60 Hz where supported, otherwise 30 Hz). +/// Because that timer marshals its callback back to the captured when this +/// manager was constructed, the per-frame work runs on the UI thread and can invalidate controls directly. +/// +/// +internal partial class AnimationManager +{ + private readonly Stopwatch _stopwatch; + private readonly HighPrecisionTimer.TimerRegistration _timerRegistration; + + private readonly ConcurrentDictionary _renderer = []; + + private static AnimationManager? s_instance; + + private static AnimationManager Instance + => s_instance ??= new AnimationManager(); + + private AnimationManager() + { + _stopwatch = Stopwatch.StartNew(); + + // HighPrecisionTimer captures the current SynchronizationContext and posts each tick back to it, + // so OnFrameTickAsync runs on the UI thread. A SynchronizationContext is expected here because the + // first animation is always started from the UI thread. + _timerRegistration = HighPrecisionTimer.Register(OnFrameTickAsync); + + Application.ApplicationExit += (sender, e) => DisposeRenderer(); + } + + /// + /// Disposes the animation renderers and releases the timer registration. + /// + private void DisposeRenderer() + { + // Stop the timer. + _timerRegistration.Dispose(); + + foreach (AnimatedControlRenderer renderer in _renderer.Keys) + { + renderer.Dispose(); + } + } + + /// + /// Registers an animation renderer. + /// + /// The animation renderer to register. + /// The duration of the animation. + /// The animation cycle. + public static void RegisterOrUpdateAnimationRenderer( + AnimatedControlRenderer animationRenderer, + int animationDuration, + AnimationCycle animationCycle) + { + // If the renderer is already registered, update the animation parameters. + if (Instance._renderer.TryGetValue(animationRenderer, out AnimationRendererItem? renderItem)) + { + renderItem.StopwatchTarget = Instance._stopwatch.ElapsedMilliseconds + animationDuration; + renderItem.AnimationDuration = animationDuration; + renderItem.AnimationCycle = animationCycle; + + return; + } + + renderItem = new AnimationRendererItem(animationRenderer, animationDuration, animationCycle) + { + StopwatchTarget = Instance._stopwatch.ElapsedMilliseconds + animationDuration, + }; + + _ = Instance._renderer.TryAdd(animationRenderer, renderItem); + } + + /// + /// Unregisters an animation renderer. + /// + /// The animation renderer to unregister. + internal static void UnregisterAnimationRenderer(AnimatedControlRenderer animationRenderer) + { + _ = Instance._renderer.TryRemove(animationRenderer, out _); + } + + internal static void Suspend(AnimatedControlRenderer animatedControlRenderer) + { + if (Instance._renderer.TryGetValue(animatedControlRenderer, out AnimationRendererItem? renderItem)) + { + renderItem.Renderer.StopAnimationInternal(); + } + } + + /// + /// Handles a single frame tick delivered by HighPrecisionTimer (on the UI thread). + /// + private ValueTask OnFrameTickAsync(HighPrecisionTimerTick tick, CancellationToken cancellationToken) + { + long elapsedStopwatchMilliseconds = _stopwatch.ElapsedMilliseconds; + + foreach (AnimationRendererItem item in _renderer.Values) + { + if (!item.Renderer.IsRunning) + { + continue; + } + + long remainingAnimationMilliseconds = item.StopwatchTarget - elapsedStopwatchMilliseconds; + + item.FrameCount += item.FrameOffset; + + if (elapsedStopwatchMilliseconds >= item.StopwatchTarget) + { + switch (item.AnimationCycle) + { + case AnimationCycle.Once: + item.Renderer.EndAnimation(); + break; + + case AnimationCycle.Loop: + item.FrameCount = 0; + item.StopwatchTarget = elapsedStopwatchMilliseconds + item.AnimationDuration; + item.Renderer.RestartAnimation(); + break; + + case AnimationCycle.Bounce: + item.FrameOffset = -item.FrameOffset; + item.StopwatchTarget = elapsedStopwatchMilliseconds + item.AnimationDuration; + item.Renderer.RestartAnimation(); + break; + } + + continue; + } + + float progress = 1 - (remainingAnimationMilliseconds / (float)item.AnimationDuration); + + // We are already on the UI thread (HighPrecisionTimer marshalled us here), so invoke directly. + item.Renderer.AnimationProc(progress); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs new file mode 100644 index 00000000000..7e0c92054e0 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs @@ -0,0 +1,140 @@ +// 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; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Animation; + +namespace System.Windows.Forms.Rendering.CheckBox; + +/// +/// Renders and animates a in mode. +/// Used when is or later. +/// +internal sealed class AnimatedToggleSwitchRenderer : AnimatedControlRenderer +{ + private const int AnimationDuration = 300; // milliseconds + private const int SwitchWidthLogical = 50; + private const int SwitchHeightLogical = 25; + private const int CircleDiameterLogical = 20; + private const int TextGapLogical = 10; + + private readonly ModernCheckBoxStyle _switchStyle; + + public AnimatedToggleSwitchRenderer(Control control, ModernCheckBoxStyle switchStyle) + : base(control) + { + _switchStyle = switchStyle; + } + + private Forms.CheckBox CheckBox => (Forms.CheckBox)Control; + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + Invalidate(); + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + AnimationProgress = 1; + + return (AnimationDuration, AnimationCycle.Once); + } + + /// + /// Called from the control's OnPaint. Works both while the animation is running (driven by + /// ) and when it is settled (progress is 1). + /// + /// The graphics object to render into. + public override void RenderControl(Graphics graphics) + { + int dpiScale = DpiScale; + + int switchWidth = SwitchWidthLogical * dpiScale; + int switchHeight = SwitchHeightLogical * dpiScale; + int circleDiameter = CircleDiameterLogical * dpiScale; + int textGap = TextGapLogical * dpiScale; + + Size textSize = TextRenderer.MeasureText(Control.Text, Control.Font); + + int totalHeight = Math.Max(textSize.Height, switchHeight); + int switchY = (totalHeight - switchHeight) / 2; + int textY = (totalHeight - textSize.Height) / 2; + + graphics.Clear(Control.BackColor); + + switch (CheckBox.TextAlign) + { + case ContentAlignment.MiddleLeft: + case ContentAlignment.TopLeft: + case ContentAlignment.BottomLeft: + RenderSwitch(graphics, new Rectangle(textSize.Width + textGap, switchY, switchWidth, switchHeight), circleDiameter); + RenderText(graphics, new Point(0, textY)); + break; + + default: + RenderSwitch(graphics, new Rectangle(0, switchY, switchWidth, switchHeight), circleDiameter); + RenderText(graphics, new Point(switchWidth + textGap, textY)); + break; + } + } + + private void RenderText(Graphics graphics, Point position) => + TextRenderer.DrawText(graphics, CheckBox.Text, CheckBox.Font, position, CheckBox.ForeColor); + + private void RenderSwitch(Graphics graphics, Rectangle rect, int circleDiameter) + { + // The background color flips at 80% of the animation so the thumb travels visibly before the color change. + Color backgroundColor = CheckBox.Checked ^ (AnimationProgress < 0.8f) + ? SystemColors.Highlight + : SystemColors.ControlDark; + + Color circleColor = SystemColors.ControlText; + + // Works both for the running and settled states (settled progress is 1, so the thumb rests in place). + float circlePosition = CheckBox.Checked + ? (rect.Width - circleDiameter) * (1 - EaseOut(AnimationProgress)) + : (rect.Width - circleDiameter) * EaseOut(AnimationProgress); + + using var backgroundBrush = backgroundColor.GetCachedSolidBrushScope(); + using var circleBrush = circleColor.GetCachedSolidBrushScope(); + using var backgroundPen = SystemColors.WindowFrame.GetCachedPenScope(2 * DpiScale); + + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + if (_switchStyle == ModernCheckBoxStyle.Rounded) + { + float radius = rect.Height / 2f; + + using GraphicsPath path = new(); + path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); + path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); + path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseFigure(); + + graphics.FillPath(backgroundBrush, path); + graphics.DrawPath(backgroundPen, path); + } + else + { + graphics.FillRectangle(backgroundBrush, rect); + graphics.DrawRectangle(backgroundPen, rect); + } + + graphics.FillEllipse(circleBrush, rect.X + circlePosition, rect.Y + (2.5f * DpiScale), circleDiameter, circleDiameter); + + static float EaseOut(float t) => (1 - t) * (1 - t); + } + + protected override void OnAnimationStopped() + { + AnimationProgress = 0; + } + + protected override void OnAnimationEnded() + { + AnimationProgress = 1; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs new file mode 100644 index 00000000000..32f35518ed3 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs @@ -0,0 +1,10 @@ +// 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.Rendering.CheckBox; + +internal enum ModernCheckBoxStyle +{ + Rectangular, + Rounded +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs b/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs new file mode 100644 index 00000000000..2658846d23e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs @@ -0,0 +1,41 @@ +// 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; + +/// +/// Represents the version of the visual renderer that a control or the application uses. +/// +/// +/// +/// The visual styles version controls how a control renders its adorners, borders, and layout. +/// Newer versions can adjust minimum sizes, padding, and margins to satisfy current accessibility +/// requirements without changing the behavior of applications that target an earlier version. +/// +/// +public enum VisualStylesMode : short +{ + /// + /// The classic version of the visual renderer (.NET 8 and earlier), based on version 6 of the + /// common controls library. + /// + Classic = 0, + + /// + /// Visual renderers are not in use - see . + /// Controls are based on version 5 of the common controls library. + /// + Disabled = 1, + + /// + /// The .NET 11 version of the visual renderer. Controls are rendered using the latest version + /// of the common controls library, and the adorner rendering or the layout of specific controls + /// has been improved based on the latest accessibility requirements. + /// + Net11 = 2, + + /// + /// The latest version of the visual renderer available in the running framework. + /// + Latest = short.MaxValue +} diff --git a/src/test/integration/WinformsControlsTest/Buttons.cs b/src/test/integration/WinformsControlsTest/Buttons.cs index b6b9836c74f..a369b841659 100644 --- a/src/test/integration/WinformsControlsTest/Buttons.cs +++ b/src/test/integration/WinformsControlsTest/Buttons.cs @@ -119,6 +119,14 @@ protected override void OnLoad(EventArgs e) column: 1, row: 1); + Button visualStylesButton = new() + { + AutoSize = true, + Text = "VisualStyles Buttons\u2026" + }; + visualStylesButton.Click += (s, _) => new VisualStylesButtons().Show(this); + table.Controls.Add(visualStylesButton, column: 2, row: 1); + base.OnLoad(e); } } diff --git a/src/test/integration/WinformsControlsTest/VisualStylesButtons.cs b/src/test/integration/WinformsControlsTest/VisualStylesButtons.cs new file mode 100644 index 00000000000..41fc06292f6 --- /dev/null +++ b/src/test/integration/WinformsControlsTest/VisualStylesButtons.cs @@ -0,0 +1,193 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Drawing; + +namespace WinFormsControlsTest; + +/// +/// Exploratory-testing harness for the conservative and modern (.NET 11 VisualStyles) button renderers. +/// Toggle the "Modern visual styles" check box to flip every sample button between +/// and at runtime. +/// +/// +/// +/// The application-wide color mode (Classic/Dark) is a start-up, set-once setting, so to evaluate the dark +/// palette the host application must be started in dark mode. The modern vs. conservative look, however, is +/// driven by the per-control ambient property and can be toggled live here. +/// +/// +[DesignerCategory("Default")] +public sealed class VisualStylesButtons : Form +{ + private static readonly FlatStyle[] s_styles = + [ + FlatStyle.Standard, + FlatStyle.Flat, + FlatStyle.Popup, + FlatStyle.System + ]; + + private readonly List public void RenderButton( Graphics graphics, + Control control, Rectangle bounds, FlatStyle flatStyle, PushButtonState state, @@ -120,9 +122,6 @@ public void RenderButton( // Scope the graphics state so all changes are reverted after rendering using (new GraphicsStateScope(graphics)) { - // Clear the background over the whole button area. - ClearBackground(graphics, parentBackgroundColor); - // Use padding from the renderer. When the focus ring is not drawn, renderers may return a smaller // padding so the button body expands into the space the ring and its gap would otherwise occupy. Padding padding = GetContentPadding(focused && showFocusCues); @@ -133,22 +132,41 @@ public void RenderButton( width: bounds.Width - padding.Horizontal, height: bounds.Height - padding.Vertical); - // Draw button background and get content bounds - Rectangle contentBounds = DrawButtonBackground(graphics, paddedBounds, state, isDefault, focused, backColor); - - // Paint image and field using the provided delegates - paintImage(contentBounds); - - paintField(); - - if (focused && showFocusCues) + GraphicsPath? backgroundPath = CreateBackgroundPath(paddedBounds, isDefault, focused); + try { - // Draw focus indicator for other styles - DrawFocusIndicator(graphics, bounds, isDefault); + if (backgroundPath is not null) + { + ParentBackgroundRenderer.Paint(control, graphics, bounds, backgroundPath, parentBackgroundColor); + } + else + { + // Rectangular renderers still need a complete background before painting their body. + ClearBackground(graphics, parentBackgroundColor); + } + + // Draw button background and get content bounds + Rectangle contentBounds = DrawButtonBackground(graphics, paddedBounds, state, isDefault, focused, backColor); + + // Paint image and field using the provided delegates + paintImage(contentBounds); + paintField(); + + if (focused && showFocusCues) + { + // Draw focus indicator for other styles + DrawFocusIndicator(graphics, bounds, isDefault); + } + } + finally + { + backgroundPath?.Dispose(); } } } + private protected virtual GraphicsPath? CreateBackgroundPath(Rectangle bounds, bool isDefault, bool focused) => null; + public abstract Rectangle DrawButtonBackground( Graphics graphics, Rectangle bounds, diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs index e5b71b025ab..6bd41d27544 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs @@ -30,6 +30,7 @@ static void DrawButtonBorder(Graphics graphics, GraphicsPath path, Color borderC /// Renders the button with the specified style, state, and content. /// /// The graphics context to draw on. + /// The button control whose parent surface is used for exposed regions. /// The bounds of the button. /// The flat style of the button. /// The visual state of the button (normal, hot, pressed, disabled, default). @@ -41,6 +42,7 @@ static void DrawButtonBorder(Graphics graphics, GraphicsPath path, Color borderC /// An action to paint the text or field within the specified rectangle, color, and enabled state. void RenderButton( Graphics graphics, + Control control, Rectangle bounds, FlatStyle flatStyle, PushButtonState state, diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs index c112c0e1a12..683e808b054 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs @@ -25,6 +25,7 @@ internal sealed class ModernButtonDarkModeRenderer : ButtonDarkModeRendererBase private const int FocusGapThicknessLogical = 1; private const int FocusedCornerRadiusLogical = 6; private const int UnfocusedCornerRadiusLogical = 8; + private const int BorderThicknessLogical = 1; private const int ContentInsetLogical = 4; // Dark scheme - default (accept) button area. @@ -88,6 +89,16 @@ private protected override Padding GetContentPadding(bool focusRingVisible) private protected override bool UseModernStateDefaults => true; + private protected override GraphicsPath? CreateBackgroundPath(Rectangle bounds, bool isDefault, bool focused) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return null; + } + + return CreateRoundedPath(GetPathBounds(bounds), GetCornerRadius(focused, isDefault)); + } + public override Rectangle DrawButtonBackground( Graphics graphics, Rectangle bounds, @@ -101,19 +112,31 @@ public override Rectangle DrawButtonBackground( { graphics.SmoothingMode = SmoothingMode.AntiAlias; - int radius = GetCornerRadius(focused, isDefault); - using (var brush = backColor.GetCachedSolidBrushScope()) - { - graphics.FillRoundedRectangle(brush, bounds, new Size(radius, radius)); - } + RectangleF pathBounds = GetPathBounds(bounds); + float radius = GetCornerRadius(focused, isDefault); + int borderThickness = ScaleBorderThickness( + Math.Max(1, Scale(BorderThicknessLogical))); - // A subtle border for the light, non-default button, matching the WinUI neutral button. - if (!IsDark && !isDefault && state != PushButtonState.Disabled) + if (!IsDark + && !isDefault + && state != PushButtonState.Disabled + && borderThickness > 0) { - using var borderPen = s_lightBorder.GetCachedPenScope(); - Rectangle borderRect = new(bounds.X, bounds.Y, bounds.Width - 1, bounds.Height - 1); - graphics.DrawRoundedRectangle(borderPen, borderRect, new Size(radius, radius)); + Color borderColor = ResolveBorderColor(s_lightBorder); + using var borderBrush = borderColor.GetCachedSolidBrushScope(); + using GraphicsPath borderPath = CreateRingPath( + pathBounds, + radius, + borderThickness); + graphics.FillPath(borderBrush, borderPath); + + pathBounds = Inset(pathBounds, borderThickness); + radius = Math.Max(1, radius - (2 * borderThickness)); } + + using var brush = backColor.GetCachedSolidBrushScope(); + using GraphicsPath bodyPath = CreateRoundedPath(pathBounds, radius); + graphics.FillPath(brush, bodyPath); } finally { @@ -139,30 +162,28 @@ public override void DrawFocusIndicator(Graphics graphics, Rectangle bounds, boo return; } - int radius = GetCornerRadius(focused: true, isDefault: isDefault) - + FocusGapThickness - + FocusRingThickness; + RectangleF outerBounds = GetPathBounds(bounds); + int bodyInset = FocusRingThickness + FocusGapThickness; + float outerRadius = GetCornerRadius(focused: true, isDefault: isDefault) + + (2 * bodyInset); - // Dark gap ring between the focus ring and the button area. Color gapColor = IsDark ? s_darkGap : SystemColors.Window; - int gapInset = FocusRingThickness + (FocusGapThickness / 2); - Rectangle gapRect = Rectangle.Inflate(bounds, -gapInset, -gapInset); - gapRect.Width -= 1; - gapRect.Height -= 1; - using (var gapPen = gapColor.GetCachedPenScope(FocusGapThickness)) + using (var gapBrush = gapColor.GetCachedSolidBrushScope()) { - graphics.DrawRoundedRectangle(gapPen, gapRect, new Size(radius, radius)); + using GraphicsPath gapPath = CreateRingPath( + Inset(outerBounds, FocusRingThickness), + outerRadius - (2 * FocusRingThickness), + FocusGapThickness); + graphics.FillPath(gapBrush, gapPath); } - // Outer rounded focus ring. Color ringColor = ResolveBorderColor(IsDark ? s_darkFocusRing : SystemColors.WindowText); - int ringInset = FocusRingThickness / 2; - Rectangle ringRect = Rectangle.Inflate(bounds, -ringInset, -ringInset); - ringRect.Width -= 1; - ringRect.Height -= 1; - - using var ringPen = ringColor.GetCachedPenScope(FocusRingThickness); - graphics.DrawRoundedRectangle(ringPen, ringRect, new Size(radius + ringInset, radius + ringInset)); + using var ringBrush = ringColor.GetCachedSolidBrushScope(); + using GraphicsPath ringPath = CreateRingPath( + outerBounds, + outerRadius, + FocusRingThickness); + graphics.FillPath(ringBrush, ringPath); } finally { @@ -226,4 +247,40 @@ public override Color GetBackgroundColor(PushButtonState state, bool isDefault) _ => s_lightNormal }; } + + private static RectangleF GetPathBounds(Rectangle bounds) + => new( + bounds.X, + bounds.Y, + Math.Max(1, bounds.Width - 1), + Math.Max(1, bounds.Height - 1)); + + private static RectangleF Inset(RectangleF bounds, float inset) + => new( + bounds.X + inset, + bounds.Y + inset, + Math.Max(1, bounds.Width - (2 * inset)), + Math.Max(1, bounds.Height - (2 * inset))); + + private static GraphicsPath CreateRoundedPath(RectangleF bounds, float radius) + { + GraphicsPath path = new(); + float clampedRadius = Math.Clamp(radius, 1, Math.Min(bounds.Width, bounds.Height)); + path.AddRoundedRectangle(bounds, new SizeF(clampedRadius, clampedRadius)); + return path; + } + + private static GraphicsPath CreateRingPath( + RectangleF outerBounds, + float outerRadius, + float thickness) + { + GraphicsPath path = CreateRoundedPath(outerBounds, outerRadius); + RectangleF innerBounds = Inset(outerBounds, thickness); + float innerRadius = Math.Max(1, outerRadius - (2 * thickness)); + using GraphicsPath innerPath = CreateRoundedPath(innerBounds, innerRadius); + path.FillMode = FillMode.Alternate; + path.AddPath(innerPath, connect: false); + return path; + } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs index 7a18789fa10..b478671e27d 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs @@ -27,6 +27,16 @@ internal class SystemButtonDarkModeRenderer : ButtonDarkModeRendererBase private protected override Padding PaddingCore { get; } = new Padding(SystemStylePadding); + private protected override GraphicsPath? CreateBackgroundPath(Rectangle bounds, bool isDefault, bool focused) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return null; + } + + return CreateRoundedPath(GetPathBounds(bounds), CornerRadius - DarkBorderGapThickness); + } + /// /// Draws button background with system styling (larger rounded corners). /// @@ -38,17 +48,26 @@ public override Rectangle DrawButtonBackground( bool focused, Color backColor) { - // Shrink for DarkBorderGap and FocusBorderThickness - Rectangle fillBounds = Rectangle.Inflate(bounds, -SystemStylePadding, -SystemStylePadding); - - using GraphicsPath fillPath = CreateRoundedRectanglePath(fillBounds, CornerRadius - DarkBorderGapThickness); + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; - // Fill the background using cached brush - using var brush = backColor.GetCachedSolidBrushScope(); - graphics.FillPath(brush, fillPath); + RectangleF pathBounds = GetPathBounds(bounds); + using GraphicsPath fillPath = CreateRoundedPath(pathBounds, FocusIndicatorCornerRadius); + using var brush = backColor.GetCachedSolidBrushScope(); + graphics.FillPath(brush, fillPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } // Return content bounds (area inside the button for text/image) - return fillBounds; + return bounds; } /// @@ -56,20 +75,28 @@ public override Rectangle DrawButtonBackground( /// public override void DrawFocusIndicator(Graphics graphics, Rectangle contentBounds, bool isDefault) { - // We need the bottom and the right border one pixel inside the button - Rectangle focusRect = new( - x: contentBounds.X, - y: contentBounds.Y, - width: contentBounds.Width - 1, - height: contentBounds.Height - 1); - - // Create path for the focus outline - using GraphicsPath focusPath = CreateRoundedRectanglePath(focusRect, FocusIndicatorCornerRadius); - - // System style uses a solid white border instead of dotted lines - using var focusPen = Color.White.GetCachedPenScope(FocusedButtonBorderThickness); + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; - graphics.DrawPath(focusPen, focusPath); + RectangleF outerBounds = GetPathBounds(contentBounds); + float outerRadius = FocusIndicatorCornerRadius + (2 * SystemStylePadding); + Color focusColor = ResolveBorderColor(Color.White); + using var focusBrush = focusColor.GetCachedSolidBrushScope(); + using GraphicsPath focusPath = CreateRingPath( + outerBounds, + outerRadius, + FocusedButtonBorderThickness); + graphics.FillPath(focusBrush, focusPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } } /// @@ -139,7 +166,9 @@ public void DrawButtonBorder( // Outer border path Rectangle borderRect = Rectangle.Inflate(bounds, -SystemStylePadding, -SystemStylePadding); - using GraphicsPath borderPath = CreateRoundedRectanglePath(borderRect, CornerRadius); + using GraphicsPath borderPath = CreateRoundedPath( + GetPathBounds(borderRect), + CornerRadius); // We need to implement a subtle 3d effect around the already // painted filling. We do this by drawing a border with a 1px pen, @@ -274,15 +303,39 @@ private static GraphicsPath GetBottomRightSegmentPath(Rectangle bounds, int radi return path; } - /// - /// Creates a GraphicsPath for a rounded rectangle. - /// - private static GraphicsPath CreateRoundedRectanglePath(Rectangle bounds, int radius) + private static RectangleF GetPathBounds(Rectangle bounds) + => new( + bounds.X, + bounds.Y, + Math.Max(1, bounds.Width - 1), + Math.Max(1, bounds.Height - 1)); + + private static RectangleF Inset(RectangleF bounds, float inset) + => new( + bounds.X + inset, + bounds.Y + inset, + Math.Max(1, bounds.Width - (2 * inset)), + Math.Max(1, bounds.Height - (2 * inset))); + + private static GraphicsPath CreateRoundedPath(RectangleF bounds, float radius) { GraphicsPath path = new(); + float clampedRadius = Math.Clamp(radius, 1, Math.Min(bounds.Width, bounds.Height)); + path.AddRoundedRectangle(bounds, new SizeF(clampedRadius, clampedRadius)); + return path; + } - path.AddRoundedRectangle(bounds, new Size(radius, radius)); - + private static GraphicsPath CreateRingPath( + RectangleF outerBounds, + float outerRadius, + float thickness) + { + GraphicsPath path = CreateRoundedPath(outerBounds, outerRadius); + RectangleF innerBounds = Inset(outerBounds, thickness); + float innerRadius = Math.Max(1, outerRadius - (2 * thickness)); + using GraphicsPath innerPath = CreateRoundedPath(innerBounds, innerRadius); + path.FillMode = FillMode.Alternate; + path.AddPath(innerPath, connect: false); return path; } } 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 690df74093e..58304272e9b 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 @@ -348,6 +348,12 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) return Rendering.CheckBox.ToggleSwitchMetrics.Create(this).GetPreferredSize(this); } + if (Appearance == Appearance.Button && FlatStyle == FlatStyle.Popup) + { + return DarkModeAdapterFactory.CreatePopupAdapter(this).GetPreferredSizeCore(proposedConstraints) + + Padding.Size; + } + if (Appearance == Appearance.Button) { ButtonStandardAdapter adapter = new(this); 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 48946e1dd7d..81bec289917 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 @@ -288,6 +288,12 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) return Rendering.CheckBox.ToggleSwitchMetrics.Create(this).GetPreferredSize(this); } + if (Appearance == Appearance.Button && FlatStyle == FlatStyle.Popup) + { + return DarkModeAdapterFactory.CreatePopupAdapter(this).GetPreferredSizeCore(proposedConstraints) + + Padding.Size; + } + if (FlatStyle != FlatStyle.System) { return base.GetPreferredSizeCore(proposedConstraints); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs index 4b4fa46bee1..7155719eb48 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs @@ -328,10 +328,25 @@ protected override CreateParams CreateParams /// /// RichEdit reserves the scrollbar space itself while processing WM_NCCALCSIZE (see - /// ), so no additional managed scrollbar allowance is - /// added on top; otherwise the space would be counted twice. + /// ). Return the configured reservation for preferred-size + /// measurement; the native client-area calculation explicitly excludes it. /// - private protected override Padding GetScrollBarPadding() => Padding.Empty; + private protected override Padding GetScrollBarPadding() + { + Padding padding = Padding.Empty; + + if (Multiline && !WordWrap && (ScrollBars & RichTextBoxScrollBars.Horizontal) != 0) + { + padding.Bottom = SystemInformation.GetHorizontalScrollBarHeightForDpi(DeviceDpiInternal); + } + + if (Multiline && (ScrollBars & RichTextBoxScrollBars.Vertical) != 0) + { + padding.Right = SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpiInternal); + } + + return padding; + } /// /// Controls whether or not the rich edit control will automatically highlight URLs. @@ -437,6 +452,12 @@ public override Font Font internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // TextBoxBase owns the modern inset, user Padding, and scrollbar geometry. + return base.GetPreferredSizeCore(proposedConstraints); + } + Size scrollBarPadding = Size.Empty; // If the RTB is multiline, we won't have a horizontal scrollbar. @@ -657,6 +678,7 @@ public RichTextBoxScrollBars ScrollBars using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.ScrollBars)) { _richTextBoxFlags[s_scrollBarsSection] = (int)value; + CommonProperties.xClearPreferredSizeCache(this); RecreateHandle(); } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs index d0670ce3e1f..08c4fd56f9a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs @@ -2884,7 +2884,9 @@ private void WmPrint(ref Message m) { base.WndProc(ref m); if (((nint)m.LParamInternal & PInvoke.PRF_NONCLIENT) != 0 - && Application.RenderWithVisualStyles && BorderStyle == BorderStyle.Fixed3D) + && Application.RenderWithVisualStyles + && BorderStyle == BorderStyle.Fixed3D + && EffectiveVisualStylesMode < VisualStylesMode.Net11) { using Graphics g = Graphics.FromHdc((HDC)m.WParamInternal); Rectangle rect = new(0, 0, Size.Width - 1, Size.Height - 1); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs index 50299a05186..5f29b798482 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Drawing; using System.Drawing.Design; +using System.Windows.Forms.Layout; using System.Windows.Forms.VisualStyles; using Windows.Win32.UI.Accessibility; @@ -383,6 +384,8 @@ public ScrollBars ScrollBars _scrollBars = value; RecreateHandle(); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.ScrollBars); } } } @@ -391,6 +394,13 @@ public ScrollBars ScrollBars internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // TextBoxBase already includes the native scrollbar reservation in the modern geometry. + // Applying the legacy adjustment here would count each scrollbar twice. + return base.GetPreferredSizeCore(proposedConstraints); + } + Size scrollBarPadding = Size.Empty; if (Multiline && !WordWrap && (ScrollBars & ScrollBars.Horizontal) != 0) @@ -411,6 +421,31 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) return prefSize + scrollBarPadding; } + private protected override Padding GetScrollBarPadding() + { + if (IsHandleCreated) + { + return base.GetScrollBarPadding(); + } + + Padding padding = Padding.Empty; + + if (Multiline + && !WordWrap + && _textAlign == HorizontalAlignment.Left + && (_scrollBars & ScrollBars.Horizontal) != 0) + { + padding.Bottom = SystemInformation.GetHorizontalScrollBarHeightForDpi(DeviceDpiInternal); + } + + if (Multiline && (_scrollBars & ScrollBars.Vertical) != 0) + { + padding.Right = SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpiInternal); + } + + return padding; + } + /// /// Gets or sets the current text in the text box. /// 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 6055fea74c2..6028fa0a6a7 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 @@ -57,6 +57,8 @@ public abstract partial class TextBoxBase : Control private const int VisualStylesFixed3DBorderPadding = 5; private const int VisualStylesFixedSingleBorderPadding = 4; private const int VisualStylesNoBorderPadding = 3; + internal const int VisualStylesInternalChromeInset = 2; + private const int VisualStylesCornerRadius = 15; private const int BorderThickness = 1; /// @@ -360,8 +362,21 @@ public BorderStyle BorderStyle SourceGenerated.EnumValidator.Validate(value); _borderStyle = value; + CommonProperties.xClearPreferredSizeCache(this); + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + AdjustHeight(false); + } + UpdateStyles(); - RecreateHandle(); + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11 && IsHandleCreated) + { + RecalculateVisualStylesClientArea(); + } + else + { + RecreateHandle(); + } // PreferredSize depends on BorderStyle : thru CreateParams.ExStyle in User32!AdjustRectEx. // So when the BorderStyle changes let the parent of this control know about it. @@ -853,8 +868,25 @@ private void ResetPadding() /// Returns the preferred height for modern Visual Styles, taking the carved padding band /// (including the live scrollbar allowance and the user ) into account. /// - private protected virtual int PreferredHeightCore => - FontHeight + GetVisualStylesPadding(includeScrollbars: true).Vertical; + private protected virtual int PreferredHeightCore + { + get + { + int preferredHeight = FontHeight + GetVisualStylesPadding(includeScrollbars: true).Vertical; + + if (AutoSize && !Multiline && BorderStyle == BorderStyle.Fixed3D) + { + // A naturally sized single-line control must leave enough room for the complete + // rounded chrome. Explicitly fixed controls are not forced through this floor. + int roundedChromeMinimumHeight = (ScaleVisualStylesMetric(VisualStylesCornerRadius) * 2) + + ScaleVisualStylesMetric(BorderThickness) + + ScaleVisualStylesMetric(VisualStylesInternalChromeInset); + preferredHeight = Math.Max(preferredHeight, roundedChromeMinimumHeight); + } + + return preferredHeight; + } + } /// /// Returns the classic (Everett-compatible) preferred height for a single-line text box. @@ -891,12 +923,13 @@ private int PreferredHeightClassic /// /// /// The visible part of the padding is the area by which we extend the real-estate of the control - /// with its back color. The user-provided is always added on top. + /// with its back color. Border/corner reservation, the DPI-scaled internal chrome inset, the + /// user-provided , and scrollbar reservation are each added once. /// /// private protected Padding GetVisualStylesPadding(bool includeScrollbars) { - int offset = LogicalToDeviceUnits(BorderThickness); + int offset = ScaleVisualStylesMetric(BorderThickness); // The visible padding is selected per BorderStyle (not per VisualStylesMode): each border look // reserves a differently sized band around the native edit's client area for the modern chrome @@ -905,30 +938,32 @@ private protected Padding GetVisualStylesPadding(bool includeScrollbars) // single-line border; None reserves only a minimal band, plus extra room on the right and bottom // for the scrollbars and the focus line. BorderThickness (offset) is added so the drawn border // line sits inside the reserved band rather than on its outer edge. - Padding padding = BorderStyle switch + Padding borderPadding = BorderStyle switch { BorderStyle.Fixed3D => new Padding( - left: LogicalToDeviceUnits(VisualStylesFixed3DBorderPadding) + offset, - top: LogicalToDeviceUnits(VisualStylesFixed3DBorderPadding) + offset, - right: LogicalToDeviceUnits(VisualStylesFixed3DBorderPadding) + offset, - bottom: LogicalToDeviceUnits(VisualStylesFixed3DBorderPadding) + offset), + left: ScaleVisualStylesMetric(VisualStylesFixed3DBorderPadding) + offset, + top: ScaleVisualStylesMetric(VisualStylesFixed3DBorderPadding) + offset, + right: ScaleVisualStylesMetric(VisualStylesFixed3DBorderPadding) + offset, + bottom: ScaleVisualStylesMetric(VisualStylesFixed3DBorderPadding) + offset), BorderStyle.FixedSingle => new Padding( - left: LogicalToDeviceUnits(VisualStylesFixedSingleBorderPadding) + offset, - top: LogicalToDeviceUnits(VisualStylesFixedSingleBorderPadding) + offset, - right: LogicalToDeviceUnits(VisualStylesFixedSingleBorderPadding) + offset, - bottom: LogicalToDeviceUnits(VisualStylesFixedSingleBorderPadding) + offset), + left: ScaleVisualStylesMetric(VisualStylesFixedSingleBorderPadding) + offset, + top: ScaleVisualStylesMetric(VisualStylesFixedSingleBorderPadding) + offset, + right: ScaleVisualStylesMetric(VisualStylesFixedSingleBorderPadding) + offset, + bottom: ScaleVisualStylesMetric(VisualStylesFixedSingleBorderPadding) + offset), BorderStyle.None => new Padding( - left: LogicalToDeviceUnits(VisualStylesNoBorderPadding), - top: LogicalToDeviceUnits(VisualStylesNoBorderPadding), - right: LogicalToDeviceUnits(VisualStylesNoBorderPadding) + offset, + left: ScaleVisualStylesMetric(VisualStylesNoBorderPadding), + top: ScaleVisualStylesMetric(VisualStylesNoBorderPadding), + right: ScaleVisualStylesMetric(VisualStylesNoBorderPadding) + offset, // We still need some extra space for the focus indication. - bottom: LogicalToDeviceUnits(VisualStylesNoBorderPadding) + offset), + bottom: ScaleVisualStylesMetric(VisualStylesNoBorderPadding) + offset), _ => Padding.Empty, }; + Padding padding = borderPadding + new Padding(ScaleVisualStylesMetric(VisualStylesInternalChromeInset)); + if (includeScrollbars) { padding += GetScrollBarPadding(); @@ -939,6 +974,9 @@ private protected Padding GetVisualStylesPadding(bool includeScrollbars) return padding; } + private int ScaleVisualStylesMetric(int logicalValue) + => ScaleHelper.ScaleToDpi(logicalValue, DeviceDpiInternal); + /// /// Returns the additional padding required to clear the live scrollbars, /// if any are currently shown. @@ -1613,6 +1651,8 @@ protected override void OnHandleCreated(EventArgs e) ScrollToCaret(); _textBoxFlags[s_scrollToCaretOnHandleCreated] = false; } + + RecalculateVisualStylesClientArea(); } protected override void OnHandleDestroyed(EventArgs e) @@ -1624,6 +1664,27 @@ protected override void OnHandleDestroyed(EventArgs e) base.OnHandleDestroyed(e); } + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.VisualStylesMode); + AdjustHeight(false); + + if (!IsHandleCreated) + { + return; + } + + // Update the native styles without recreating the handle so text, selection, and scroll state + // remain untouched. The client-area latch must be reset before requesting a new frame. + _triggerNewClientSizeRequest = false; + UpdateStyles(); + RecalculateVisualStylesClientArea(); + } + /// /// Replaces the current selection in the text box with the contents of the Clipboard. /// @@ -1719,9 +1780,25 @@ protected override unsafe void OnSizeChanged(EventArgs e) protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Font); AdjustHeight(false); } + protected override void OnDpiChangedAfterParent(EventArgs e) + { + base.OnDpiChangedAfterParent(e); + + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Bounds); + AdjustHeight(false); + + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + RecalculateVisualStylesClientArea(); + } + } + protected virtual void OnHideSelectionChanged(EventArgs e) { if (Events[s_hideSelectionChangedEvent] is EventHandler eh) @@ -1778,6 +1855,8 @@ protected virtual void OnMultilineChanged(EventArgs e) protected override void OnPaddingChanged(EventArgs e) { base.OnPaddingChanged(e); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Padding); AdjustHeight(false); // The carved modern Visual Styles padding band includes the user Padding, so a runtime change @@ -2339,14 +2418,14 @@ private unsafe void WmNcCalcSize(ref Message m) // Controls whose native window reserves its own non-client metrics (RichEdit reserves its // border and scrollbars) must run the default handler first, so we carve the modern padding // band from the already-adjusted client rectangle rather than the raw proposed window - // rectangle. Those controls report an empty GetScrollBarPadding so the scrollbar space the - // native handler already reserved is not counted twice. + // rectangle. Their managed scrollbar allowance is omitted from this carve because the + // native handler already reserved it. if (ReservesNativeNonClientArea) { base.WndProc(ref m); } - Padding padding = GetVisualStylesPadding(includeScrollbars: true); + Padding padding = GetVisualStylesPadding(includeScrollbars: !ReservesNativeNonClientArea); ref RECT clientRect = ref ncCalcSizeParams->rgrc._0; @@ -2429,6 +2508,26 @@ private void WmNcPaint(ref Message m) } } + private void WmPrint(ref Message m) + { + base.WndProc(ref m); + + if (EffectiveVisualStylesMode < VisualStylesMode.Net11 + || ((nint)m.LParamInternal & PInvoke.PRF_NONCLIENT) == 0) + { + return; + } + + HDC hdc = (HDC)m.WParamInternal; + if (hdc.IsNull) + { + return; + } + + using Graphics graphics = Graphics.FromHdc(hdc); + OnNcPaint(graphics, hdc); + } + /// /// Builds a non-client update region, in screen coordinates, that spans the whole window but /// excludes the live client rectangle. This is handed to the default WM_NCPAINT handler so @@ -2473,8 +2572,8 @@ private RegionScope CreateNonClientClipRegion() /// private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) { - int cornerRadius = LogicalToDeviceUnits(15); - int borderThickness = LogicalToDeviceUnits(BorderThickness); + int cornerRadius = ScaleVisualStylesMetric(VisualStylesCornerRadius); + int borderThickness = ScaleVisualStylesMetric(BorderThickness); Color adornerColor = ForeColor; @@ -2498,17 +2597,10 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) width: Bounds.Width, height: Bounds.Height); - Padding clientPadding = GetVisualStylesPadding(includeScrollbars: false); - - // This is the client area without the padding. - Rectangle clientBounds = new( - clientPadding.Left, - clientPadding.Top, - Math.Max(0, bounds.Width - clientPadding.Horizontal), - Math.Max(0, bounds.Height - clientPadding.Vertical)); - clientBounds = Rectangle.Intersect(bounds, clientBounds); + // The native client rectangle is the only protected area. It includes the border, internal + // chrome inset, user Padding, and scrollbar reservation exactly as the native window reports it. + Rectangle clientBounds = Rectangle.Intersect(bounds, GetNativeClientRectangle()); - // This is the client area of the actual original edit control. Rectangle deflatedBounds = bounds; // Making sure we never color outside the lines. @@ -2535,14 +2627,22 @@ private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) bounds.Inflate(1, 1); - // Fill the buffer with the parent background color. - offscreenGraphics.FillRectangle(parentBackgroundBrush, bounds); - // Below roughly 2 * cornerRadius + thickness the rounded Fixed3D chrome renders as a broken // lozenge. When the available height is below that viable threshold we fall back to flat/simple // chrome. This is a render-only fallback - it does not change size, layout, or ClientSize. bool canRenderRoundedChrome = deflatedBounds.Height >= (2 * cornerRadius) + borderThickness; + if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) + { + using GraphicsPath roundedBodyPath = new(); + roundedBodyPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + ParentBackgroundRenderer.Paint(this, offscreenGraphics, bufferBounds, roundedBodyPath, parentBackColor); + } + else + { + offscreenGraphics.FillRectangle(parentBackgroundBrush, bounds); + } + switch (BorderStyle) { case BorderStyle.None: @@ -2680,6 +2780,25 @@ private static Rectangle[] GetNonClientPaintBands(Rectangle bounds, Rectangle cl ]; } + private Rectangle GetNativeClientRectangle() + { + if (!IsHandleCreated + || !PInvokeCore.GetWindowRect(this, out RECT windowRect)) + { + return Rectangle.Empty; + } + + PInvokeCore.GetClientRect(this, out RECT clientRect); + Point clientTopLeft = default; + PInvoke.ClientToScreen(this, ref clientTopLeft); + + return new Rectangle( + clientTopLeft.X - windowRect.left, + clientTopLeft.Y - windowRect.top, + clientRect.Width, + clientRect.Height); + } + private void WmReflectCommand(ref Message m) { if (_textBoxFlags[s_codeUpdateText] || _textBoxFlags[s_creatingHandle]) @@ -2767,6 +2886,9 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_NCPAINT: WmNcPaint(ref m); break; + case PInvokeCore.WM_PRINT: + WmPrint(ref m); + break; case PInvokeCore.WM_LBUTTONDBLCLK: _doubleClickFired = true; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs index 70f700fe3b7..7aac9b4c3d3 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs @@ -512,7 +512,7 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) int width = LayoutUtils.OldGetLargestStringSizeInCollection(Font, Items).Width; // AdjustWindowRect with our border, since textbox is borderless. - width = SizeFromClientSizeInternal(new(width, height)).Width + _upDownButtons.Width; + width = GetPreferredWidth(width, height); return new Size(width, height) + Padding.Size; } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs index fd1695e47f6..6c6e5516735 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs @@ -822,7 +822,7 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) } // Call AdjustWindowRect to add space for the borders - int width = SizeFromClientSizeInternal(new(textWidth, height)).Width + _upDownButtons.Width; + int width = GetPreferredWidth(textWidth, height); return new Size(width, height) + Padding.Size; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs index f85e0337970..726ae729253 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs @@ -72,9 +72,15 @@ internal Rectangle GetButtonRectangle(ButtonID button) if (UseSideBySideButtons) { - int half = client.Width / 2; - Rectangle leadingRect = new(client.X, client.Y, half, client.Height); - Rectangle trailingRect = new(client.X + half, client.Y, client.Width - half, client.Height); + int spacing = Math.Min(_parent.ModernButtonGroupSpacing, client.Width); + int availableWidth = Math.Max(0, client.Width - spacing); + int leadingWidth = (availableWidth + 1) / 2; + Rectangle leadingRect = new(client.X, client.Y, leadingWidth, client.Height); + Rectangle trailingRect = new( + client.X + leadingWidth + spacing, + client.Y, + availableWidth - leadingWidth, + client.Height); bool rightToLeft = _parent.RightToLeft == RightToLeft.Yes; Rectangle upRect = rightToLeft ? leadingRect : trailingRect; @@ -96,9 +102,18 @@ internal Rectangle GetButtonRectangle(ButtonID button) /// The mouse event arguments. private void BeginButtonPress(MouseEventArgs e) { - _pushed = _captured = GetButtonRectangle(ButtonID.Up).Contains(e.Location) + ButtonID button = GetButtonRectangle(ButtonID.Up).Contains(e.Location) ? ButtonID.Up - : ButtonID.Down; + : GetButtonRectangle(ButtonID.Down).Contains(e.Location) + ? ButtonID.Down + : ButtonID.None; + + if (button == ButtonID.None) + { + return; + } + + _pushed = _captured = button; Invalidate(); // Capture the mouse @@ -212,14 +227,15 @@ protected override void OnMouseMove(MouseEventArgs e) Rectangle rectDown = GetButtonRectangle(ButtonID.Down); // Check if the mouse is on the upper or lower button. Note that it could be in neither. - if (rectUp.Contains(e.X, e.Y)) - { - _mouseOver = ButtonID.Up; - Invalidate(); - } - else if (rectDown.Contains(e.X, e.Y)) + ButtonID mouseOver = rectUp.Contains(e.X, e.Y) + ? ButtonID.Up + : rectDown.Contains(e.X, e.Y) + ? ButtonID.Down + : ButtonID.None; + + if (_mouseOver != mouseOver) { - _mouseOver = ButtonID.Down; + _mouseOver = mouseOver; Invalidate(); } @@ -296,27 +312,41 @@ protected override void OnPaint(PaintEventArgs e) // the modern control-button renderer, which adapts to both light and dark modes. bool isDarkMode = Application.IsDarkModeEnabled; - Graphics cachedGraphics = EnsureCachedBitmap(ClientSize.Width, ClientSize.Height); + using Graphics cachedGraphics = EnsureCachedBitmap(ClientSize.Width, ClientSize.Height); DrawModernControlButton( cachedGraphics, GetButtonRectangle(ButtonID.Down), - ModernControlButtonStyle.Down | ModernControlButtonStyle.SingleBorder, + ModernControlButtonStyle.Down, GetButtonState(ButtonID.Down), isDarkMode); DrawModernControlButton( cachedGraphics, GetButtonRectangle(ButtonID.Up), - ModernControlButtonStyle.Up | ModernControlButtonStyle.SingleBorder, + ModernControlButtonStyle.Up, GetButtonState(ButtonID.Up), isDarkMode); e.GraphicsInternal.DrawImageUnscaled(_cachedBitmap, new Point(0, 0)); + + int spacing = _parent.ModernButtonGroupSpacing; + if (spacing > 0) + { + Rectangle upBounds = GetButtonRectangle(ButtonID.Up); + Rectangle downBounds = GetButtonRectangle(ButtonID.Down); + Rectangle gap = new( + Math.Min(upBounds.Right, downBounds.Right), + 0, + spacing, + ClientSize.Height); + using var gapBrush = _parent.BackColor.GetCachedSolidBrushScope(); + e.Graphics.FillRectangle(gapBrush, gap); + } } else if (Application.IsDarkModeEnabled) { - Graphics cachedGraphics = EnsureCachedBitmap( + using Graphics cachedGraphics = EnsureCachedBitmap( _parent._defaultButtonsWidth, ClientSize.Height); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs index bfdb45c17ef..8262d0f374a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs @@ -1,6 +1,7 @@ // 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; using Windows.Win32.UI.Accessibility; namespace System.Windows.Forms; @@ -19,6 +20,18 @@ internal UpDownEdit(UpDownBase parent) _parent = parent; } + public override VisualStylesMode VisualStylesMode + { + get => VisualStylesMode.Classic; + set + { + } + } + + private protected override void OnNcPaint(Graphics graphics, HDC windowHdc) + { + } + [AllowNull] public override string Text { @@ -114,6 +127,7 @@ protected override void OnGotFocus(EventArgs e) { _parent.SetActiveControl(this); _parent.InvokeGotFocus(_parent, e); + _parent.Invalidate(); if (IsAccessibilityObjectCreated) { @@ -122,6 +136,9 @@ protected override void OnGotFocus(EventArgs e) } protected override void OnLostFocus(EventArgs e) - => _parent.InvokeLostFocus(_parent, e); + { + _parent.InvokeLostFocus(_parent, e); + _parent.Invalidate(); + } } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs index 6da711535d2..e1c23e6f79a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs @@ -5,6 +5,7 @@ using System.Drawing; using System.Drawing.Drawing2D; using System.Runtime.InteropServices; +using System.Windows.Forms.Layout; using System.Windows.Forms.VisualStyles; using Microsoft.Win32; @@ -21,14 +22,10 @@ public abstract partial class UpDownBase : ContainerControl private const int DefaultControlWidth = 120; private const int ThemedBorderWidth = 1; // width of custom border we draw when themed - // Modern (Net11+) chrome geometry, mirrored from TextBoxBase so the frame we draw around the - // whole control matches the look of a stand-alone modern TextBox. The padding values determine how - // far the edit and the buttons are inset from the outer edge so they clear the drawn border and the - // rounded corners; the border thickness and corner radius drive the frame itself. - private const int ModernFixed3DBorderPadding = 5; - private const int ModernFixedSingleBorderPadding = 4; - private const int ModernNoBorderPadding = 3; + // Modern (Net11+) chrome geometry. The edit and button group share the border thickness and + // internal chrome inset used by TextBoxBase; only the gap between the two buttons is additional. private const int ModernBorderThickness = 1; + private const int ModernButtonGroupSpacingLogical = 2; private const int ModernCornerRadius = 15; private const BorderStyle DefaultBorderStyle = BorderStyle.Fixed3D; private const LeftRightAlignment DefaultUpDownAlign = LeftRightAlignment.Right; @@ -353,6 +350,22 @@ public int PreferredHeight { get { + if (UseSideBySideButtons) + { + int contentInset = ModernContentInset; + int preferredHeight = FontHeight + (contentInset * 2); + + if (_borderStyle == BorderStyle.Fixed3D) + { + int roundedChromeMinimumHeight = (LogicalToDeviceUnits(ModernCornerRadius) * 2) + + LogicalToDeviceUnits(ModernBorderThickness) + + LogicalToDeviceUnits(TextBoxBase.VisualStylesInternalChromeInset); + preferredHeight = Math.Max(preferredHeight, roundedChromeMinimumHeight); + } + + return preferredHeight; + } + int height = FontHeight; // Adjust for the border style @@ -469,7 +482,13 @@ public LeftRightAlignment UpDownAlign internal override Rectangle ApplyBoundsConstraints(int suggestedX, int suggestedY, int proposedWidth, int proposedHeight) { - return base.ApplyBoundsConstraints(suggestedX, suggestedY, proposedWidth, PreferredHeight); + int height = AutoSize + ? PreferredHeight + Padding.Vertical + : UseSideBySideButtons + ? proposedHeight + : PreferredHeight; + + return base.ApplyBoundsConstraints(suggestedX, suggestedY, proposedWidth, height); } internal override void ReleaseUiaProvider(HWND handle) @@ -497,6 +516,14 @@ protected override void RescaleConstantsForDpi(int deviceDpiOld, int deviceDpiNe base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew); _defaultButtonsWidth = LogicalToDeviceUnits(DefaultButtonsWidth); _upDownButtons.Width = _defaultButtonsWidth; + CommonProperties.xClearPreferredSizeCache(this); + + if (AutoSize) + { + Height = PreferredHeight; + } + + PositionControls(); } /// @@ -525,6 +552,25 @@ protected override void OnHandleDestroyed(EventArgs e) base.OnHandleDestroyed(e); } + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + CommonProperties.xClearPreferredSizeCache(this); + + if (AutoSize) + { + Height = PreferredHeight; + } + + PositionControls(); + + if (IsHandleCreated) + { + Invalidate(true); + } + } + /// /// Handles painting the buttons on the control. /// @@ -663,7 +709,11 @@ protected virtual void OnTextBoxLostFocus(object? source, EventArgs e) /// protected virtual void OnTextBoxResize(object? source, EventArgs e) { - Height = PreferredHeight; + if (!UseSideBySideButtons || AutoSize) + { + Height = PreferredHeight; + } + PositionControls(); } @@ -828,7 +878,11 @@ protected override void OnFontChanged(EventArgs e) // Clear the font height cache FontHeight = -1; - Height = PreferredHeight; + if (!UseSideBySideButtons || AutoSize) + { + Height = PreferredHeight; + } + PositionControls(); base.OnFontChanged(e); @@ -865,8 +919,9 @@ private void PositionControls() Rectangle upDownEditBounds = Rectangle.Empty; Rectangle upDownButtonsBounds = Rectangle.Empty; - Rectangle clientArea = new(Point.Empty, ClientSize); - int totalClientWidth = clientArea.Width; + Rectangle clientArea = LayoutUtils.DeflateRect( + new Rectangle(Point.Empty, ClientSize), + Padding); bool themed = Application.RenderWithVisualStyles; BorderStyle borderStyle = BorderStyle; @@ -905,8 +960,8 @@ private void PositionControls() if (updownAlign == LeftRightAlignment.Left) { // If the buttons are aligned to the left, swap position of text box/buttons - upDownButtonsBounds.X = totalClientWidth - upDownButtonsBounds.Right; - upDownEditBounds.X = totalClientWidth - upDownEditBounds.Right; + upDownButtonsBounds.X = clientArea.Left + (clientArea.Right - upDownButtonsBounds.Right); + upDownEditBounds.X = clientArea.Left + (clientArea.Right - upDownEditBounds.Right); } // Apply locations @@ -919,35 +974,47 @@ private void PositionControls() } } - /// - /// The inset, in device units, of the edit and the buttons from the outer edge when a modern - /// is in effect. It matches the band a stand-alone modern TextBox - /// reserves for its border so the frame we draw in looks consistent and the - /// child controls clear the rounded corners. - /// - private int ModernBorderPadding => LogicalToDeviceUnits(_borderStyle switch - { - BorderStyle.Fixed3D => ModernFixed3DBorderPadding + ModernBorderThickness, - BorderStyle.FixedSingle => ModernFixedSingleBorderPadding + ModernBorderThickness, - _ => ModernNoBorderPadding, - }); + private int ModernContentInset + => LogicalToDeviceUnits( + (_borderStyle == BorderStyle.None ? 0 : ModernBorderThickness) + + TextBoxBase.VisualStylesInternalChromeInset); + + internal int ModernButtonGroupSpacing + => LogicalToDeviceUnits(ModernButtonGroupSpacingLogical); + + internal int GetModernButtonGroupWidth() + => (_defaultButtonsWidth * 2) + ModernButtonGroupSpacing; + + internal int GetPreferredWidth(int textWidth, int height) + => UseSideBySideButtons + ? textWidth + (ModernContentInset * 2) + GetModernButtonGroupWidth() + : SizeFromClientSizeInternal(new(textWidth, height)).Width + _upDownButtons.Width; /// /// Calculates the size and position of the upDownEdit control and the side-by-side updown buttons /// when a modern is in effect. Both the edit and the buttons are - /// inset by so they sit inside the frame drawn by + /// inset by so they sit inside the frame drawn by /// and clear its rounded corners. /// private void PositionControlsModern() { - Rectangle clientArea = new(Point.Empty, ClientSize); - int totalClientWidth = clientArea.Width; + Rectangle clientArea = LayoutUtils.DeflateRect( + new Rectangle(Point.Empty, ClientSize), + Padding); - int pad = ModernBorderPadding; - int buttonsWidth = _defaultButtonsWidth * 2; + int pad = ModernContentInset; + int buttonsWidth = Math.Min(GetModernButtonGroupWidth(), Math.Max(0, clientArea.Width - (pad * 2))); Rectangle inner = clientArea; inner.Inflate(-pad, -pad); + if (inner.Width < 0 || inner.Height < 0) + { + inner = new Rectangle( + x: Math.Min(pad, clientArea.Width), + y: Math.Min(pad, clientArea.Height), + width: 0, + height: 0); + } Rectangle upDownEditBounds = inner; upDownEditBounds.Width = Math.Max(0, inner.Width - buttonsWidth); @@ -961,8 +1028,8 @@ private void PositionControlsModern() // Left/right updown align translation (also honors RTL). if (RtlTranslateLeftRight(UpDownAlign) == LeftRightAlignment.Left) { - upDownButtonsBounds.X = totalClientWidth - upDownButtonsBounds.Right; - upDownEditBounds.X = totalClientWidth - upDownEditBounds.Right; + upDownButtonsBounds.X = clientArea.Left + (clientArea.Right - upDownButtonsBounds.Right); + upDownEditBounds.X = clientArea.Left + (clientArea.Right - upDownEditBounds.Right); } _upDownEdit?.Bounds = upDownEditBounds; @@ -995,7 +1062,6 @@ private void DrawModernBorder(PaintEventArgs e) Color parentBackColor = Parent?.BackColor ?? BackColor; Color clientBackColor = BackColor; - using var parentBackgroundBrush = parentBackColor.GetCachedSolidBrushScope(); using var clientBackgroundBrush = clientBackColor.GetCachedSolidBrushScope(); using var adornerPen = adornerColor.GetCachedPenScope(borderThickness); @@ -1004,15 +1070,25 @@ private void DrawModernBorder(PaintEventArgs e) deflatedBounds.Height -= 1; Graphics graphics = e.Graphics; + using GraphicsStateScope graphicsState = new(graphics); graphics.SmoothingMode = SmoothingMode.AntiAlias; - // Fill the whole client with the parent's back color; the rounded corners then blend against it. - graphics.FillRectangle(parentBackgroundBrush, bounds); - // Below roughly 2 * cornerRadius + thickness the rounded chrome renders as a broken lozenge; fall - // back to a flat rectangle in that case. This mirrors TextBoxBase.OnNcPaint. + // back to a flat rectangle in that case. bool canRenderRoundedChrome = deflatedBounds.Height >= (2 * cornerRadius) + borderThickness; + using GraphicsPath bodyPath = new(); + if (canRenderRoundedChrome) + { + bodyPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + } + else + { + bodyPath.AddRectangle(deflatedBounds); + } + + ParentBackgroundRenderer.Paint(this, graphics, bounds, bodyPath, parentBackColor); + switch (_borderStyle) { case BorderStyle.None: @@ -1039,6 +1115,48 @@ private void DrawModernBorder(PaintEventArgs e) break; } + + if (Focused && _borderStyle == BorderStyle.Fixed3D) + { + using var focusPen = SystemColors.MenuHighlight.GetCachedPenScope(borderThickness); + if (canRenderRoundedChrome) + { + int focusInset = Math.Max(1, (cornerRadius - 3) / 2); + graphics.DrawLine( + focusPen, + deflatedBounds.Left + focusInset, + deflatedBounds.Bottom, + deflatedBounds.Right - focusInset, + deflatedBounds.Bottom); + graphics.DrawLine( + focusPen, + deflatedBounds.Left + focusInset - 2, + deflatedBounds.Bottom - 1, + deflatedBounds.Right - focusInset + 2, + deflatedBounds.Bottom - 1); + graphics.DrawLine( + focusPen, + deflatedBounds.Left + focusInset - 3, + deflatedBounds.Bottom - 2, + deflatedBounds.Right - focusInset + 3, + deflatedBounds.Bottom - 2); + } + else + { + graphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom, + deflatedBounds.Right, + deflatedBounds.Bottom); + graphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom - 1, + deflatedBounds.Right, + deflatedBounds.Bottom - 1); + } + } } /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs new file mode 100644 index 00000000000..234ff9e7644 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs @@ -0,0 +1,42 @@ +// 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; +using System.Drawing.Drawing2D; + +namespace System.Windows.Forms; + +internal static class ParentBackgroundRenderer +{ + internal static void Paint( + Control control, + Graphics graphics, + Rectangle bounds, + GraphicsPath opaquePath, + Color fallbackColor) + { + ArgumentNullException.ThrowIfNull(control); + ArgumentNullException.ThrowIfNull(graphics); + ArgumentNullException.ThrowIfNull(opaquePath); + + using GraphicsStateScope state = new(graphics); + using Region exposedRegion = new(bounds); + exposedRegion.Exclude(opaquePath); + + // Keep an existing clip (for example, the native TextBox client area) in effect while + // PaintTransparentBackground establishes the parent-coordinate clip. + using Region currentClip = graphics.Clip; + exposedRegion.Intersect(currentClip); + + Control? parent = control.ParentInternal; + if (parent is null || parent.IsDisposed) + { + using SolidBrush fallbackBrush = new(fallbackColor); + graphics.FillRegion(fallbackBrush, exposedRegion); + return; + } + + using PaintEventArgs paintEventArgs = new(graphics, bounds); + control.PaintTransparentBackground(paintEventArgs, bounds, exposedRegion); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs index b930efbefe2..573e0f4aa04 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Drawing; +using System.Drawing.Drawing2D; using System.Windows.Forms.Rendering.Animation; using PushButtonState = System.Windows.Forms.VisualStyles.PushButtonState; @@ -154,11 +155,6 @@ public override void RenderControl(Graphics graphics) : flatAppearance.BorderColor; } - using (PaintEventArgs paintEventArgs = new(graphics, button.ClientRectangle)) - { - button.PaintBackground(paintEventArgs, button.ClientRectangle); - } - PopupButtonRenderContext context = new() { Bounds = button.ClientRectangle, @@ -185,6 +181,22 @@ public override void RenderControl(Graphics graphics) HighContrast = highContrast }; + using GraphicsPath? bodyPath = PopupButtonKeyCapRenderer.CreateBodyPath(context); + if (bodyPath is null) + { + using PaintEventArgs paintEventArgs = new(graphics, button.ClientRectangle); + button.PaintBackground(paintEventArgs, button.ClientRectangle); + } + else + { + ParentBackgroundRenderer.Paint( + button, + graphics, + button.ClientRectangle, + bodyPath, + button.Parent?.BackColor ?? button.BackColor); + } + Action? paintImage = null; Image? image = button.Image; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs index b902eb079fc..f849d43a6b4 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs @@ -30,6 +30,20 @@ namespace System.Windows.Forms.Rendering.Button; /// internal static class PopupButtonKeyCapRenderer { + internal static GraphicsPath? CreateBodyPath(PopupButtonRenderContext context) + { + ArgumentNullException.ThrowIfNull(context); + + Rectangle bounds = context.Bounds; + if (context.HighContrast || bounds.Width < 8 || bounds.Height < 8) + { + return null; + } + + Metrics metrics = Metrics.Create(context); + return CreateRoundedPath(metrics.KeyRect, metrics.CornerRadius); + } + /// /// Renders the key into the given . /// @@ -73,7 +87,7 @@ public static void Render(Graphics graphics, PopupButtonRenderContext context, A (Rectangle textBounds, Rectangle imageBounds) = CreateContentLayout( context, metrics.BowlRect, - applySurfaceInset: true); + applySurfaceInset: false); GraphicsState state = graphics.Save(); @@ -279,11 +293,6 @@ private static void DrawText( return; } - // The key top already moves with the press; the caption sinks a touch further, which sells the - // "finger pushes the key down" moment. - int extraSink = (int)MathF.Round(context.AnimationState.PressProgress * 0.75f * metrics.Scale); - textRect.Offset(0, extraSink); - TextFormatFlags flags = GetTextFormatFlags(context); int reliefOffset = metrics.TextReliefOffset; @@ -407,6 +416,9 @@ private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout contentBounds = ApplyPadding(contentBounds, context.Padding); bool hasText = !string.IsNullOrEmpty(context.Text); bool hasImage = !context.ImageSize.IsEmpty; + ContentAlignment imageAlign = context.RightToLeft == RightToLeft.Yes + ? MirrorAlignment(context.ImageAlign) + : context.ImageAlign; if (!hasImage) { @@ -421,7 +433,7 @@ private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout { return ( hasText ? contentBounds : Rectangle.Empty, - AlignInRectangle(contentBounds, imageSize, context.ImageAlign)); + AlignInRectangle(contentBounds, imageSize, imageAlign)); } TextImageRelation relation = context.RightToLeft == RightToLeft.Yes @@ -435,7 +447,7 @@ private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout TextImageRelation.TextBeforeImage => CreateHorizontalLayout(imageFirst: false), TextImageRelation.ImageAboveText => CreateVerticalLayout(imageFirst: true), TextImageRelation.TextAboveImage => CreateVerticalLayout(imageFirst: false), - _ => (contentBounds, AlignInRectangle(contentBounds, imageSize, context.ImageAlign)) + _ => (contentBounds, AlignInRectangle(contentBounds, imageSize, imageAlign)) }; (Rectangle TextBounds, Rectangle ImageBounds) CreateHorizontalLayout(bool imageFirst) @@ -450,7 +462,7 @@ private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout return ( textBounds, - AlignInRectangle(imageSlot, imageSize with { Width = imageWidth }, context.ImageAlign)); + AlignInRectangle(imageSlot, imageSize with { Width = imageWidth }, imageAlign)); } (Rectangle TextBounds, Rectangle ImageBounds) CreateVerticalLayout(bool imageFirst) @@ -465,7 +477,7 @@ private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout return ( textBounds, - AlignInRectangle(imageSlot, imageSize with { Height = imageHeight }, context.ImageAlign)); + AlignInRectangle(imageSlot, imageSize with { Height = imageHeight }, imageAlign)); } } @@ -608,6 +620,9 @@ private static ContentAlignment MirrorAlignment(ContentAlignment alignment) /// private readonly struct Metrics { + private const float SideClearanceDip = 1f; + private const float PressTravelDip = 1.5f; + public float Scale { get; init; } public int Ambient { get; init; } public int BorderWidth { get; init; } @@ -632,19 +647,27 @@ public static Metrics Create(PopupButtonRenderContext context) float hover = context.AnimationState.HoverProgress; float press = context.AnimationState.PressProgress; - int ambient = Math.Max(1, (int)MathF.Round(2.5f * scale)); + int sideClearance = Math.Max(1, (int)MathF.Round(SideClearanceDip * scale)); + int pressTravel = Math.Max(1, (int)MathF.Round(PressTravelDip * scale)); + int ambient = sideClearance + pressTravel; int maxBorder = Math.Max(0, (Math.Min(bounds.Width, bounds.Height) / 4) - 1); int defaultBorderIncrease = context.IsDefault && context.Enabled ? Math.Max(1, (int)MathF.Round(scale)) : 0; int borderWidth = Math.Clamp(context.BorderWidth + defaultBorderIncrease, 0, maxBorder); - Rectangle keyRect = Rectangle.Inflate(bounds, -ambient, -ambient); - - // Pressing sinks the key top; the bottom edge stays put, so the cap compresses. - int pressOffset = (int)MathF.Round(press * 1.5f * scale); - keyRect.Y += pressOffset; - keyRect.Height = Math.Max(4, keyRect.Height - pressOffset); + Rectangle keyRect = new( + bounds.X + sideClearance, + bounds.Y + sideClearance, + Math.Max(1, bounds.Width - (2 * sideClearance)), + Math.Max(1, bounds.Height - sideClearance - ambient)); + + // Pressing translates the complete key top into the space released by its shortening shadow. + // Keeping the key height constant avoids moving the bowl, border, and content independently. + int pressOffset = Math.Min( + (int)MathF.Round(press * PressTravelDip * scale), + Math.Max(0, bounds.Bottom - sideClearance - keyRect.Bottom)); + keyRect.Offset(0, pressOffset); int rim = Math.Max(2, (int)MathF.Round(3f * scale)); int bowlInset = borderWidth + rim; diff --git a/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs b/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs index f835831c3f0..1d0fac899fa 100644 --- a/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs +++ b/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs @@ -27,4 +27,25 @@ await RunSingleControlTestAsync(async (form, control) => Assert.NotNull(focused); }); } + + [WinFormsFact] + public async Task NumericUpDown_ModernChrome_UsesInsetEditAndSideBySideButtonsAsync() + { + await RunSingleControlTestAsync(async (form, control) => + { + control.VisualStylesMode = VisualStylesMode.Net11; + control.AutoSize = true; + form.PerformLayout(); + + if (!control.UseSideBySideButtons) + { + return; + } + + Assert.True(control.Height >= control.LogicalToDeviceUnits(15) * 2); + Assert.Equal(control.LogicalToDeviceUnits(3), control.TextBox.Left); + Assert.Equal(control.LogicalToDeviceUnits(3), control.UpDownButtonsInternal.Top); + Assert.True(control.UpDownButtonsInternal.Bounds.Left >= control.TextBox.Bounds.Right); + }); + } } diff --git a/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs new file mode 100644 index 00000000000..df29d20c6e5 --- /dev/null +++ b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs @@ -0,0 +1,226 @@ +// 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; +using System.Drawing.Drawing2D; + +namespace System.Windows.Forms.UITests; + +public class VisualStylesCrossFeatureTests : ControlTestBase +{ + public VisualStylesCrossFeatureTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + [WinFormsFact] + public async Task VisualStyles_InheritedControls_RenderAgainstPatternedParentAcrossModesAndDpiAsync() + { + List samples = []; + + await RunFormWithoutControlAsync( + () => + { + Form form = new() + { + AutoScaleMode = AutoScaleMode.Dpi, + RightToLeft = RightToLeft.Yes, + RightToLeftLayout = true, + Size = new Size(900, 600) + }; + + PatternedGradientPanel parent = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(8), + RightToLeft = RightToLeft.Yes, + VisualStylesMode = VisualStylesMode.Classic + }; + form.Controls.Add(parent); + + TableLayoutPanel table = new() + { + AutoScroll = true, + BackColor = Color.Transparent, + ColumnCount = 3, + Dock = DockStyle.Fill, + RowCount = 11 + }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 32)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + parent.Controls.Add(table); + + table.Controls.Add(new Label { Text = "Control / font", AutoSize = true }, 0, 0); + table.Controls.Add(new Label { Text = "AutoSize", AutoSize = true }, 1, 0); + table.Controls.Add(new Label { Text = "Fixed small", AutoSize = true }, 2, 0); + + string[] controlKinds = ["TextBox", "MaskedTextBox", "RichTextBox", "NumericUpDown", "DomainUpDown"]; + int row = 1; + foreach (float fontSize in new[] { 9f, 11f }) + { + foreach (string controlKind in controlKinds) + { + table.Controls.Add(new Label { Text = $"{controlKind} ({fontSize:0} pt)", AutoSize = true }, 0, row); + Control autoSized = CreateSampleControl(controlKind, fontSize, autoSize: true); + Control fixedSmall = CreateSampleControl(controlKind, fontSize, autoSize: false); + samples.Add(autoSized); + samples.Add(fixedSmall); + table.Controls.Add(autoSized, 1, row); + table.Controls.Add(fixedSmall, 2, row); + row++; + } + } + + return form; + }, + async form => + { + Panel parent = (Panel)form.Controls[0]; + Assert.Equal(form.DeviceDpi, parent.DeviceDpi); + Assert.All(samples, sample => Assert.Equal(RightToLeft.Yes, sample.RightToLeft)); + + foreach (Control sample in samples) + { + Assert.Equal(VisualStylesMode.Classic, sample.VisualStylesMode); + Assert.True(sample.Width > 0); + Assert.True(sample.Height > 0); + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Net11; + form.PerformLayout(); + Assert.All(samples, sample => Assert.Equal(VisualStylesMode.Net11, sample.VisualStylesMode)); + + foreach (Control sample in samples) + { + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Classic; + await Task.Yield(); + Assert.All(samples, sample => Assert.Equal(VisualStylesMode.Classic, sample.VisualStylesMode)); + }); + } + + [WinFormsFact] + public async Task VisualStyles_StandardSystemAndPopup_RenderFocusedDefaultAndPressedStatesAsync() + { + await RunFormWithoutControlAsync( + () => + { + Form form = new() { Size = new Size(500, 220) }; + FlowLayoutPanel panel = new() + { + Dock = DockStyle.Fill, + RightToLeft = RightToLeft.Yes, + FlowDirection = FlowDirection.LeftToRight + }; + form.Controls.Add(panel); + form.RightToLeft = RightToLeft.Yes; + form.RightToLeftLayout = true; + form.AutoScaleMode = AutoScaleMode.Dpi; + + Button standard = CreateButton(FlatStyle.Standard, "Standard"); + standard.NotifyDefault(true); + form.AcceptButton = standard; + panel.Controls.Add(standard); + foreach (FlatStyle style in new[] { FlatStyle.System, FlatStyle.Popup }) + { + Button button = CreateButton(style, style.ToString()); + button.NotifyDefault(true); + panel.Controls.Add(button); + } + + return form; + }, + async form => + { + FlowLayoutPanel panel = (FlowLayoutPanel)form.Controls[0]; + foreach (Button button in panel.Controls.OfType