From cb8e6bbba29936182c2046e0e5f386653d204549 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 18 Jul 2026 01:26:39 -0700 Subject: [PATCH 01/99] Centralize feature prompt documents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27022e2a-9eb8-4f58-9bfa-e79ca0d6c559 --- ...B05-HighPrecisionTimer-Rework-WorkOrder.md | 157 +++++++++++++++ ...B01-SystemVisualSettings-Implementation.md | 158 +++++++++++++++ ...sualStylesMode-ImpactApi-Implementation.md | 137 +++++++++++++ .../Application.SystemTextAwareness.md | 180 ++++++++++++++++++ .../TextBoxBase-VisualStyles-WorkOrder.md | 137 +++++++++++++ .../Create-Github-API-Proposal-Prompt.md | 120 ++++++++++++ ...elocationAndPainting-API-Feature-Prompt.md | 76 ++++++++ .../invokeAsync_generate_test_instructions.md | 0 ...uttonRendererCodeGenerationInstructions.md | 0 9 files changed, 965 insertions(+) create mode 100644 .github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md create mode 100644 .github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md create mode 100644 .github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md create mode 100644 .github/Feature-Prompts/Net11/Application.SystemTextAwareness/Application.SystemTextAwareness.md create mode 100644 .github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md create mode 100644 .github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md create mode 100644 .github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md rename .github/{copilot => Feature-Prompts/Net9}/Async/invokeAsync_generate_test_instructions.md (100%) rename .github/{copilot => Feature-Prompts/Net9}/GDI/DarkModeButtonRendererCodeGenerationInstructions.md (100%) diff --git a/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md b/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md new file mode 100644 index 00000000000..470d3584b5b --- /dev/null +++ b/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md @@ -0,0 +1,157 @@ +# Work Order: HighPrecisionTimer Rework (Animation Timing) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Files:** +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs` +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs` +- `src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs` +- Consumer: `src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs` + +This work order is self-contained; it results from a code review of the current implementation. +Read the current sources first, verify each finding against the code as it stands (the branch may have +moved), then implement. + +**Keep as-is (explicitly not up for redesign):** the registration model — per-registration +`SynchronizationContext` capture, `InFlight` CAS-based frame coalescing with a `DroppedFrames` counter +surfaced in `HighPrecisionTimerTick`, and the id-based `TimerRegistration` disposable struct +(double-dispose safe, `default` safe). The *clock/pacing core* is what gets replaced. + +--- + +## Finding 1 (critical): fixed-cadence pacer + spin causes a sawtooth burning ~50% of a core + +Current design: `PeriodicTimer` at 14 ms (60 Hz path), then `SpinToTarget` spins to the 16.667 ms +frame target with `SpinOnce(sleep1Threshold: -1)` (never sleeps). + +`PeriodicTimer` fires on its own **fixed** cadence (14, 28, 42, 56, …) and does not re-phase per +`WaitForNextTickAsync` call, while frame targets are 16.67, 33.33, 50, 66.67, … The phase slips +2.67 ms per frame, so spin duration grows every frame — wake 28 → spin 5.3 ms; wake 42 → spin 8 ms; +wake 56 → spin 10.7 ms — until phases wrap and the sawtooth restarts. Average spin ≈ half a frame +≈ 8 ms of every 16.67 ms ⇒ ~50% of one core, continuously, for as long as **any** animation is +registered. Infinite cycles (e.g. a pulsing focus indicator) make this permanent. The 30 Hz path +(30 ms tick vs 33.33 ms frame) has the identical slip. + +**Required fix (architecture, not constant-tuning):** absolute schedule. Due times are +`epoch + frameIndex * period` against a single clock; wait on a mechanism that can hit them +(Finding 3), with at most a sub-millisecond residual spin. Overshoot must be amortized against the +absolute schedule (no `lastTick + period` relative scheduling — that accumulates and runs slow). + +## Finding 2: integer-millisecond arithmetic throughout + +`stopwatch.ElapsedMilliseconds` (truncated `long`) feeds `lastTickTimestamp`, `elapsed`, the spin +target, drift detection, and the `Timestamp`/`Elapsed` values delivered to consumers — ±1 ms +quantization (6% of a 16.667 ms budget) plus systematic truncation bias. Use `ElapsedTicks` / +`Elapsed.TotalMilliseconds` (double) end to end; `HighPrecisionTimerTick` fields stay `TimeSpan`, +constructed from ticks. + +## Finding 3: replace `timeBeginPeriod(1)` with a high-resolution waitable timer + +The code gates on `windows10.0.17134` — which `timeBeginPeriod` (ancient) does not need, but which is +exactly the build (1803) that introduced `CreateWaitableTimerExW` with +`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`. Use it: + +- absolute due times (negative-relative or absolute FILETIME) with sub-ms accuracy, +- no process-wide timer-resolution raise (current code holds 1 ms resolution for the entire lifetime + of any animation — a documented power/battery anti-pattern, and post-Win11 the effective resolution + changes for occluded windows, silently shifting the timing floor), +- composes directly with Finding 1's absolute schedule and eliminates the spin loop almost entirely. + +Fallback below 17134: keep a coarse path (30 Hz, plain waits, no `timeBeginPeriod`) — document that +sub-frame precision is not attempted there. Remove `TimeBeginPeriod`/`TimeEndPeriod` P/Invokes if no +longer referenced. + +## Finding 4: `Register`/`Unregister` vs `StopTimer` race strands registrations + +`Unregister` does `TryRemove` → `IsEmpty?` → `StopTimer()` without coordinating with `Register`. +Interleaving: A removes the last entry and observes empty; B adds a registration and `EnsureRunning` +sees `s_loopTask != null` (still running) and returns; A stops the timer. Result: live registration, +dead timer — animation frozen until an unrelated `Register` restarts the loop. +**Fix:** perform the emptiness check + stop decision under `s_lock` together with loop-state +transitions, or introduce a generation counter that `StopTimer` validates before actually stopping. + +## Finding 5: per-frame, per-registration closure allocations on the hot path + +`SyncContext.Post(_ => _ = InvokeCallbackAsync(registration, tick, cancellationToken), null)` +allocates closure + delegate per registration per frame (60 Hz × N renderers of steady GC pressure). +**Fix:** one cached `static SendOrPostCallback`; pass state via a per-registration state object — +`InFlight` guarantees exclusivity, so tick data can be written into a reusable per-registration slot +before posting. Target: zero allocations per frame in steady state. + +## Finding 6: drift `Debug.Assert` is an assert storm + +Drift >20% for 10 frames is normal under a debugger, breakpoints, or CI load. Once tripped, +`consecutiveDriftFrames` keeps incrementing, so the assert fires **every subsequent frame**. +**Fix:** replace with tracing/EventSource counters (drift, dropped frames, spin time). If any assert +remains, reset the counter after firing once. + +## Finding 7: lifecycle edges + +- `StopTimer` never observes `s_loopTask`; a stop/start pair can transiently run two loops, and a + stopping loop can dispatch one final frame with a canceled token. Decide and document: either join + the old loop (bounded) or make late dispatch provably benign. +- Post-unregister ticks can still be in flight toward a disposed consumer; `AnimationManager` / + `AnimatedControlRenderer` must tolerate late callbacks — add a test. +- `Reset()` (test hook) mutates state without locking — document the serialization requirement or + lock it. +- Dead code: `Registration.Id` is never read. +- `InvokeCallbackAsync` swallows non-OCE exceptions with `Debug.Fail` and keeps invoking the same + callback forever; consider auto-unregistering a registration after N consecutive faults. + +## Finding 8: single-SyncContext funnel in `AnimationManager` (design note) + +`HighPrecisionTimer` correctly captures a `SynchronizationContext` **per registration**, but +`AnimationManager` is a process-wide singleton with a single registration, so every animation in a +multi-message-loop application marshals to the first UI thread — and dies with it. Minimum: document +the constraint. Better: make the manager per-UI-thread (e.g. `[ThreadStatic]` instance keyed off the +message-loop thread), preserving one timer-registration-per-UI-thread. Also note the duplicate +timeline: the manager keeps its own `Stopwatch` instead of deriving progress from +`HighPrecisionTimerTick.Timestamp/Elapsed`; animation progress should use the tick's timeline so frame +coalescing (`DroppedFrames`) is accounted for consistently. + +## Finding 9: 60 Hz is a settled ceiling; the period is a pacer-owned runtime value for *downshift* + +**Decision (do not relitigate):** the timer targets a hard 60 Hz ceiling (30 Hz fallback). Do not +add refresh-rate matching or raise the cadence. Rationale, for the record: + +- Under DWM, windowed GDI/GDI+ apps do not tear (composition is tear-free from the redirection + surface); the only artifact of an unsynced 60 Hz timer is judder, which is imperceptible for this + content class (focus pulses, hover fades, toggle transitions — not motion/scrolling). +- GDI+ raster cost scales linearly with rate; driving N animated controls at 120–144 Hz multiplies + compute for no perceptible gain (e.g. batched MVVM-driven updates across many controls). +- A process-wide timer cannot refresh-match on mixed-rate multi-monitor setups ("the" refresh rate + is ill-defined), and `DwmFlush`-style vblank pacing blocks per frame, binds to one monitor, and + misbehaves under RDP — unfit for a process-wide UI timer by construction. + +**Required now (structural):** the frame period must be a runtime value owned by the pacer +(queryable/settable internally), not compile-time constants woven through the loop. The 30 Hz +fallback already makes the period variable; the forward-looking motivation is **downshifting**, not +matching: future power-driven reductions (30 Hz or full pause for occluded/minimized windows — where +Windows 11 timer coalescing already alters the effective cadence — and battery-saver scenarios) +must be addable without another rework. + +--- + +## Acceptance criteria + +1. Steady-state CPU of the timer loop with one registered infinite animation: **< 2% of one core** + (measure; the current implementation is the ~50% baseline per Finding 1). +2. No `timeBeginPeriod` while animations run (verify via `powercfg /energy` or timer-resolution + query on a Win10 1803+ box). +3. Frame delivery: mean interval within ±0.5 ms of target over a 10 s run on an idle machine at + 60 Hz; no monotonic slow drift (absolute-schedule check: 600th frame due time within one frame of + `epoch + 600 × period`). +4. Zero per-frame heap allocations in steady state (verify with an allocation-tracking test or + `GC.GetAllocatedBytesForCurrentThread` bracketing). +5. Race test for Finding 4: concurrent register/unregister stress leaves no registration without a + running loop. +6. Existing `HighPrecisionTimerTests` updated/extended accordingly; late-callback tolerance test for + consumers (Finding 7). +7. `HighPrecisionTimerTick` surface unchanged (internal consumers depend on it); all other churn is + internal to the pacer. + +## Constraints + +- Everything stays `internal`; no public API review implications. +- Follow repo conventions (`LibraryImport`, nullable enabled, existing XML-doc voice). +- Windows-only paths gated with `[SupportedOSPlatform]` / `OperatingSystem.IsWindowsVersionAtLeast` + as currently practiced in the file. diff --git a/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md b/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md new file mode 100644 index 00000000000..56070f96b3e --- /dev/null +++ b/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md @@ -0,0 +1,158 @@ +# Work Order: SystemVisualSettings (Implementation) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Scope:** Code changes only. The GitHub proposal issue is handled by a separate work order +(`ApiReview-IssueUpdates.md`, Section 3) and run only after this implementation stands. +**Supersedes:** `Application.GetWindowsAccentColor` and the standalone accessibility text-size change +event currently on the branch (see item 6). + +--- + +## Background + +Windows delivers visual/accessibility setting changes through four channels — `WM_SETTINGCHANGE`, +`WM_DWMCOLORIZATIONCOLORCHANGED`, `WM_THEMECHANGED`, `WM_SYSCOLORCHANGE` — and today each consumer +normalizes that zoo individually. This work order introduces one typed, read-only snapshot plus one +unified change notification, with a **leak-free consumption path for controls** (virtual cascade, no +static-event subscription — the `SystemEvents.UserPreferenceChanged` leak class must be structurally +impossible for the default path). + +Deliberate non-goals, enforced by the type system: **no settable properties.** Perceptual adjustments +(contrast, color filtering, text scale, focus prominence, motion) are user-owned via Windows +accessibility settings; app-side theming is the business of control vendor partners. Renderers derive +output from this snapshot combined with `EffectiveVisualStylesMode`. + +--- + +## 1. New types (`System.Windows.Forms`) + +```csharp +public sealed class SystemVisualSettings +{ + public Color AccentColor { get; } + public float TextScaleFactor { get; } // 1.0–2.25, Windows a11y text scale + public bool HighContrastEnabled { get; } + public bool ClientAreaAnimationEnabled { get; } // SPI_GETCLIENTAREAANIMATION + public bool KeyboardCuesVisible { get; } // SPI_GETKEYBOARDCUES (system default) + public Size FocusBorderMetrics { get; } // SPI_GETFOCUSBORDERWIDTH/HEIGHT, pixels +} + +[Flags] +public enum SystemVisualSettingsCategories +{ + None = 0, + AccentColor = 1 << 0, + TextScale = 1 << 1, + HighContrast = 1 << 2, + Animations = 1 << 3, + KeyboardCues = 1 << 4, + FocusMetrics = 1 << 5 +} + +public class SystemVisualSettingsChangedEventArgs : EventArgs +{ + public SystemVisualSettings OldSettings { get; } + public SystemVisualSettings NewSettings { get; } + public SystemVisualSettingsCategories Changed { get; } +} +``` + +Immutable snapshot semantics — values must not shift under an event handler comparing old vs new. +XML remarks per the design notes: `HighContrastEnabled` cross-references the effective-mode clamp; +`KeyboardCuesVisible` documents the system-default vs per-window (`WM_UPDATEUISTATE`) distinction; +`FocusBorderMetrics` is documented as the baseline input for border/focus prominence in renderers +(scaled for DPI and `TextScaleFactor`), replacing fixed constants; the class remarks state the +no-app-side-overrides principle explicitly. + +## 2. `Application` surface + +```csharp +public static SystemVisualSettings SystemVisualSettings { get; } +public static event EventHandler? SystemVisualSettingsChanged; +``` + +Event XML remarks must state: (a) raised once per settings transition, normalized across the four +underlying messages; (b) handlers should early-out via `e.Changed`; (c) **audience is app-lifetime +consumers** (theming engines, services); components with shorter lifetime than the application must +unsubscribe; **controls and forms should use the `Control`-level virtual/instance event instead, +which requires no unsubscription** (see item 3). This positioning is the leak fix — make the +leak-free path the documented default. + +## 3. `Control`-level consumption (the leak-free path) + +```csharp +protected virtual void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e); +public event EventHandler? SystemVisualSettingsChanged; +``` + +- Model the cascade on the existing `OnSystemColorsChanged` pattern in `Control.cs`: virtual + dispatch parent→children, instance event raised from within the virtual. No subscription to any + static event exists anywhere in this path — lifetime coupling is structural. +- Keep the cascade pattern-identical to `OnVisualStylesModeChanged` / `OnParentVisualStylesModeChanged` + (see the VisualStylesMode impact work order). A `HighContrast` category change resolves as an + effective-visual-styles-mode change for affected controls and must route through that machinery's + early-out/dispatch — no duplicate HC handling in this cascade. +- **Remove/replace the existing Form-level replicated text-size event**: it generalizes into this + cascade and disappears as a special case. Migrate in-box usages. +- Staleness rule (document in XML remarks, mirroring `OnSystemColorsChanged` folklore — this time + written down): the cascade only reaches parented controls; a control created but not yet parented + misses transitions and must re-query `Application.SystemVisualSettings` on handle creation / + `OnParentChanged`. + +## 4. Message plumbing and normalization + +Central internal tracker (e.g. `SystemVisualSettingsTracker`) holding the current snapshot: + +- Every **top-level** window already receives the four raw messages; handle them in the existing + top-level `WndProc` paths. +- On receipt: the window asks the tracker to re-query. The **first** arriver computes the diff + against the current snapshot, atomically swaps it (`Interlocked` reference swap), raises the static + `Application` event **once**, and cascades into its own tree. Subsequent top-levels re-query, see + no diff, raise nothing at Application level, but **still cascade into their own trees** using the + already-computed args. +- Threading falls out for free: each tree is notified on the thread owning its top-level — no + marshaling, correct for multi-message-loop applications. Do not centralize onto one thread. +- Coalesce message storms: a single user action can produce several of the four messages; debounce + within a message-pump iteration (re-query once per burst per top-level, not per message). + +## 5. In-box consumption cleanup + +- Audit in-box `Microsoft.Win32.SystemEvents` subscriptions (`UserPreferenceChanged` et al.) in + controls/renderers; migrate those covered by the new categories to the cascade. Document any that + must remain (categories outside this surface) — do not expand the snapshot to chase them in this + work order. +- `TextBoxBase` Net11 border rendering: consume `FocusBorderMetrics` + `TextScaleFactor` as the + border-prominence input where fixed constants are currently used (coordinate with the animation / + focus-indicator renderer as applicable). +- Animated renderers (`AnimatedControlRenderer` / `AnimationManager`): honor + `ClientAreaAnimationEnabled == false` by rendering final state immediately and suppressing + transitions; react to the `Animations` category change at runtime. + +## 6. Supersede the piecemeal APIs + +- `Application.GetWindowsAccentColor` → `Application.SystemVisualSettings.AccentColor`. The method + has not shipped stable: **remove it** on this branch (preferred) rather than obsoleting, to avoid + two sources of truth. If removal is blocked by preview-compat policy, `[Obsolete]` with pointer. +- The standalone text-size change event (Application- and/or Form-level) → `Changed.HasFlag(TextScale)` + on the unified event / cascade. Same removal-vs-obsolete decision, same preference. +- Migrate all in-box call sites. + +## 7. Tests + +- Snapshot immutability and correct SPI mapping per property (mock/native-shim as the repo's test + infra allows). +- Normalization: N top-level windows + one settings transition ⇒ exactly one Application-level raise; + every window's tree cascaded exactly once; delivery on each tree's own thread. +- Flags correctness per category, including multi-category transitions (HC toggle typically changes + colors + HC + metrics in one burst — must coalesce to one event with combined flags). +- Leak test: create/dispose forms subscribing to the **control-level** event in a loop; assert + collectability (`WeakReference`), proving no static rooting. Counter-test documenting that the + static event does root (expected, documented behavior). +- HC toggle end-to-end: cascade triggers effective-mode change path once, no duplicate layout. + +## Constraints + +- All new public surface XML-documented in the branch's voice; internal tracker fully internal. +- No settable members anywhere on the new types — if implementation pressure suggests one, stop and + flag rather than adding it. +- Windows-only P/Invoke via `LibraryImport`, gated per existing repo practice. diff --git a/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md b/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md new file mode 100644 index 00000000000..a1d56d1aa0b --- /dev/null +++ b/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md @@ -0,0 +1,137 @@ +# Work Order: VisualStylesMode — Change-Impact API (Implementation) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Scope:** Code changes only. GitHub issue updates are handled by a separate work order +(`ApiReview-IssueUpdates.md`, Section 1) and run only after this implementation stands. +**Out of scope:** `HighPrecisionTimer` (separate work order), `SystemVisualSettings` (separate work order — +but see the composition note in item 4, the two cascades share a pattern). + +--- + +## Background + +The `VisualStylesMode` API is implemented on this branch. Two gaps motivate this change: + +1. **Correctness/ordering:** Base `Control.OnVisualStylesModeChanged` only invalidates, raises the + event, and cascades — no preferred-size cache invalidation, no layout transaction (unlike + `OnFontChanged`). Every metric-changing control reimplements a four-step protocol; `TextBoxBase` + currently runs `LayoutTransaction.DoLayoutIf(...)` **before** `UpdateStyles()`, so containers + measure against pre-transition metrics — a live mode switch clips/overlaps until controls are + recreated. +2. **Performance:** A top-level switch triggers one full parent layout per metric-affected control, + each against partially-transitioned state; repaint-only controls pay costs they don't need. + +Fix: a declarative impact model — derived controls state **what** changes, the base class owns +**how and in what order** to react, with one coalesced layout pass per container. + +--- + +## Changes + +### 1. `Control.cs` — visibility promotion + +`EffectiveVisualStylesMode`: `private protected` → `protected`. **Non-virtual** (deliberate — it is +the accessibility policy enforcement point; the customization hook is the already-virtual +`DefaultVisualStylesMode`). Extend XML docs: this is the value controls must honor for rendering; +reflects High Contrast (⇒ `Classic`) and `Disabled`; cross-reference `DefaultVisualStylesMode`. + +### 2. `Control.cs` — impact enum + virtual + +```csharp +protected enum VisualStylesModeChangeImpact +{ + None, // no rendering difference; skip all work + Repaint, // client-area rendering only; metrics identical + NonClientUpdate, // NC frame changes; style/frame update, no size change + Metrics // preferred size / layout metrics change; full invalidation + layout +} + +protected virtual VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + => VisualStylesModeChangeImpact.Repaint; +``` + +Parameters are **effective** modes. Document: implementations may consult instance state +(`Multiline`, `BorderStyle`, …) but the value must be stable for the duration of the change handling. + +### 3. `Control.cs` — setter early-out on effective equality + +In the `VisualStylesMode` setter and `OnParentVisualStylesModeChanged`: capture effective mode before +the change; if unchanged after, skip `OnVisualStylesModeChanged` entirely (no invalidate, no cascade +into that subtree). Preserve the existing shadowing behavior (child with explicit local value already +stops the ambient cascade) and add the effective-equality check on top. High Contrast active ⇒ raw +`Net11 → Latest` switch is a complete no-op. + +### 4. `Control.cs` — `OnVisualStylesModeChanged` becomes the dispatcher + +Preserve the disposal guard and public event raise. Then: + +- `impact = GetVisualStylesModeChangeImpact(oldEffective, newEffective)` — plumb old/new effective + values from the setter/cascade (least-invasive mechanism consistent with how `OnParentFontChanged` + plumbs state). +- `None` → raise event, skip rendering/layout work, still cascade (children's impact may differ). +- `Repaint` → `Invalidate()`. +- `NonClientUpdate` → `UpdateStyles()` + NC frame refresh (`SetWindowPos` + `FRAMECHANGED`, per the + branch's existing pattern), then `Invalidate()`. +- `Metrics` → in order: `CommonProperties.xClearPreferredSizeCache(this)` → `UpdateStyles()` / frame + refresh → layout deferred to the coalesced step: + +```csharp +if (ChildControls is { } children) +{ + using (new LayoutTransaction(this, this, PropertyNames.VisualStylesMode, resumeLayout: false)) + { + for (int i = 0; i < children.Count; i++) + { + children[i].OnParentVisualStylesModeChanged(e); + } + } +} + +LayoutTransaction.DoLayout(this, this, PropertyNames.VisualStylesMode); +``` + +Add `PropertyNames.VisualStylesMode` if missing. + +**Composition note:** the `SystemVisualSettings` work order introduces a structurally identical +parent→child cascade (`OnSystemVisualSettingsChanged`, modeled on `OnSystemColorsChanged`). Keep the +two cascades pattern-identical; a High Contrast toggle arriving via that path resolves here as an +effective-mode change and must take the same early-out/dispatch route — no duplicate handling. + +### 5. `TextBoxBase.cs` — adopt the model, delete the hand-rolled protocol + +- Override `GetVisualStylesModeChangeImpact`: crossing `Classic`/`Disabled` ↔ `>= Net11` ⇒ `Metrics` + (`PreferredHeight` selection and NC padding both change). Within `>= Net11` (`Net11 → Latest`) ⇒ + `Repaint` **unless** the branch's renderer shows metric differences between those modes — verify + against `PreferredHeightCore` and the NC padding tables; document the decision in XML remarks. +- Slim `OnVisualStylesModeChanged`: remove manual `xClearPreferredSizeCache` / `DoLayoutIf` / + `AdjustHeight` sequencing — base owns it. Keep control-specific work + (`_focusIndicatorRenderer?.Synchronize(...)`, `_triggerNewClientSizeRequest = false`), with the + ordering contract: latch reset **before** `base.OnVisualStylesModeChanged(e)` (which runs + `UpdateStyles()`); any residual `AdjustHeight` need goes **after** `base` — verify with the + live-switch repro (TableLayoutPanel, AutoSize rows, anchored single-line TextBoxes; runtime mode + switch; rows must resize without reopening the form). +- This closes the `DoLayoutIf(AutoSize: false)` hole: base-owned coalesced layout means + `AutoSize == false` TextBoxes no longer skip parent re-measurement. + +### 6. Documentation & tests + +- `docs/Net11Api_05_VisualStylesMode.HighRiskReview.md`: add the behavioral change (base + `OnVisualStylesModeChanged` now clears caches, updates styles, requests layout; overriders calling + `base` inherit this). If the branch's `WFCC`/`ComponentChange` infrastructure is present, annotate; + otherwise the high-risk doc entry suffices. +- `ControlTests.VisualStylesMode.cs`: + - effective-equality early-out (HC active ⇒ no event storm, no layout), + - default impact is `Repaint`, + - `Metrics` path clears preferred-size cache; exactly one layout per container for a multi-control + subtree switch (layout-count instrumentation or `LayoutEventArgs` assertion), + - `TextBoxBase` live-switch regression test: preferred-height change reflected in an AutoSize + `TableLayoutPanel` row without control recreation. + +## Constraints + +- No binary breaking changes (`protected` promotion + new `protected` members on unsealed public + class are additive). +- Do not change the `EffectiveVisualStylesMode` clamping logic. +- Match repo analyzers/style and the branch's XML-doc voice. 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. diff --git a/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md new file mode 100644 index 00000000000..09894f9d23f --- /dev/null +++ b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md @@ -0,0 +1,137 @@ +# Work Order — Port `TextBoxBase` NC-Painting + `VisualStylesMode` Chrome onto `VisualStylesNet11` + +**Target branch:** `KlausLoeffelmann/winforms` → `VisualStylesNet11` (current-`main`-based; modern path layout, no `/src/src/` doubling; `TextBoxBase.cs` ≈ 2130 lines). + +**Source of the original implementation (pinned):** `KlausLoeffelmann/winforms` @ `cf32e9c4efeba9d77e1a14025a8590b104e3c705` (old `/src/System.Windows.Forms/src/...` layout; `TextBoxBase.cs` ≈ 2562 lines). Relevant files: +- `.../Controls/TextBox/TextBoxBase.cs` — NC paint, NC calc, focus invalidation, `GetVisualStylesPadding`, helpers. +- `.../Controls/TextBox/TextBoxBase.NonClientBitmapCache.cs` — the offscreen cache class. +- `.../Controls/TextBox/TextBox.cs` — derived-level overrides (`CreateParams`, `WndProc`, `OnBackColorChanged`, `PRF_NONCLIENT` path). + +**Standing approval:** API review board sign-off from the .NET 9 cycle is still valid; DRI owns the call. This is a port + cleanup, **not** a redesign. Do not invent new public API beyond what the original exposed plus the `Padding` unshadowing called out below. + +**House style (apply throughout):** namespaces globally imported (no `using`/`imports` noise); C# 13/14; NRTs on; `var` only for long type names or when the type is obvious from the RHS, explicit type names for primitives; blank line between a new block and a following `return`; pattern matching / `is` / `and` / `or` / switch expressions preferred; collection expressions (`List x = [];`); expression-bodied members for single-line methods/read-only props formatted with the `=>` on the next line, 1-space-indented. Generate XML doc comments; use ``/``. + +--- + +## PROMPT 1 — Carry-over / Port + +> **Role:** You are porting a working-but-imperfect feature across a 3-year gap in the surrounding file. Faithfully reproduce the *mechanism*; do not improve, simplify, or "modernize" the algorithm except where this document explicitly says to. Where the surrounding `VisualStylesNet11` code has moved on, rebase onto the new shape rather than pasting. + +**Task.** Bring the non-client (NC) painting feature for `TextBoxBase` (and the `TextBox` derived touchpoints) from the pinned SHA `cf32e9c4…` onto `VisualStylesNet11`. The target branch currently has **none** of this work (no `WmNcPaint`/`WmNcCalcSize`/`OnNcPaint`/`GetVisualStylesPadding`/`NonClientBitmapCache`), but it **does** have the `VisualStylesMode` property infrastructure referenced in doc comments — wire into that, don't redeclare it. + +**Steps, in order:** + +1. **Fetch and diff the three source files** at SHA `cf32e9c4…` against their `VisualStylesNet11` counterparts. Produce a short inventory of every member you intend to add or modify, grouped by file, before writing any code. + +2. **Port `TextBoxBase.cs` members:** + - `WmNcPaint` / `OnNcPaint` (offscreen bitmap fill → AA rounded/single chrome → blit; `GetWindowDC`/`ReleaseDC` in `finally`). + - `WmNcCalcSize` (carves the padding band from `NCCALCSIZE_PARAMS->rgrc[0]`; gated on the `_triggerNewClientSizeRequest` latch). + - `InitializeClientArea` (the one-shot `SetWindowPos(SWP_FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER|NOACTIVATE)` that provokes the single NC-calc). + - `GetVisualStylesPadding` / `GetScrollBarPadding` and the `VisualStyles{Fixed3D|FixedSingle|NoBorder}BorderPadding`, `BorderThickness` consts. + - The `WM_NCCALCSIZE` / `WM_NCPAINT` cases in `WndProc`. + - `OnGotFocus` / `OnLostFocus` / `OnSizeChanged` NC-frame invalidation (`RedrawWindow` with `RDW_FRAME|RDW_INVALIDATE`). + - `PreferredHeight` split (`PreferredHeightClassic` vs `PreferredHeightCore` selected by `VisualStylesMode`). + - Reconcile `GetPreferredSizeCore` with the modern branch already present on target. + +3. **Port `TextBoxBase.NonClientBitmapCache.cs`** — BUT this is a **decision point**, see Step 6. + +4. **Reconcile `TextBox.cs` (derived):** the target already has `CreateParams`, `WndProc`, `OnBackColorChanged` (special-casing `Fixed3D`), `OnGotFocus`, and a `WM_PRINTCLIENT`/`PRF_NONCLIENT` + `Application.RenderWithVisualStyles` path. Merge the NC behavior so the derived overrides cooperate with the new base NC painting (no double border draw, no fighting the `PRF_NONCLIENT` path). Call out every conflict you resolve. + +5. **Unshadow `Padding`.** On target it is still the neutered shadow (`[Browsable(false)]`, `EditorBrowsableState.Never`, `DesignerSerializationVisibility.Hidden`, `get/set => base.Padding`). Make it a real, browsable, serializable property that feeds the NC band via `GetVisualStylesPadding`. Preserve classic-mode behavior when `VisualStylesMode` is `Disabled`/`Classic`. Note the designer-serialization and back-compat implications in the PR description. + +6. **Retarget the rounded-rectangle helpers to the framework.** The original called fork-local `FillRoundedRectangle`/`DrawRoundedRectangle`. These now ship as **`System.Drawing.Graphics` instance methods** (`(Pen, Rectangle, Size)` + `RectangleF`/`SizeF` overloads; landed in the .NET 9 wave, current on target). **Delete the fork-local helpers and call the shipped methods.** ⚠️ Verify a **`FillRoundedRectangle(Brush, …)`** overload actually exists on target — the public ref only enumerates the `Draw`(`Pen`) overloads. If the `Fill`/`Brush` overload is absent, STOP and flag it; do not re-add a private helper without surfacing the gap. + +7. **Cache → `BufferedGraphics` (DECIDED — implement, do not re-evaluate).** The original used a hand-rolled per-instance `NonClientBitmapCache` (`CreateCompatibleBitmap` + `Image.FromHbitmap` + manual `DeleteObject`, `EnsureSize` realloc). Jeremy's late "use the existing cached bitmap" is confirmed to mean WinForms' own **`BufferedGraphics`/`BufferedGraphicsContext`** (the engine behind `OptimizedDoubleBuffer`; in `System.Drawing.Common` since .NET Framework 2.0, present on target). It is **not** `System.Drawing.Imaging.CachedBitmap` — that type is a read-only, device-dependent, blit-only frozen copy (no `Graphics`, translation-only, dies on bit-depth change) and cannot be a render target. **Delete `NonClientBitmapCache` entirely** (and its file `TextBoxBase.NonClientBitmapCache.cs`) and the `_cachedBitmap` field; replace with the shared buffer. Exact wiring: + - Inside `OnNcPaint`, get the shared context and allocate the buffer against the **window-DC `Graphics` already created in `WmNcPaint`**, sized to the window `bounds`: + `BufferedGraphicsContext context = BufferedGraphicsManager.Current;` + `using BufferedGraphics buffer = context.Allocate(graphics, bounds);` + `Graphics offscreenGraphics = buffer.Graphics;` + - **Do NOT `using`/dispose `buffer.Graphics`** — the `buffer` owns it; `using` the **buffer** only. (The original `using`-disposed its `GetNewGraphics()` because it owned that `Graphics`; that ownership is now the buffer's.) + - **All drawing into `offscreenGraphics` is unchanged** — the `FillRectangle(parentBackgroundBrush…)` corner-fill, the `BorderStyle` switch, the focus line: byte-for-byte identical, just a different `Graphics` target. + - **`ExcludeClip(clientBounds)` stays on the *target* `graphics`** (the window-DC one), exactly where it is now, set *before* the buffer draws. It governs where `Render()` may blit, protecting the client area — unchanged semantics. + - Replace the final blit `graphics.DrawImageUnscaled(offscreenBitmap, Point.Empty);` with **`buffer.Render();`** (no argument — it blits to the `graphics` captured at `Allocate` time). + - While here, fix the pre-existing **double-dispose** in `WmNcPaint`: it has both `using Graphics graphics = …` and an explicit `graphics.Dispose()` in `finally`. Drop the explicit `Dispose()`; keep the `using` (or keep explicit and drop `using` — one, not both). + - **Rationale to record in PR notes:** WinForms paints NC serially (one HWND at a time on the UI thread), so a single shared buffer suffices for any number of controls; the per-instance cache kept N resident GDI bitmaps to serve a one-deep queue. Steady-state allocation is unchanged (zero — shared buffer is reused when size fits); resident GDI memory drops from N× to 1×. The only cost is buffer-resize churn if controls of *wildly varying* sizes paint in a grow/shrink-alternating order — see smoke scenario 7 instrumentation. + +8. **Carve clamp + chrome degradation (settled design — implement exactly as stated, do NOT add a minimum size).** The original `WmNcCalcSize` does raw subtraction on `rgrc[0]` with **no clamp**, so a large `Padding` (made worse because `GetVisualStylesPadding(true)` *adds* the live scrollbar allowance from `GetScrollBarPadding` on top of the border padding) can drive the carved client rect to **zero or inverted**. Two separate fixes, and they are deliberately *not* a `MinimumSize`: + - **(8a) Never-invert clamp in `WmNcCalcSize`.** Floor each carved extent so the client rect can never invert: after the four adjustments, ensure `bottom >= top` and `right >= left` (e.g. clamp so the resulting client width/height is `Math.Max(0, …)`). A 0–1px client area is **acceptable and intended** — shipping multiline `TextBox` already shrinks to ~1px with scrollbars present, and we match that exactly. **Do NOT introduce a min-height/`MinimumSize`**, and do NOT make sizing behavior differ by `VisualStylesMode` (that would fracture the appearance-only contract of the opt-in). The clamp only prevents *underflow past zero*, which raw subtraction does and plain shrinking does not. + - **(8b) Paint-time chrome degradation in `OnNcPaint`.** The rounded `Fixed3D` chrome (15px radius) renders as a broken lozenge below roughly `2 × cornerRadius + BorderThickness` in height. When the available band/height is below that viable threshold, **fall back to the original/simple chrome render** (flat or single-style border) instead of the rounded path. This is a *rendering* fallback only — it does not change size or layout. Rationale on record: if the box is so small there's no usable client area, the control isn't usable anyway, so graceful visual degradation (not a size floor) is the correct response. + +9. **Build** `System.Windows.Forms` for the target TFM. Resolve all errors. Do not suppress new analyzer warnings without a one-line justification each. + +**Deliverable:** a single commit (or tight series) on `VisualStylesNet11` plus a PR description that lists: members added/modified per file, every `TextBox.cs` conflict resolved, confirmation that `NonClientBitmapCache` was removed and replaced by `BufferedGraphics` (Step 7) with the resize-churn rationale, the clamp/degradation (Step 8) confirmed as render-only with no size minimum, the `Padding` unshadowing implications, and any flagged gaps (Step 6 `Fill` overload). + +--- + +## PROMPT 2 — Critical Review (run AFTER Prompt 1, BEFORE smoke test) + +> **Role:** Adversarial reviewer. The author wants the issues a sharp WinForms maintainer would catch, not reassurance. Cite file + line for every finding. Classify each as **MUST-FIX**, **SHOULD-FIX**, or **PRESERVE (do not 'improve')**. + +Audit the ported code against this checklist. For each item, state the finding and the exact location. + +1. **DPI scaling of the corner radius.** The original hardcodes `const int cornerRadius = 15` and `BorderThickness = 1` in device-independent units, then uses them inside a DPI-scaled NC band, while `GetVisualStylesPadding` *does* take a DPI path (`_deviceDpi`). Confirm whether the radius/thickness now scale Per-Monitor-V2. If not → **MUST-FIX** (corners look proportionally too tight at 150/200%). + +2. **Full-frame NC repaint vs. partial `hrgnClip`.** `WmNcPaint` ignores the wParam clip region and repaints the whole frame. This is the **intended** fix for the offscreen-restore "dirty corners" artifact — verify it's preserved. But confirm `base.WndProc(ref m)` is still invoked with the original message and isn't double-painting the native border under the custom chrome. Classify the "ignore clip" behavior as **PRESERVE**. + +3. **Corner-blend source = `Parent?.BackColor ?? BackColor`.** This is the known ceiling: corners blend against the parent's flat back color, so they mismatch over a gradient/image/Mica/sibling. For the common case (solid form/panel) it's correct. **PRESERVE** — do not let it be "improved" into a fake general-case solution. Note it as a documented limitation only. + +4. **`WM_NCCALCSIZE` ↔ `Padding` round-trip + underflow.** Verify the band carved in `WmNcCalcSize` matches what `GetVisualStylesPadding(true)` reports and what `GetPreferredSizeCore`/`SizeFromClientSize` assume, for all three `BorderStyle` values × `Multiline` × scrollbars. Off-by-one here clips text or the caret. **Additionally** confirm the never-invert clamp (Prompt 1 Step 8a) is present and correct: with large `Padding` on a small multiline box *with both scrollbars*, the carved client rect must floor at 0, never invert. Remember the threshold is **border padding + live scrollbar padding** (`GetScrollBarPadding` reads `WS_HSCROLL`/`WS_VSCROLL`), so underflow hits sooner than the `Padding` value alone implies. Classify "0–1px client area is allowed, no min-size" as **PRESERVE** — do not let a reviewer or the agent add a `MinimumSize` floor. + +4b. **Chrome degradation below viable height.** Confirm `OnNcPaint` (Prompt 1 Step 8b) falls back to simple/flat chrome when height < ≈`2 × cornerRadius + BorderThickness`, instead of drawing a corrupted rounded rect. This is **render-only**; assert it does **not** alter size, layout, or `ClientSize`. Verify the fallback path itself is DPI-correct (the threshold scales with the radius, which per item 1 must scale). + +5. **`BorderStyle` fork + native edge suppression.** `Fixed3D` → rounded chrome, `FixedSingle` → single + underline, `None` → fill. Confirm the native `WS_EX_CLIENTEDGE`/`WS_BORDER` from `CreateParams` is suppressed when NC chrome is active, so the native edge isn't drawn under the custom one. + +6. **`VisualStylesMode` gating is total.** Every NC entry point (`WmNcPaint`, `WmNcCalcSize`, `InitializeClientArea`, the focus/size invalidations) must early-out to byte-for-byte classic behavior when `VisualStylesMode` is `Disabled`/`Classic`. One missing guard = a back-compat regression. Note the original's `OnLostFocus` was **missing** the guard that `OnGotFocus` had — verify the port fixed this asymmetry. + +7. **DC / GDI lifetime.** `GetWindowDC`→`ReleaseDC` in `finally`, and `Graphics.FromHdc`+`Dispose` ordering: correct **only** because `FromHdc` doesn't own the DC. **PRESERVE** — flag any "tidy into a single `using`" as a regression. Confirm the `WmNcPaint` **double-dispose** was fixed (it had both `using Graphics` and an explicit `graphics.Dispose()`). For the `BufferedGraphics` swap (Prompt 1 Step 7): verify the **buffer** is `using`-scoped but **`buffer.Graphics` is NOT separately disposed**; verify `Allocate` targets the window-DC `graphics` and `Render()` is called with no argument; confirm `NonClientBitmapCache` and the `_cachedBitmap` field are fully removed with no dangling refs. Audit for any HBITMAP/HDC leak in the new path (there should be none — the buffer owns it). + +8. **DPI-change without handle recreate.** `_triggerNewClientSizeRequest` is a one-shot latch reset on handle recreate. Does a DPI change that does *not* recreate the handle re-carve the band with new padding? If the band can go stale on monitor move → **MUST-FIX** or at least an explicit tracked issue. + +9. **Caret / IME / selection repaint.** The native `EDIT` invalidates aggressively. Confirm NC chrome doesn't go stale on caret blink/IME composition, and conversely that NC isn't thrashing-repainting on every caret tick. (Author never confirmed this was clean in the original.) + +10. **`TextBox.cs` derived reconciliation.** Verify the derived `WndProc`, `OnBackColorChanged` `Fixed3D` special-case, and the `PRF_NONCLIENT`/`Application.RenderWithVisualStyles` path don't conflict with base NC painting (double draw, wrong-mode paint). + +11. **Allocation churn.** Brushes/pens use cached scopes (`GetCachedSolidBrushScope`/`GetCachedPenScope`) — good; confirm preserved. Confirm the offscreen surface isn't reallocated per paint (only on size change). + +**Deliverable:** a findings list (file:line, severity, recommendation). MUST-FIX items get fixed in this pass; SHOULD-FIX either fixed or filed; PRESERVE items annotated in code with a brief `// Intentional:` comment so the next reader doesn't "fix" them. + +--- + +## PROMPT 3 — Smoke Test Harness + +> *("Smoke test" = the shallow "does it power on without catching fire" pass — from hardware bring-up, where first power-on literally checked for smoke — run before any deep/perf testing. Goal here: broad coverage that it comes up and behaves on the obvious axes, with the two known-fragile cases as explicit named tests.)* + +**Task.** Build a throwaway WinForms test app (separate project, not shipped) that exercises the ported feature across its permutation space and **specifically reproduces the two regressions this feature is prone to.** + +**Permutation grid** — generate a form populated with `TextBox`es (and at least one `RichTextBox`, since `TextBoxBase` is the shared base) covering the cross-product of: +- `BorderStyle`: `None` × `FixedSingle` × `Fixed3D` +- `Multiline`: `false` × `true` (+ `WordWrap` on/off for multiline) +- `Padding`: `Empty` × asymmetric (e.g. `2,6,2,6`) × large (`12`) +- Scrollbars: none × vertical × both +- `VisualStylesMode`: `Disabled`/`Classic` (must look exactly like today) × `Net10`+ (new chrome) +- Focused vs unfocused (drive focus programmatically to capture the adorner/underline) + +**Named, must-pass scenarios (the ones that silently regress):** + +1. **Offscreen-restore ("dirty corners").** Move the window partly off the left/top screen edge, then back. Assert the NC corner regions are repainted clean (no stale pixels). This is the artifact that drove the full-frame-repaint design. Automate the drag via `SetWindowPos`/`MoveWindow`; capture before/after. + +2. **Partial NC invalidation.** Trigger a partial `WM_NCPAINT` (e.g. overlap then reveal a sliver of the frame) and assert the whole chrome is coherent, not just the revealed strip. + +3. **Per-Monitor-V2 DPI.** Run DPI-aware; move forms between a 100% and a 150%/200% monitor (or fake via `LogicalToDeviceUnits`/DPI-changed messages). Assert corner radius, border thickness, and padding band all scale; assert no clipped text/caret. + +4. **Classic-mode parity.** With `VisualStylesMode = Disabled`, assert the control is pixel-identical to baseline `main` (native edge, no custom NC). A regression here is the back-compat line breaking. + +5. **`BorderStyle` switch at runtime** (`Fixed3D`↔`FixedSingle`↔`None`) and **`Padding` change at runtime** — assert the band re-carves and chrome redraws without artifacts (exercises the `_triggerNewClientSizeRequest` reset path). + +6. **Focus transitions** — tab through the grid; assert the focus underline (single) / 3D focus line (Fixed3D, shortened to clear the corner curve) appears/clears correctly. + +7. **Shrink-to-collapse (clamp + degradation).** Take a multiline `Fixed3D` box with large `Padding` (e.g. `12`) and **both** scrollbars visible, then programmatically drag/resize its height down toward 1px. Assert: (a) **no crash / no inverted client rect** handed to the native `EDIT` — the carve floors at 0 (Step 8a); (b) below ≈`2 × cornerRadius + thickness` the chrome **falls back to flat/simple render** rather than drawing a corrupted lozenge (Step 8b); (c) the control **still collapses** to ~1px exactly like classic multiline — assert it is **not** held open by any min-size (regression if a floor appeared). Repeat at 150%/200% DPI so the degradation threshold is verified scaled, not fixed at 96-dpi pixels. + +8. **BufferedGraphics allocation churn (perf sanity).** Build two forms: (a) **40 same-size** textboxes, (b) **40 wildly varying-size** textboxes (mix tiny and large), all `VisualStylesMode ≥ Net10`. Force a full repaint storm (invalidate all NC frames repeatedly; resize the form to cascade re-layout). Instrument the shared buffer: wrap/observe `BufferedGraphicsManager.Current` and count actual **bitmap (re)allocations** vs. reuses across the storm. Assert: case (a) allocates the buffer ≈once then reuses (steady-state alloc ≈ 0); case (b) may reallocate on grow but must **not** allocate-per-paint. Log alloc count per case. This empirically confirms the shared-buffer reasoning from Prompt 1 Step 7 and catches any accidental per-paint allocation regression. + +**Harness mechanics:** +- A "capture all" button that screenshots each form to disk per `VisualStylesMode`, for eyeball diffing classic-vs-modern and pre-vs-post-DPI. +- A console/log line per assertion (pass/fail) so it can run semi-automated. +- Keep it dependency-light: raw WinForms + `SetWindowPos`/`RedrawWindow` P/Invoke for the offscreen and invalidation drivers. + +**Deliverable:** the test project + a one-screen README naming the six scenarios and how to run them, plus a results log from one full run on the porter's machine (note DPI of monitors used). diff --git a/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md new file mode 100644 index 00000000000..ce3ad461cab --- /dev/null +++ b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md @@ -0,0 +1,120 @@ +# Copilot Prompt 1 — Author the WinForms API Suggestion + +## 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 two related features +for reducing visible intermediate states while WinForms applications update their UI: + +1. Painting suspension with optional layout suspension across a control tree. +2. Deferred top-level form reveal. + +Use these issue sections: + +- `## Rationale` +- `## API Proposal` +- `## API Usage` +- `## Alternative Designs` +- `## Risks` +- `## Will this feature affect UI controls?` +- `### Status Checklist` + +## Sub-feature A — painting and layout suspension + +### Settled API surface + +```csharp +namespace System.Windows.Forms; + +public interface ISupportSuspendPainting +{ + void BeginSuspendPainting(); + void EndSuspendPainting(); +} + +public enum LayoutSuspendTraversal +{ + None = 0, + TopLevelOnly = 1, + Traverse = 2, +} + +public sealed class SuspendPaintingScope : IDisposable +{ + public SuspendPaintingScope(ISupportSuspendPainting? target); + public void Dispose(); +} + +public static class ControlMutationExtensions +{ + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); +} +``` + +- `Control` implements `ISupportSuspendPainting` explicitly and exposes protected virtual + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` route painting + suspension through their existing `BeginUpdate` / `EndUpdate` paths. +- `None` suspends painting only. +- `TopLevelOnly` suspends layout on the target `Control`. +- `Traverse` suspends layout on the target and all existing descendants. +- The predicate is evaluated for the target and every descendant. A `false` result skips + that node but does not prune traversal. +- Selected nodes are suspended even when they currently have no children. +- Layout-aware overloads require the target to derive from `Control`. +- The scope is a sealed class so it can span `await`. + +### Design considerations + +- Snapshot the selected controls when the scope starts. +- Suspend layout root-to-leaf and resume it deepest-first. +- Resume layout before ending painting so recursive invalidation occurs after layout. +- Keep disposal idempotent and preserve nesting through the existing ref counts. +- Explain that controls added after the snapshot are covered by their parent's suspended + layout but do not receive an independently balanced suspension. +- Discuss traversal cost for large control trees and exceptions thrown by predicates. +- Include invalid and non-`Control` target behavior in the proposal. + +## Sub-feature B — deferred form reveal + +Use the current `FormRevealMode` design from the tracked API proposal: + +```csharp +namespace System.Windows.Forms; + +public enum FormRevealMode +{ + Inherit = -1, + Classic = 0, + Deferred = 1, +} + +public partial class Form +{ + public virtual FormRevealMode FormRevealMode { get; set; } +} + +public partial class Application +{ + public static FormRevealMode DefaultFormRevealMode { get; } + public static void SetDefaultFormRevealMode(FormRevealMode mode); + public static bool IsFormRevealDeferred { get; } +} +``` + +Describe DWM cloaking, dark-mode-aware default resolution, designer serialization, +top-level-window limitations, and conservative fallback behavior on unsupported systems. + +## Filing instruction + +Produce a complete proposal rather than an implementation plan. Clearly distinguish fixed +API shape from implementation choices that remain open for review. diff --git a/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md new file mode 100644 index 00000000000..35d765ad3ce --- /dev/null +++ b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md @@ -0,0 +1,76 @@ +# Copilot Prompt 2 — Implement the flicker-free UI mutation APIs + +## Prerequisite + +Read the current upstream API suggestion before implementation. Treat its latest +`API Proposal` section as the public contract and report any conflict instead of silently +choosing a different shape. + +## Scope + +### A — painting suspension with optional layout traversal + +- Implement `ISupportSuspendPainting` on `Control` with ref-counted + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- Route `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` through their + existing `BeginUpdate` / `EndUpdate` mechanisms. +- Keep `SuspendPaintingScope` as a sealed, idempotent `IDisposable` class so it can span + `await`. +- Provide these extension methods: + + ```csharp + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); + ``` + +- `LayoutSuspendTraversal` has `None`, `TopLevelOnly`, and `Traverse`. +- Snapshot selected controls before suspension. Suspend root-to-leaf and resume + deepest-first. +- The predicate is evaluated for the target and every descendant. A `false` result skips + suspension for that node without pruning its descendants. +- Suspend selected controls even when they currently have no children. +- Validate layout-aware calls before beginning painting. A non-`Control` target is invalid. +- Resume all selected layout scopes before ending painting so the recursive invalidation + occurs after layout. + +### B — deferred form reveal + +- Implement the approved `FormRevealMode`, `Form.FormRevealMode`, and + `Application` configuration APIs. +- Preserve classic behavior when deferred reveal is not active. +- Cloak eligible top-level form handles and uncloak according to the timing strategy in the + proposal. +- Keep unsupported DWM and window configurations inert and safe. + +## Engineering requirements + +- Match the current repository language version, nullable annotations, code style, and + interop conventions. +- Add XML documentation for every public or protected API. +- Update `PublicAPI.Unshipped.txt`. +- Keep state lazy where existing `Control` property-store patterns apply. +- Do not change existing public `BeginUpdate` / `EndUpdate` behavior. + +## Tests + +- Painting ref-count balance, nesting, idempotent disposal, handle creation, and handle + recreation. +- `None`, `TopLevelOnly`, and `Traverse` layout selection. +- Predicate selection, continued traversal after a rejected node, and empty containers. +- Deepest-first layout resume and recursive invalidation after layout. +- Null, invalid-enum, and non-`Control` target failures without partial suspension. +- Existing native update paths for the selected built-in controls. +- Classic/deferred form reveal behavior and unsupported-OS fallback. + +## Deliverable + +Provide production code, focused tests, updated API tracking, and an updated API proposal. +Call out any behavior that the implementation proves unsafe or unnecessarily costly. diff --git a/.github/copilot/Async/invokeAsync_generate_test_instructions.md b/.github/Feature-Prompts/Net9/Async/invokeAsync_generate_test_instructions.md similarity index 100% rename from .github/copilot/Async/invokeAsync_generate_test_instructions.md rename to .github/Feature-Prompts/Net9/Async/invokeAsync_generate_test_instructions.md diff --git a/.github/copilot/GDI/DarkModeButtonRendererCodeGenerationInstructions.md b/.github/Feature-Prompts/Net9/GDI/DarkModeButtonRendererCodeGenerationInstructions.md similarity index 100% rename from .github/copilot/GDI/DarkModeButtonRendererCodeGenerationInstructions.md rename to .github/Feature-Prompts/Net9/GDI/DarkModeButtonRendererCodeGenerationInstructions.md From ba4b052cba9d6a464f8a376aac6b646a2b1df637 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 30 May 2026 02:05:23 -0700 Subject: [PATCH 02/99] 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 904f907a0ba8cad219064839d5ed14aa10c4012c 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 03/99] 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 e8ccfcc65227f1ca9ae2dc35ea8e3d48679c8a34 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 04/99] 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 ea2837f21c8df352fca79fac811e01ae8faec153 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 05/99] 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 e23bcd15dcd57c68c0c21c9a9c0fa666714cfb75 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 8 Jul 2026 14:57:47 -0700 Subject: [PATCH 06/99] 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 b4fdde0ab393aa8c2b4e9c44c4b49649e54c5aba Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 8 Jul 2026 14:57:48 -0700 Subject: [PATCH 07/99] 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 79a1bd18d8500d6083b132cd15cf60ac2740211c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Tue, 14 Jul 2026 16:09:33 -0700 Subject: [PATCH 08/99] Simplify system text size notifications Use event subscriptions as the opt-in for text-size notifications and remove the redundant awareness API surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40d0f0f7-a584-4167-9187-9d529b7c70f8 --- .../PublicAPI.Unshipped.txt | 5 -- .../Forms/Application.SystemTextSize.cs | 72 +++++++++---------- .../Windows/Forms/Form.SystemTextSize.cs | 12 ++-- .../Windows/Forms/SystemTextSizeAwareness.cs | 31 -------- .../Forms/ApplicationTests.SystemTextSize.cs | 41 ++--------- 5 files changed, 45 insertions(+), 116 deletions(-) delete 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 be22f5f4e2f..5d58dcbc122 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,10 +1,5 @@ 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 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/System/Windows/Forms/Application.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs index 136be5d5cc7..5db47e397bc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs @@ -1,7 +1,6 @@ // 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; @@ -11,7 +10,6 @@ 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; @@ -33,18 +31,7 @@ public sealed partial class Application 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 . + /// Occurs once per process when the Windows Accessibility text-scale setting changes. /// /// /// @@ -58,30 +45,43 @@ public sealed partial class Application /// notifications from their window procedures and do not subscribe to this static event, avoiding framework-managed /// static-event rooting of forms. /// + /// + /// On operating systems earlier than Windows 10 version 1507, this event is not raised. + /// /// 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)); + add + { + if (value is null) + { + return; + } - lock (s_internalSyncObject) + lock (s_internalSyncObject) + { + EnsureSystemTextSizeNotificationsInitialized(); + AddEventHandler(s_eventSystemTextSizeChanged, value); + } + } + remove { - EnsureSystemTextSizeNotificationsInitialized(); - s_systemTextSizeAwareness = awareness; + if (value is null) + { + return; + } + + lock (s_internalSyncObject) + { + RemoveEventHandler(s_eventSystemTextSizeChanged, value); + + if (s_systemTextSizeNotificationsInitialized + && s_eventHandlers?[s_eventSystemTextSizeChanged] is null) + { + SystemEvents.UserPreferenceChanged -= OnSystemTextSizeUserPreferenceChanged; + s_systemTextSizeNotificationsInitialized = false; + } + } } } @@ -118,11 +118,7 @@ private static void OnSystemTextSizeUserPreferenceChanged(object? sender, UserPr } s_lastSystemTextSize = systemTextSize; - - if (s_systemTextSizeAwareness == SystemTextSizeAwareness.Notify) - { - handler = s_eventHandlers?[s_eventSystemTextSizeChanged] as EventHandler; - } + handler = s_eventHandlers?[s_eventSystemTextSizeChanged] as EventHandler; } handler?.Invoke(null, EventArgs.Empty); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs index 38c36b8a999..2a7886e20df 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs @@ -13,9 +13,11 @@ public partial class Form private double _lastSystemTextSize = ScaleHelper.GetSystemTextScaleFactor(); /// - /// Occurs on this top-level when the Windows Accessibility text-scale setting changes, - /// while is . + /// Occurs on this top-level when the Windows Accessibility text-scale setting changes. /// + /// + /// On operating systems earlier than Windows 10 version 1507, this event is not raised. + /// [SRCategory(nameof(SR.CatLayout))] [SRDescription(nameof(SR.FormOnSystemTextSizeChangedDescr))] public event EventHandler? SystemTextSizeChanged @@ -62,11 +64,7 @@ private void WmSettingChange(ref Message m) } _lastSystemTextSize = systemTextSize; - - if (Application.SystemTextSizeAwareness == SystemTextSizeAwareness.Notify) - { - OnSystemTextSizeChanged(EventArgs.Empty); - } + OnSystemTextSizeChanged(EventArgs.Empty); } #endif } diff --git a/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs deleted file mode 100644 index 2c8ab20ca1f..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/SystemTextSizeAwareness.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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 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 index 5847634d332..50df93caf10 100644 --- 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 @@ -3,9 +3,6 @@ #nullable disable -using System.ComponentModel; -using Microsoft.DotNet.RemoteExecutor; - namespace System.Windows.Forms.Tests; public partial class ApplicationTests @@ -18,41 +15,15 @@ public void Application_SystemTextSize_GetReturnsExpected() } [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) + public void Application_SystemTextSizeChanged_AddRemove_Success() { - SystemTextSizeAwareness originalValue = Application.SystemTextSizeAwareness; + int callCount = 0; + EventHandler handler = (sender, e) => callCount++; - try - { - Application.SetSystemTextSizeAwareness(value); - Assert.Equal(value, Application.SystemTextSizeAwareness); + Application.SystemTextSizeChanged += handler; + Application.SystemTextSizeChanged -= handler; - 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)); + Assert.Equal(0, callCount); } #endif } From 34ed07935177533a567fe59f1b03da872acaa589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Tue, 14 Jul 2026 16:14:13 -0700 Subject: [PATCH 09/99] Fix system text size analyzer diagnostics Wrap the Form event remark in a paragraph and remove the redundant nullable handler initialization reported by the Arcade analyzers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40d0f0f7-a584-4167-9187-9d529b7c70f8 --- .../System/Windows/Forms/Application.SystemTextSize.cs | 2 +- .../System/Windows/Forms/Form.SystemTextSize.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs index 5db47e397bc..9fcf5d314a2 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs @@ -108,7 +108,7 @@ private static void OnSystemTextSizeUserPreferenceChanged(object? sender, UserPr return; } - EventHandler? handler = null; + EventHandler? handler; lock (s_internalSyncObject) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs index 2a7886e20df..266766186d1 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs @@ -16,7 +16,9 @@ public partial class Form /// Occurs on this top-level when the Windows Accessibility text-scale setting changes. /// /// - /// On operating systems earlier than Windows 10 version 1507, this event is not raised. + /// + /// On operating systems earlier than Windows 10 version 1507, this event is not raised. + /// /// [SRCategory(nameof(SR.CatLayout))] [SRDescription(nameof(SR.FormOnSystemTextSizeChangedDescr))] From a3b023e6932827c344ab23d8e97b468749fdc8cb Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 18 Jul 2026 03:03:07 -0700 Subject: [PATCH 10/99] Add system visual settings snapshot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 27022e2a-9eb8-4f58-9bfa-e79ca0d6c559 --- .../PublicAPI.Unshipped.txt | 30 +- 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/Application.SystemTextSize.cs | 127 ---- .../Forms/Application.SystemVisualSettings.cs | 52 ++ .../Forms/Application.WindowsAccentColor.cs | 52 -- .../System/Windows/Forms/Control.cs | 105 +++ .../Windows/Forms/Form.SystemTextSize.cs | 72 --- .../System/Windows/Forms/Form.cs | 5 - .../Windows/Forms/SystemVisualSettings.cs | 165 +++++ .../Forms/SystemVisualSettingsTracker.cs | 206 ++++++ .../Forms/ApplicationTests.SystemTextSize.cs | 29 - .../Windows/Forms/FormTests.SystemTextSize.cs | 38 -- .../Forms/SystemVisualSettingsTests.cs | 602 ++++++++++++++++++ 26 files changed, 1223 insertions(+), 396 deletions(-) delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs delete mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs delete mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTests.cs diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 5d58dcbc122..39228206624 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,5 +1,25 @@ -static System.Windows.Forms.Application.GetWindowsAccentColor() -> System.Drawing.Color -static System.Windows.Forms.Application.SystemTextSize.get -> double -static System.Windows.Forms.Application.SystemTextSizeChanged -> System.EventHandler? -System.Windows.Forms.Form.SystemTextSizeChanged -> System.EventHandler? -virtual System.Windows.Forms.Form.OnSystemTextSizeChanged(System.EventArgs! e) -> void \ No newline at end of file +static System.Windows.Forms.Application.SystemVisualSettings.get -> System.Windows.Forms.SystemVisualSettings! +static System.Windows.Forms.Application.SystemVisualSettingsChanged -> System.Windows.Forms.SystemVisualSettingsChangedEventHandler? +virtual System.Windows.Forms.Control.OnSystemVisualSettingsChanged(System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +System.Windows.Forms.Control.SystemVisualSettingsChanged -> System.Windows.Forms.SystemVisualSettingsChangedEventHandler? +System.Windows.Forms.SystemVisualSettings.AccentColor.get -> System.Drawing.Color +System.Windows.Forms.SystemVisualSettings.ClientAreaAnimationEnabled.get -> bool +System.Windows.Forms.SystemVisualSettings.FocusBorderMetrics.get -> System.Drawing.Size +System.Windows.Forms.SystemVisualSettings.HighContrastEnabled.get -> bool +System.Windows.Forms.SystemVisualSettings.KeyboardCuesVisible.get -> bool +System.Windows.Forms.SystemVisualSettings.TextScaleFactor.get -> float +System.Windows.Forms.SystemVisualSettings +System.Windows.Forms.SystemVisualSettingsCategories.AccentColor = 1 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.Animations = 8 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.FocusMetrics = 32 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.HighContrast = 4 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.KeyboardCues = 16 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.None = 0 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.TextScale = 2 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.Changed.get -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.NewSettings.get -> System.Windows.Forms.SystemVisualSettings! +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.OldSettings.get -> System.Windows.Forms.SystemVisualSettings! +System.Windows.Forms.SystemVisualSettingsChangedEventArgs +System.Windows.Forms.SystemVisualSettingsChangedEventHandler +virtual System.Windows.Forms.SystemVisualSettingsChangedEventHandler.Invoke(object? sender, System.Windows.Forms.SystemVisualSettingsChangedEventArgs! 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 d826674f28a..e088b4c248d 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -1224,6 +1224,9 @@ Event raised when the system colors change. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when the value of the TabIndex property is changed. @@ -6568,9 +6571,6 @@ 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 6ea1726da67..f5eae0f8219 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -2027,6 +2027,11 @@ Událost aktivovaná při změně systémových barev + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Událost aktivovaná při změně hodnoty vlastnosti TabIndex @@ -5600,11 +5605,6 @@ 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 02fafa68f4e..98985c6b0d7 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -2027,6 +2027,11 @@ Das ausgelöste Ereignis, wenn sich die Systemfarben ändern. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Das ausgelöste Ereignis, wenn der Wert der TabIndex-Eigenschaft geändert wird. @@ -5600,11 +5605,6 @@ 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 fd53492b44e..ce26d250d5e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -2027,6 +2027,11 @@ Evento que se desencadena cuando cambian los colores del sistema. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Evento que se desencadena cuando se cambia el valor de la propiedad TabIndex. @@ -5600,11 +5605,6 @@ 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 69cf8fcbf5a..ede5e848428 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -2027,6 +2027,11 @@ Événement déclenché lorsque les couleurs système changent. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Événement déclenché lorsque la valeur de la propriété TabIndex change. @@ -5600,11 +5605,6 @@ 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 eae22352b89..a8c55a43745 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -2027,6 +2027,11 @@ Evento generato alla modifica dei colori di sistema. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Generato quando il valore della proprietà TabIndex cambia. @@ -5600,11 +5605,6 @@ 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 3029942e7b5..af5e005a3a9 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -2027,6 +2027,11 @@ システム カラーが変更されるときに発生するイベントです。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex プロパティの値が変更されたときに発生するイベントです。 @@ -5600,11 +5605,6 @@ 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 d51a633204e..bef9c5b2361 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -2027,6 +2027,11 @@ 시스템 색이 변경되면 이벤트가 발생합니다. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 속성 값이 변경되면 이벤트가 발생합니다. @@ -5600,11 +5605,6 @@ 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 1d081c2fa93..750ec17f2e8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -2027,6 +2027,11 @@ Zdarzenie wywoływane, gdy kolory systemowe zostaną zmienione. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Zdarzenie wywoływane, gdy wartość właściwości TabIndex zostanie zmieniona. @@ -5600,11 +5605,6 @@ 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 92a16e85591..2352e4695d4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -2027,6 +2027,11 @@ Evento gerado quando as cores do sistema são alteradas. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Evento gerado quando o valor da propriedade TabIndex é alterado. @@ -5600,11 +5605,6 @@ 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 8ea7ed5da2b..312e166a491 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -2027,6 +2027,11 @@ Событие возникает при изменении системной палитры. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Событие возникает при изменении значения свойства TabIndex. @@ -5600,11 +5605,6 @@ 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 517ae78a795..808fec8f768 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -2027,6 +2027,11 @@ Sistem renkleri değiştiğinde harekete geçirilen olay. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex özelliğinin değeri değiştirildiğinde harekete geçirilen olay. @@ -5600,11 +5605,6 @@ 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 ad8da4c70db..9e8bf21cd37 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -2027,6 +2027,11 @@ 在系统颜色更改时引发的事件。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 属性值更改时引发的事件。 @@ -5600,11 +5605,6 @@ 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 739e72d38a4..8e890fb94e8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -2027,6 +2027,11 @@ 當系統色彩變更時引發的事件。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 屬性的值變更時引發的事件。 @@ -5600,11 +5605,6 @@ 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 deleted file mode 100644 index 9fcf5d314a2..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemTextSize.cs +++ /dev/null @@ -1,127 +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 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 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(); - - /// - /// Occurs once per process when the Windows Accessibility text-scale setting changes. - /// - /// - /// - /// 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. - /// - /// - /// On operating systems earlier than Windows 10 version 1507, this event is not raised. - /// - /// - public static event EventHandler? SystemTextSizeChanged - { - add - { - if (value is null) - { - return; - } - - lock (s_internalSyncObject) - { - EnsureSystemTextSizeNotificationsInitialized(); - AddEventHandler(s_eventSystemTextSizeChanged, value); - } - } - remove - { - if (value is null) - { - return; - } - - lock (s_internalSyncObject) - { - RemoveEventHandler(s_eventSystemTextSizeChanged, value); - - if (s_systemTextSizeNotificationsInitialized - && s_eventHandlers?[s_eventSystemTextSizeChanged] is null) - { - SystemEvents.UserPreferenceChanged -= OnSystemTextSizeUserPreferenceChanged; - s_systemTextSizeNotificationsInitialized = false; - } - } - } - } - - 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; - - lock (s_internalSyncObject) - { - if (systemTextSize == s_lastSystemTextSize) - { - return; - } - - s_lastSystemTextSize = systemTextSize; - handler = s_eventHandlers?[s_eventSystemTextSizeChanged] as EventHandler; - } - - handler?.Invoke(null, EventArgs.Empty); - } -#endif -} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs new file mode 100644 index 00000000000..a91d409c770 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.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. + +namespace System.Windows.Forms; + +public sealed partial class Application +{ + private static readonly object s_eventSystemVisualSettingsChanged = new(); + + /// + /// Gets the current Windows visual and accessibility settings snapshot. + /// + public static SystemVisualSettings SystemVisualSettings + => SystemVisualSettingsTracker.CurrentSettings; + + /// + /// Occurs when Windows visual or accessibility settings change. + /// + /// + /// + /// WinForms raises this event once for each settings transition, normalizing + /// WM_SETTINGCHANGE, WM_DWMCOLORIZATIONCOLORCHANGED, + /// WM_THEMECHANGED, and WM_SYSCOLORCHANGE. Handlers should use + /// to early-out when + /// their category did not change. + /// + /// + /// This static event is for application-lifetime consumers such as theming engines and + /// services. Components whose lifetime is shorter than the application must unsubscribe. + /// Controls and forms should use or + /// override + /// instead; that instance path requires no unsubscription. + /// + /// + public static event SystemVisualSettingsChangedEventHandler? SystemVisualSettingsChanged + { + add => AddEventHandler(s_eventSystemVisualSettingsChanged, value); + remove => RemoveEventHandler(s_eventSystemVisualSettingsChanged, value); + } + + internal static void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + { + SystemVisualSettingsChangedEventHandler? handler; + + lock (s_internalSyncObject) + { + handler = s_eventHandlers?[s_eventSystemVisualSettingsChanged] as SystemVisualSettingsChangedEventHandler; + } + + handler?.Invoke(null, e); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs deleted file mode 100644 index 0fbce5b214b..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs +++ /dev/null @@ -1,52 +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 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); - } - } -} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 14efc5014ff..f5555bccb3f 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_systemVisualSettingsChangedEvent = new(); private static MessageId s_threadCallbackMessage; private static ContextCallback? s_invokeMarshaledCallbackHelperDelegate; @@ -172,6 +173,7 @@ public unsafe partial class Control : private const byte RequiredScalingMask = 0x0F; private const byte HighOrderBitMask = 0x80; + private const uint WmDwmColorizationColorChanged = 0x0320; private static Font? s_defaultFont; @@ -227,6 +229,8 @@ public unsafe partial class Control : private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); + private static readonly int s_systemVisualSettingsProperty = PropertyStore.CreateKey(); + private static readonly int s_systemVisualSettingsRefreshPendingProperty = PropertyStore.CreateKey(); private static bool s_needToLoadComCtl = true; @@ -4188,6 +4192,24 @@ public event EventHandler? SystemColorsChanged remove => Events.RemoveHandler(s_systemColorsChangedEvent, value); } + /// + /// Occurs when a Windows visual or accessibility setting changes. + /// + /// + /// + /// This instance event is the leak-free default for controls and forms. Unlike + /// , it does not require + /// unsubscription when the control is disposed. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.ControlOnSystemVisualSettingsChangedDescr))] + public event SystemVisualSettingsChangedEventHandler? SystemVisualSettingsChanged + { + add => Events.AddHandler(s_systemVisualSettingsChangedEvent, value); + remove => Events.RemoveHandler(s_systemVisualSettingsChangedEvent, value); + } + /// /// Occurs when the control is validating. /// @@ -7292,6 +7314,8 @@ protected virtual void OnParentChanged(EventArgs e) { OnTopMostActiveXParentChanged(EventArgs.Empty); } + + ResynchronizeSystemVisualSettings(); } /// @@ -7420,6 +7444,8 @@ protected virtual void OnHandleCreated(EventArgs e) PInvokeCore.PostMessage(this, s_threadCallbackMessage); SetState(States.ThreadMarshalPending, false); } + + ResynchronizeSystemVisualSettings(); } void HandleHighDpi() @@ -8111,6 +8137,77 @@ protected virtual void OnSystemColorsChanged(EventArgs e) ((EventHandler?)Events[s_systemColorsChangedEvent])?.Invoke(this, e); } + /// + /// Raises the event. + /// + /// The event data. + /// + /// + /// The cascade reaches parented controls only. A control created before it is parented can + /// miss a transition and should query when + /// it creates a handle or is parented. + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + { + Properties.AddOrRemoveValue(s_systemVisualSettingsProperty, e.NewSettings); + + if (ChildControls is { } children) + { + for (int i = 0; i < children.Count; i++) + { + children[i].OnSystemVisualSettingsChanged(e); + } + } + + ((SystemVisualSettingsChangedEventHandler?)Events[s_systemVisualSettingsChangedEvent])?.Invoke(this, e); + } + + private void QueueSystemVisualSettingsRefresh() + { + if (!GetTopLevel() + || Disposing + || IsDisposed + || Properties.GetValueOrDefault(s_systemVisualSettingsRefreshPendingProperty)) + { + return; + } + + Properties.AddOrRemoveValue(s_systemVisualSettingsRefreshPendingProperty, true, defaultValue: false); + BeginInvoke((Action)ProcessSystemVisualSettingsChange); + } + + internal void ProcessSystemVisualSettingsChange() + { + Properties.AddOrRemoveValue(s_systemVisualSettingsRefreshPendingProperty, false, defaultValue: false); + + if (!GetTopLevel() || Disposing || IsDisposed) + { + return; + } + + SystemVisualSettings newSettings = SystemVisualSettingsTracker.Refresh(); + SystemVisualSettings? oldSettings = Properties.GetValueOrDefault(s_systemVisualSettingsProperty); + if (oldSettings is null) + { + ResynchronizeSystemVisualSettings(); + return; + } + + SystemVisualSettingsCategories changed = SystemVisualSettingsTracker.GetChangedCategories(oldSettings, newSettings); + if (changed != SystemVisualSettingsCategories.None) + { + OnSystemVisualSettingsChanged( + new SystemVisualSettingsChangedEventArgs(oldSettings, newSettings, changed)); + } + } + + private void ResynchronizeSystemVisualSettings() + => Properties.AddOrRemoveValue( + s_systemVisualSettingsProperty, + Application.SystemVisualSettings); + /// /// Raises the event. /// @@ -12649,6 +12746,7 @@ protected virtual void WndProc(ref Message m) } } + QueueSystemVisualSettingsRefresh(); break; case PInvokeCore.WM_SYSCOLORCHANGE: @@ -12657,6 +12755,13 @@ protected virtual void WndProc(ref Message m) OnSystemColorsChanged(EventArgs.Empty); } + QueueSystemVisualSettingsRefresh(); + break; + + case PInvokeCore.WM_THEMECHANGED: + case WmDwmColorizationColorChanged: + DefWndProc(ref m); + QueueSystemVisualSettingsRefresh(); break; case PInvokeCore.WM_EXITMENULOOP: diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs deleted file mode 100644 index 266766186d1..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.SystemTextSize.cs +++ /dev/null @@ -1,72 +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.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. - /// - /// - /// - /// On operating systems earlier than Windows 10 version 1507, this event is not raised. - /// - /// - [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; - 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 a924b13fb3b..c3900d09c6b 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -7225,11 +7225,6 @@ 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/SystemVisualSettings.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.cs new file mode 100644 index 00000000000..14bcebd5ca5 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.cs @@ -0,0 +1,165 @@ +// 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; + +/// +/// Represents an immutable snapshot of visual and accessibility settings that Windows owns. +/// +/// +/// +/// Applications must not override these values. Renderers can combine this snapshot with their +/// own state to honor the user's Windows personalization and accessibility preferences. +/// +/// +public sealed class SystemVisualSettings +{ + internal SystemVisualSettings( + Color accentColor, + float textScaleFactor, + bool highContrastEnabled, + bool clientAreaAnimationEnabled, + bool keyboardCuesVisible, + Size focusBorderMetrics) + { + AccentColor = accentColor; + TextScaleFactor = textScaleFactor; + HighContrastEnabled = highContrastEnabled; + ClientAreaAnimationEnabled = clientAreaAnimationEnabled; + KeyboardCuesVisible = keyboardCuesVisible; + FocusBorderMetrics = focusBorderMetrics; + } + + /// + /// Gets the user's current Windows accent color. + /// + public Color AccentColor { get; } + + /// + /// Gets the Windows Accessibility text-scale factor. + /// + /// + /// + /// The factor is independent of display DPI and is in the range 1.0 through 2.25. + /// + /// + public float TextScaleFactor { get; } + + /// + /// Gets a value indicating whether the user has enabled Windows high-contrast mode. + /// + public bool HighContrastEnabled { get; } + + /// + /// Gets a value indicating whether Windows enables client-area animations. + /// + public bool ClientAreaAnimationEnabled { get; } + + /// + /// Gets a value indicating whether the system default displays keyboard cues. + /// + /// + /// + /// This is the system default. A window can temporarily override the effective cue state + /// through WM_UPDATEUISTATE. + /// + /// + public bool KeyboardCuesVisible { get; } + + /// + /// Gets the Windows focus-border width and height, in pixels. + /// + /// + /// + /// Renderers can use these metrics as a baseline for border and focus prominence, scaling + /// them for DPI and rather than relying on fixed constants. + /// + /// + public Size FocusBorderMetrics { get; } +} + +/// +/// Specifies the categories that changed in a transition. +/// +[Flags] +public enum SystemVisualSettingsCategories +{ + /// + /// No visual settings changed. + /// + None = 0, + + /// + /// The Windows accent color changed. + /// + AccentColor = 1 << 0, + + /// + /// The Windows Accessibility text-scale factor changed. + /// + TextScale = 1 << 1, + + /// + /// The Windows high-contrast setting changed. + /// + HighContrast = 1 << 2, + + /// + /// The Windows client-area animation setting changed. + /// + Animations = 1 << 3, + + /// + /// The Windows keyboard-cue default changed. + /// + KeyboardCues = 1 << 4, + + /// + /// The Windows focus-border metrics changed. + /// + FocusMetrics = 1 << 5 +} + +/// +/// Provides data for a or +/// event. +/// +public class SystemVisualSettingsChangedEventArgs : EventArgs +{ + internal SystemVisualSettingsChangedEventArgs( + SystemVisualSettings oldSettings, + SystemVisualSettings newSettings, + SystemVisualSettingsCategories changed) + { + OldSettings = oldSettings; + NewSettings = newSettings; + Changed = changed; + } + + /// + /// Gets the settings before the transition. + /// + public SystemVisualSettings OldSettings { get; } + + /// + /// Gets the settings after the transition. + /// + public SystemVisualSettings NewSettings { get; } + + /// + /// Gets the categories that changed in the transition. + /// + public SystemVisualSettingsCategories Changed { get; } +} + +/// +/// Represents the method that handles a system visual settings change. +/// +/// +/// The source of the event, or for +/// . +/// +/// The event data. +public delegate void SystemVisualSettingsChangedEventHandler(object? sender, SystemVisualSettingsChangedEventArgs e); diff --git a/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs new file mode 100644 index 00000000000..4a0dc98b730 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs @@ -0,0 +1,206 @@ +// 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.System.WinRT; +using Windows.Win32.UI.Accessibility; +using Windows.Win32.UI.ViewManagement; +using static Windows.Win32.UI.WindowsAndMessaging.SYSTEM_PARAMETERS_INFO_ACTION; + +namespace System.Windows.Forms; + +/// +/// Maintains the process-wide system visual settings snapshot. +/// +internal static class SystemVisualSettingsTracker +{ + private static Func s_snapshotProvider = GetNativeSnapshot; + private static SystemVisualSettings s_currentSettings = GetNativeSnapshot(); + private static long s_transitionVersion; + + /// + /// Gets the current immutable snapshot. + /// + internal static SystemVisualSettings CurrentSettings + => Volatile.Read(ref s_currentSettings); + + /// + /// Gets the version of the latest settings transition. + /// + internal static long TransitionVersion + => Interlocked.Read(ref s_transitionVersion); + + /// + /// Reads the settings source and publishes an event for an effective transition. + /// + internal static SystemVisualSettings Refresh() + { + while (true) + { + SystemVisualSettings current = CurrentSettings; + SystemVisualSettings next = Volatile.Read(ref s_snapshotProvider).Invoke(); + SystemVisualSettingsCategories changed = GetChangedCategories(current, next); + if (changed == SystemVisualSettingsCategories.None) + { + return current; + } + + if (ReferenceEquals(Interlocked.CompareExchange(ref s_currentSettings, next, current), current)) + { + Interlocked.Increment(ref s_transitionVersion); + Application.OnSystemVisualSettingsChanged( + new SystemVisualSettingsChangedEventArgs(current, next, changed)); + + return next; + } + } + } + + /// + /// Gets the categories that differ between two snapshots. + /// + internal static SystemVisualSettingsCategories GetChangedCategories( + SystemVisualSettings oldSettings, + SystemVisualSettings newSettings) + { + SystemVisualSettingsCategories changed = SystemVisualSettingsCategories.None; + + if (oldSettings.AccentColor != newSettings.AccentColor) + { + changed |= SystemVisualSettingsCategories.AccentColor; + } + + if (oldSettings.TextScaleFactor != newSettings.TextScaleFactor) + { + changed |= SystemVisualSettingsCategories.TextScale; + } + + if (oldSettings.HighContrastEnabled != newSettings.HighContrastEnabled) + { + changed |= SystemVisualSettingsCategories.HighContrast; + } + + if (oldSettings.ClientAreaAnimationEnabled != newSettings.ClientAreaAnimationEnabled) + { + changed |= SystemVisualSettingsCategories.Animations; + } + + if (oldSettings.KeyboardCuesVisible != newSettings.KeyboardCuesVisible) + { + changed |= SystemVisualSettingsCategories.KeyboardCues; + } + + if (oldSettings.FocusBorderMetrics != newSettings.FocusBorderMetrics) + { + changed |= SystemVisualSettingsCategories.FocusMetrics; + } + + return changed; + } + + /// + /// Creates a snapshot from values returned by the native settings source. + /// + internal static SystemVisualSettings CreateSnapshot(SystemVisualSettingsNativeValues values) + => new( + values.AccentColor, + values.TextScaleFactor, + values.HighContrastEnabled, + values.ClientAreaAnimationEnabled, + values.KeyboardCuesVisible, + values.FocusBorderMetrics); + + /// + /// Sets a deterministic settings source for unit tests. + /// + internal static void ResetForTesting( + SystemVisualSettings currentSettings, + Func? snapshotProvider = null) + { + ArgumentNullException.ThrowIfNull(currentSettings); + + Volatile.Write(ref s_snapshotProvider, snapshotProvider ?? GetNativeSnapshot); + Interlocked.Exchange(ref s_currentSettings, currentSettings); + Interlocked.Exchange(ref s_transitionVersion, 0); + } + + /// + /// Restores the native settings source after a unit test. + /// + internal static void ResetForTesting() + { + SystemVisualSettings nativeSettings = GetNativeSnapshot(); + ResetForTesting(nativeSettings); + } + + private static SystemVisualSettings GetNativeSnapshot() + { + HIGHCONTRASTW highContrast = default; + bool highContrastEnabled = PInvokeCore.SystemParametersInfo(ref highContrast) + && highContrast.dwFlags.HasFlag(HIGHCONTRASTW_FLAGS.HCF_HIGHCONTRASTON); + + SystemVisualSettingsNativeValues values = new( + GetWindowsAccentColor(), + (float)ScaleHelper.GetSystemTextScaleFactor(), + highContrastEnabled, + PInvokeCore.SystemParametersInfoBool(SPI_GETCLIENTAREAANIMATION), + PInvokeCore.SystemParametersInfoBool(SPI_GETKEYBOARDCUES), + new Size( + PInvokeCore.SystemParametersInfoInt(SPI_GETFOCUSBORDERWIDTH), + PInvokeCore.SystemParametersInfoInt(SPI_GETFOCUSBORDERHEIGHT))); + + return CreateSnapshot(values); + } + + private static Color GetWindowsAccentColor() + { + try + { + return GetWindowsAccentColorCore(); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + Debug.Fail($"Unable to query the Windows accent color: {ex.Message}"); + return SystemColors.Highlight; + } + } + + private static unsafe Color GetWindowsAccentColorCore() + { + 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); + } + } +} + +/// +/// Contains values obtained from the native Windows settings APIs. +/// +internal readonly record struct SystemVisualSettingsNativeValues( + Color AccentColor, + float TextScaleFactor, + bool HighContrastEnabled, + bool ClientAreaAnimationEnabled, + bool KeyboardCuesVisible, + Size FocusBorderMetrics); 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 deleted file mode 100644 index 50df93caf10..00000000000 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.SystemTextSize.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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 ApplicationTests -{ -#if NET11_0_OR_GREATER - [WinFormsFact] - public void Application_SystemTextSize_GetReturnsExpected() - { - Assert.InRange(Application.SystemTextSize, 1.0, 2.25); - } - - [WinFormsFact] - public void Application_SystemTextSizeChanged_AddRemove_Success() - { - int callCount = 0; - EventHandler handler = (sender, e) => callCount++; - - Application.SystemTextSizeChanged += handler; - Application.SystemTextSizeChanged -= handler; - - Assert.Equal(0, callCount); - } -#endif -} 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 deleted file mode 100644 index 9209f9a36f8..00000000000 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.SystemTextSize.cs +++ /dev/null @@ -1,38 +0,0 @@ -// 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/SystemVisualSettingsTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTests.cs new file mode 100644 index 00000000000..d91aacc5695 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTests.cs @@ -0,0 +1,602 @@ +// 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.Runtime.CompilerServices; + +namespace System.Windows.Forms.Tests; + +[Collection("Sequential")] +public class SystemVisualSettingsTests +{ + private const int WmDwmColorizationColorChanged = 0x0320; + + [WinFormsFact] + public void SystemVisualSettings_Snapshot_ValuesAreImmutable() + { + SystemVisualSettings settings = CreateSettings( + accentColor: Color.MediumPurple, + textScaleFactor: 1.5f, + highContrastEnabled: true, + clientAreaAnimationEnabled: false, + keyboardCuesVisible: true, + focusBorderMetrics: new Size(3, 4)); + + Assert.Equal(Color.MediumPurple, settings.AccentColor); + Assert.Equal(1.5f, settings.TextScaleFactor); + Assert.True(settings.HighContrastEnabled); + Assert.False(settings.ClientAreaAnimationEnabled); + Assert.True(settings.KeyboardCuesVisible); + Assert.Equal(new Size(3, 4), settings.FocusBorderMetrics); + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_CreateSnapshot_MapsNativeValues() + { + SystemVisualSettingsNativeValues values = new( + Color.CornflowerBlue, + 1.25f, + true, + false, + true, + new Size(2, 5)); + + SystemVisualSettings snapshot = SystemVisualSettingsTracker.CreateSnapshot(values); + + Assert.Equal(values.AccentColor, snapshot.AccentColor); + Assert.Equal(values.TextScaleFactor, snapshot.TextScaleFactor); + Assert.Equal(values.HighContrastEnabled, snapshot.HighContrastEnabled); + Assert.Equal(values.ClientAreaAnimationEnabled, snapshot.ClientAreaAnimationEnabled); + Assert.Equal(values.KeyboardCuesVisible, snapshot.KeyboardCuesVisible); + Assert.Equal(values.FocusBorderMetrics, snapshot.FocusBorderMetrics); + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_GetChangedCategories_ReturnsAllChangedCategories() + { + SystemVisualSettings oldSettings = CreateSettings(); + SystemVisualSettings newSettings = CreateSettings( + accentColor: Color.Crimson, + textScaleFactor: 1.5f, + highContrastEnabled: true, + clientAreaAnimationEnabled: false, + keyboardCuesVisible: true, + focusBorderMetrics: new Size(2, 3)); + + SystemVisualSettingsCategories changed = SystemVisualSettingsTracker.GetChangedCategories(oldSettings, newSettings); + + Assert.Equal( + SystemVisualSettingsCategories.AccentColor + | SystemVisualSettingsCategories.TextScale + | SystemVisualSettingsCategories.HighContrast + | SystemVisualSettingsCategories.Animations + | SystemVisualSettingsCategories.KeyboardCues + | SystemVisualSettingsCategories.FocusMetrics, + changed); + } + + [WinFormsFact] + public void Application_SystemVisualSettingsChanged_RefreshRaisesOncePerTransition() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings(textScaleFactor: 1.5f); + SystemVisualSettingsChangedEventHandler? handler = null; + int callCount = 0; + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + handler = (sender, e) => + { + Assert.Null(sender); + Assert.Same(initial, e.OldSettings); + Assert.Same(next, e.NewSettings); + Assert.Equal(SystemVisualSettingsCategories.TextScale, e.Changed); + callCount++; + }; + + Application.SystemVisualSettingsChanged += handler; + + Assert.Same(initial, Application.SystemVisualSettings); + Assert.Same(next, SystemVisualSettingsTracker.Refresh()); + Assert.Same(next, SystemVisualSettingsTracker.Refresh()); + Assert.Equal(1, callCount); + Assert.Equal(1, SystemVisualSettingsTracker.TransitionVersion); + } + finally + { + if (handler is not null) + { + Application.SystemVisualSettingsChanged -= handler; + } + + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public async Task SystemVisualSettingsTracker_ConcurrentRefresh_DoesNotPublishStaleSnapshot() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings stale = CreateSettings(accentColor: Color.Orange); + SystemVisualSettings current = CreateSettings(accentColor: Color.Purple); + using ManualResetEventSlim firstSampleStarted = new(initialState: false); + using ManualResetEventSlim releaseFirstSample = new(initialState: false); + int providerCallCount = 0; + + try + { + SystemVisualSettingsTracker.ResetForTesting( + initial, + () => + { + if (Interlocked.Increment(ref providerCallCount) == 1) + { + firstSampleStarted.Set(); + releaseFirstSample.Wait(TestContext.Current.CancellationToken); + return stale; + } + + return current; + }); + + Task staleRefresh = Task.Run( + SystemVisualSettingsTracker.Refresh, + TestContext.Current.CancellationToken); + Assert.True(firstSampleStarted.Wait(TimeSpan.FromSeconds(10))); + + Task currentRefresh = Task.Run( + SystemVisualSettingsTracker.Refresh, + TestContext.Current.CancellationToken); + Assert.Same(current, await currentRefresh); + releaseFirstSample.Set(); + Assert.Same(current, await staleRefresh); + + Assert.Same(current, SystemVisualSettingsTracker.CurrentSettings); + } + finally + { + releaseFirstSample.Set(); + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void Control_SystemVisualSettingsChanged_CascadesToChildren() + { + SystemVisualSettingsChangedEventArgs eventArgs = new( + CreateSettings(), + CreateSettings(textScaleFactor: 1.5f), + SystemVisualSettingsCategories.TextScale); + using SubSystemVisualSettingsControl parent = new(); + using Control child = new(); + parent.Controls.Add(child); + + int parentCallCount = 0; + int childCallCount = 0; + parent.SystemVisualSettingsChanged += (sender, e) => + { + Assert.Same(parent, sender); + Assert.Same(eventArgs, e); + parentCallCount++; + }; + child.SystemVisualSettingsChanged += (sender, e) => + { + Assert.Same(child, sender); + Assert.Same(eventArgs, e); + childCallCount++; + }; + + parent.RaiseSystemVisualSettingsChanged(eventArgs); + + Assert.Equal(1, parentCallCount); + Assert.Equal(1, childCallCount); + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_ProcessTopLevelTrees_DeduplicatesApplicationAndDeliversOnOwningThread() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings( + highContrastEnabled: true, + clientAreaAnimationEnabled: false); + SystemVisualSettingsChangedEventHandler? applicationHandler = null; + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + using Form first = new(); + using Form second = new(); + using Control firstChild = new(); + using Control secondChild = new(); + first.Controls.Add(firstChild); + second.Controls.Add(secondChild); + _ = first.Handle; + _ = second.Handle; + + int applicationCallCount = 0; + int firstTreeCallCount = 0; + int secondTreeCallCount = 0; + int owningThreadId = Environment.CurrentManagedThreadId; + + applicationHandler = (sender, e) => + { + Assert.Null(sender); + Assert.Equal( + SystemVisualSettingsCategories.HighContrast | SystemVisualSettingsCategories.Animations, + e.Changed); + applicationCallCount++; + }; + Application.SystemVisualSettingsChanged += applicationHandler; + + firstChild.SystemVisualSettingsChanged += (sender, e) => + { + Assert.Same(firstChild, sender); + Assert.Equal(owningThreadId, Environment.CurrentManagedThreadId); + firstTreeCallCount++; + }; + secondChild.SystemVisualSettingsChanged += (sender, e) => + { + Assert.Same(secondChild, sender); + Assert.Equal(owningThreadId, Environment.CurrentManagedThreadId); + secondTreeCallCount++; + }; + + first.ProcessSystemVisualSettingsChange(); + second.ProcessSystemVisualSettingsChange(); + + Assert.Equal(1, applicationCallCount); + Assert.Equal(1, firstTreeCallCount); + Assert.Equal(1, secondTreeCallCount); + } + finally + { + if (applicationHandler is not null) + { + Application.SystemVisualSettingsChanged -= applicationHandler; + } + + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_RawMessagesAreCoalescedForTopLevel() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings(accentColor: Color.OrangeRed); + SystemVisualSettingsChangedEventHandler? applicationHandler = null; + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + using SubSystemVisualSettingsForm form = new(); + _ = form.Handle; + int applicationCallCount = 0; + int controlCallCount = 0; + applicationHandler = (sender, e) => applicationCallCount++; + Application.SystemVisualSettingsChanged += applicationHandler; + form.SystemVisualSettingsChanged += (sender, e) => controlCallCount++; + + form.Dispatch(PInvokeCore.WM_SETTINGCHANGE); + form.Dispatch(PInvokeCore.WM_THEMECHANGED); + form.Dispatch(WmDwmColorizationColorChanged); + form.Dispatch(PInvokeCore.WM_SYSCOLORCHANGE); + Application.DoEvents(); + + Assert.Equal(1, applicationCallCount); + Assert.Equal(1, controlCallCount); + } + finally + { + if (applicationHandler is not null) + { + Application.SystemVisualSettingsChanged -= applicationHandler; + } + + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_ProcessTopLevelTree_DeliversOnItsOwningUiThread() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings(keyboardCuesVisible: true); + SystemVisualSettingsChangedEventHandler? applicationHandler = null; + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + using UiThreadTree tree = new(); + int applicationCallCount = 0; + applicationHandler = (sender, e) => applicationCallCount++; + Application.SystemVisualSettingsChanged += applicationHandler; + + tree.ProcessSystemVisualSettingsChange(); + + Assert.True(tree.WaitForDelivery(TimeSpan.FromSeconds(10))); + Assert.Equal(tree.ThreadId, tree.DeliveryThreadId); + Assert.Equal(1, applicationCallCount); + } + finally + { + if (applicationHandler is not null) + { + Application.SystemVisualSettingsChanged -= applicationHandler; + } + + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void SystemVisualSettingsTracker_MultipleUiThreads_DeduplicatesApplicationAndDeliversToEveryTree() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings( + accentColor: Color.MediumOrchid, + keyboardCuesVisible: true); + SystemVisualSettingsChangedEventHandler? applicationHandler = null; + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + using UiThreadTree first = new(); + using UiThreadTree second = new(); + int applicationCallCount = 0; + applicationHandler = (sender, e) => Interlocked.Increment(ref applicationCallCount); + Application.SystemVisualSettingsChanged += applicationHandler; + + first.ProcessSystemVisualSettingsChange(); + second.ProcessSystemVisualSettingsChange(); + + Assert.True(first.WaitForDelivery(TimeSpan.FromSeconds(10))); + Assert.True(second.WaitForDelivery(TimeSpan.FromSeconds(10))); + Assert.NotEqual(first.ThreadId, second.ThreadId); + Assert.Equal(first.ThreadId, first.DeliveryThreadId); + Assert.Equal(second.ThreadId, second.DeliveryThreadId); + Assert.Equal(1, first.DeliveryCount); + Assert.Equal(1, second.DeliveryCount); + Assert.Equal(1, Volatile.Read(ref applicationCallCount)); + } + finally + { + if (applicationHandler is not null) + { + Application.SystemVisualSettingsChanged -= applicationHandler; + } + + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void Control_SystemVisualSettingsChanged_HandleAndParentResynchronizationDoesNotRaiseSyntheticTransition() + { + SystemVisualSettings initial = CreateSettings(); + SystemVisualSettings next = CreateSettings(accentColor: Color.DarkOrange); + + try + { + SystemVisualSettingsTracker.ResetForTesting(initial, () => next); + SystemVisualSettingsTracker.Refresh(); + + using Form parent = new(); + using Control detachedControl = new(); + int callCount = 0; + detachedControl.SystemVisualSettingsChanged += (sender, e) => callCount++; + + parent.Controls.Add(detachedControl); + _ = parent.Handle; + parent.ProcessSystemVisualSettingsChange(); + + Assert.Equal(0, callCount); + } + finally + { + SystemVisualSettingsTracker.ResetForTesting(); + } + } + + [WinFormsFact] + public void Control_SystemVisualSettingsChanged_InstanceSubscriptionDoesNotRootControl() + { + WeakReference[] weakReferences = Enumerable.Range(0, 10) + .Select(_ => CreateDisposedFormWithInstanceSubscription()) + .ToArray(); + + CollectGarbage(); + + Assert.All(weakReferences, weakReference => Assert.False(weakReference.IsAlive)); + } + + [WinFormsFact] + public void Application_SystemVisualSettingsChanged_StaticSubscriptionRootsSubscriberUntilRemoved() + { + (WeakReference subscriberReference, WeakReference handlerReference) + = CreateStaticSubscription(); + + CollectGarbage(); + + Assert.True(subscriberReference.IsAlive); + + RemoveStaticSubscription(handlerReference); + CollectGarbage(); + + Assert.False(subscriberReference.IsAlive); + } + + private static SystemVisualSettings CreateSettings( + Color? accentColor = null, + float textScaleFactor = 1.0f, + bool highContrastEnabled = false, + bool clientAreaAnimationEnabled = true, + bool keyboardCuesVisible = false, + Size? focusBorderMetrics = null) + => new( + accentColor ?? Color.DodgerBlue, + textScaleFactor, + highContrastEnabled, + clientAreaAnimationEnabled, + keyboardCuesVisible, + focusBorderMetrics ?? new Size(1, 1)); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference CreateDisposedFormWithInstanceSubscription() + { + SubSystemVisualSettingsForm form = new(); + form.SystemVisualSettingsChanged += form.OnInstanceSystemVisualSettingsChanged; + _ = form.Handle; + form.Dispose(); + + return new WeakReference(form); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static ( + WeakReference SubscriberReference, + WeakReference HandlerReference) + CreateStaticSubscription() + { + StaticSubscriber subscriber = new(); + SystemVisualSettingsChangedEventHandler handler = subscriber.OnSystemVisualSettingsChanged; + Application.SystemVisualSettingsChanged += handler; + + return (new WeakReference(subscriber), new WeakReference(handler)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void RemoveStaticSubscription( + WeakReference handlerReference) + { + Assert.True(handlerReference.TryGetTarget(out SystemVisualSettingsChangedEventHandler? handler)); + Application.SystemVisualSettingsChanged -= handler; + } + + private static void CollectGarbage() + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true); + } + + private sealed class StaticSubscriber + { + public void OnSystemVisualSettingsChanged(object? sender, SystemVisualSettingsChangedEventArgs e) + { + } + } + + private sealed class SubSystemVisualSettingsControl : Control + { + public void RaiseSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + => base.OnSystemVisualSettingsChanged(e); + + public void OnInstanceSystemVisualSettingsChanged(object? sender, SystemVisualSettingsChangedEventArgs e) + { + } + } + + private sealed class SubSystemVisualSettingsForm : Form + { + public void OnInstanceSystemVisualSettingsChanged( + object? sender, + SystemVisualSettingsChangedEventArgs e) + { + } + + internal void Dispatch(uint message) + { + Message messageToDispatch = Message.Create(Handle, (int)message, IntPtr.Zero, IntPtr.Zero); + base.WndProc(ref messageToDispatch); + } + + internal void Dispatch(int message) + { + Message messageToDispatch = Message.Create(Handle, message, IntPtr.Zero, IntPtr.Zero); + base.WndProc(ref messageToDispatch); + } + } + + private sealed class UiThreadTree : IDisposable + { + private readonly ManualResetEventSlim _ready = new(initialState: false); + private readonly ManualResetEventSlim _delivered = new(initialState: false); + private readonly Thread _thread; + private Exception? _exception; + private Form? _form; + + internal UiThreadTree() + { + _thread = new(ThreadProc) + { + IsBackground = true + }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + + if (!_ready.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("The UI thread did not start."); + } + + if (_exception is not null) + { + throw new InvalidOperationException("The UI thread failed to start.", _exception); + } + } + + internal int ThreadId { get; private set; } + + internal int DeliveryThreadId { get; private set; } + + internal int DeliveryCount { get; private set; } + + internal void ProcessSystemVisualSettingsChange() + => _form!.BeginInvoke((Action)_form.ProcessSystemVisualSettingsChange); + + internal bool WaitForDelivery(TimeSpan timeout) + => _delivered.Wait(timeout); + + public void Dispose() + { + if (_form is { IsDisposed: false } form) + { + form.BeginInvoke((Action)form.Close); + } + + _thread.Join(TimeSpan.FromSeconds(10)); + _ready.Dispose(); + _delivered.Dispose(); + } + + private void ThreadProc() + { + try + { + using Form form = new(); + using Control child = new(); + _form = form; + form.Controls.Add(child); + child.SystemVisualSettingsChanged += OnSystemVisualSettingsChanged; + _ = form.Handle; + ThreadId = Environment.CurrentManagedThreadId; + _ready.Set(); + Application.Run(form); + } + catch (Exception exception) + { + _exception = exception; + _ready.Set(); + } + } + + private void OnSystemVisualSettingsChanged(object? sender, SystemVisualSettingsChangedEventArgs e) + { + DeliveryThreadId = Environment.CurrentManagedThreadId; + DeliveryCount++; + _delivered.Set(); + } + } +} From 6f95d46c6bf1cebadd39e8d5430840c02193c980 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 11/99] Add TreeView.NodeLeading API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 6 +- 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, 374 insertions(+), 17 deletions(-) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 39228206624..c0a7ae53d19 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -22,4 +22,8 @@ System.Windows.Forms.SystemVisualSettingsChangedEventArgs.NewSettings.get -> Sys System.Windows.Forms.SystemVisualSettingsChangedEventArgs.OldSettings.get -> System.Windows.Forms.SystemVisualSettings! System.Windows.Forms.SystemVisualSettingsChangedEventArgs System.Windows.Forms.SystemVisualSettingsChangedEventHandler -virtual System.Windows.Forms.SystemVisualSettingsChangedEventHandler.Invoke(object? sender, System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void \ No newline at end of file +virtual System.Windows.Forms.SystemVisualSettingsChangedEventHandler.Invoke(object? sender, System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +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 e088b4c248d..6cdc9b510a1 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -6322,6 +6322,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. @@ -6337,6 +6340,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 f5eae0f8219..9d607913bd9 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 98985c6b0d7..d63b610e6a3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 ce26d250d5e..c91522077b3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 ede5e848428..04a05be6ebb 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 a8c55a43745..9289baf9279 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 af5e005a3a9..181de4c9080 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -11024,6 +11024,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. ノードがマウスでクリックされたときに発生します。 @@ -11049,6 +11054,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 bef9c5b2361..0a8d9f78487 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -11024,6 +11024,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. 마우스로 노드를 클릭할 때 발생합니다. @@ -11049,6 +11054,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 750ec17f2e8..07f14905399 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 2352e4695d4..b15ecebb4a4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -11024,6 +11024,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ó. @@ -11049,6 +11054,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 312e166a491..7122f0f61d7 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -11024,6 +11024,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. Возникает при щелчке мыши на узле. @@ -11049,6 +11054,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 808fec8f768..fdd658655c4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -11024,6 +11024,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. @@ -11049,6 +11054,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 9e8bf21cd37..e59ab528bda 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -11024,6 +11024,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. 用鼠标单击节点时发生。 @@ -11049,6 +11054,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 8e890fb94e8..adb1718b478 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -11024,6 +11024,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. 以滑鼠按一下節點時發生。 @@ -11049,6 +11054,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 7a0f1e862c092183697a45db39f3e59ddb1ff6c4 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 12/99] 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 df6eb1584802f428f9463921ff491b815360826e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Fri, 10 Jul 2026 01:38:48 -0700 Subject: [PATCH 13/99] Cover NodeLeading DPI resynchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 20883488-cf1e-4ad3-a681-0724e9f16777 --- .../System/Windows/Forms/TreeViewTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 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 74f0944d9b5..7dff39ca7ef 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 @@ -2933,6 +2933,27 @@ public void TreeView_NodeLeading_SetFontWithHandle_UpdatesItemHeight() Assert.Equal(GetExpectedNodeLeadingItemHeight(updatedFont, 1.25f), control.ItemHeight); } + [WinFormsFact] + public void TreeView_NodeLeading_OnDpiChangedAfterParentWithHandle_UpdatesItemHeight() + { + using SubTreeView control = new() + { + NodeLeading = 1.25f + }; + Assert.NotEqual(IntPtr.Zero, control.Handle); + int expectedItemHeight = GetExpectedNodeLeadingItemHeight(control.Font, control.NodeLeading); + + PInvokeCore.SendMessage( + control, + PInvoke.TVM_SETITEMHEIGHT, + (WPARAM)(expectedItemHeight + 1)); + Assert.Equal(expectedItemHeight + 1, control.ItemHeight); + + control.OnDpiChangedAfterParent(EventArgs.Empty); + + Assert.Equal(expectedItemHeight, control.ItemHeight); + } + [WinFormsFact] public void TreeView_ResetNodeLeading_Invoke_Success() { @@ -7903,6 +7924,10 @@ private class SubTreeView : TreeView public new void OnDrawNode(DrawTreeNodeEventArgs e) => base.OnDrawNode(e); +#if NET11_0_OR_GREATER + public new void OnDpiChangedAfterParent(EventArgs e) => base.OnDpiChangedAfterParent(e); +#endif + public new void OnItemDrag(ItemDragEventArgs e) => base.OnItemDrag(e); public new void OnKeyDown(KeyEventArgs e) => base.OnKeyDown(e); From 39ce1d7fd31ec9cddc15e773399bbb5d7ab0456c Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 13:53:19 -0700 Subject: [PATCH 14/99] 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 f7ebead586b545a97f555d43657c7ee17e8ba205 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:34 -0700 Subject: [PATCH 15/99] 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 efb62e0f5192282beb665b6006f625f867fd6dfb Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:45 -0700 Subject: [PATCH 16/99] 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 | 50 +++- .../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, 804 insertions(+), 9 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 c0a7ae53d19..468995103b3 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -26,4 +26,52 @@ virtual System.Windows.Forms.SystemVisualSettingsChangedEventHandler.Invoke(obje 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 +virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) -> void +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 f5555bccb3f..7c71b88eb22 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -231,6 +231,10 @@ public unsafe partial class Control : private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_systemVisualSettingsProperty = PropertyStore.CreateKey(); private static readonly int s_systemVisualSettingsRefreshPendingProperty = 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; @@ -272,7 +276,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; @@ -4401,12 +4404,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); } /// @@ -5092,11 +5099,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) @@ -5371,7 +5384,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 f0b430d769dbe2cacab776c6c2d0b3453e7dab3a Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:49 -0700 Subject: [PATCH 17/99] 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 bf2346ae060..0a44740b336 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 63368830ba88f839781124d9b4d1681f9eaf3735 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 18/99] 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 468995103b3..42af3558910 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -59,18 +59,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 b26c9aeb83528baea16ce9fdc1e3f49040048db8 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 19/99] 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 42af3558910..3f5e12fc102 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -28,8 +28,8 @@ System.Windows.Forms.TreeView.NodeLeading.set -> void System.Windows.Forms.TreeView.NodeLeadingChanged -> System.EventHandler? virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) -> void 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 @@ -41,25 +41,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 6cdc9b510a1..265c5f0ca61 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -3289,6 +3289,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 9d607913bd9..8449567c29d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -5425,6 +5425,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 d63b610e6a3..cebc50400ec 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -5425,6 +5425,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 c91522077b3..1555f67cdce 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -5425,6 +5425,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 04a05be6ebb..5912e47c5ee 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -5425,6 +5425,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 9289baf9279..abeeb105208 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -5425,6 +5425,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 181de4c9080..1e3cdc4d527 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -5425,6 +5425,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 0a8d9f78487..4d1a3a6990e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -5425,6 +5425,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 07f14905399..9ed87cef8e4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -5425,6 +5425,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 b15ecebb4a4..060d9859606 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -5425,6 +5425,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 7122f0f61d7..d54ab4f7aee 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -5425,6 +5425,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 fdd658655c4..8824960ed15 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -5425,6 +5425,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 e59ab528bda..54b1010811d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -5425,6 +5425,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 adb1718b478..3285d9f42b2 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -5425,6 +5425,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 0a44740b336..5aeb7d2f2e4 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 35d21a56d049cbe590d6b9fe527ec6eeac9eab12 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 20/99] 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 c664e845516f55b580e870db55a6888793a27ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Fri, 10 Jul 2026 01:02:27 -0700 Subject: [PATCH 21/99] Fix FormRevealMode public API tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 20883488-cf1e-4ad3-a681-0724e9f16777 --- src/System.Windows.Forms/PublicAPI.Unshipped.txt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 3f5e12fc102..869425f33df 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -30,9 +30,10 @@ virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) 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.FormRevealMode +System.Windows.Forms.FormRevealMode.Classic = 0 -> System.Windows.Forms.FormRevealMode +System.Windows.Forms.FormRevealMode.Deferred = 1 -> System.Windows.Forms.FormRevealMode +System.Windows.Forms.FormRevealMode.Inherit = -1 -> System.Windows.Forms.FormRevealMode System.Windows.Forms.ISupportSuspendPainting System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> void @@ -45,12 +46,15 @@ System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Fo System.Windows.Forms.SuspendRelocationScope System.Windows.Forms.SuspendRelocationScope.Dispose() -> void System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope(System.Windows.Forms.ISupportSuspendRelocation? target) -> void -static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode -static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void +static System.Windows.Forms.Application.DefaultFormRevealMode.get -> System.Windows.Forms.FormRevealMode +static System.Windows.Forms.Application.IsFormRevealDeferred.get -> bool +static System.Windows.Forms.Application.SetDefaultFormRevealMode(System.Windows.Forms.FormRevealMode mode) -> 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 +virtual System.Windows.Forms.Form.FormRevealMode.get -> System.Windows.Forms.FormRevealMode +virtual System.Windows.Forms.Form.FormRevealMode.set -> void override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void override System.Windows.Forms.ComboBox.EndSuspendPaintingCore() -> void override System.Windows.Forms.ListBox.BeginSuspendPaintingCore() -> void From 24c81cda9ba53b7e7173c108d06e34e427dde045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Fri, 10 Jul 2026 01:43:02 -0700 Subject: [PATCH 22/99] Preserve painting suspension across handles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 20883488-cf1e-4ad3-a681-0724e9f16777 --- ..._ImproveControlRendering.HighRiskReview.md | 23 ++++++++ .../System/Windows/Forms/Control.cs | 15 +++-- .../Windows/Forms/ControlTests.Methods.cs | 55 +++++++++++++++++++ 3 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md diff --git a/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md new file mode 100644 index 00000000000..0150a26b9a5 --- /dev/null +++ b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md @@ -0,0 +1,23 @@ +# Net11Api_03 ImproveControlRendering High-Risk Review + +This document records a review finding that needs native lifecycle investigation before it can be patched safely. + +## Deferred reveal is not activated during initial display + +Reviewed merge base: `8b618e7f5` +Reviewed branch tip: `1ee2b58a6` + +The deferred-reveal implementation attempts to cloak a form from `OnHandleCreated`, while `ShouldUseDeferredAppearanceCloak` requires both `IsHandleCreated` and `Visible`. During `Show`, `ShowDialog`, and `Application.Run(form)`, the handle is created while evaluating `HWND`, before `ShowWindow` makes the form visible. The visibility state changes later while processing `WM_SHOWWINDOW`, and there is no subsequent cloak attempt. The intended initial-display cloak therefore does not activate on the normal display path. + +A safe correction requires selecting a one-shot point after handle creation but before the first compositor presentation. Moving the operation into `WM_SHOWWINDOW` could cloak later hide/show cycles, while removing the visibility check could cloak hidden forms whose handles are created for unrelated reasons. + +Before implementation, document and verify the state timeline for: + +- `Show`, `ShowDialog`, and `Application.Run`; +- hidden forms with pre-created handles; +- handle recreation; +- hide/show cycles; +- owned forms and splash screens; +- DWM composition or cloak-call failure. + +The selected design should define a one-shot invariant and explicitly reject stale state after handle recreation. Native verification should inspect the DWM cloak state before first paint and after reveal. Automated tests should cover all display paths and preserve a clear rollback path if compositor timing differs across supported Windows versions. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 7c71b88eb22..92245349aae 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -4399,13 +4399,8 @@ public IAsyncResult BeginInvoke(Delegate method, params object?[]? args) internal void BeginUpdateInternal() { - if (!IsHandleCreated) - { - return; - } - int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); - if (updateCount == 0) + if (updateCount == 0 && IsHandleCreated) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); } @@ -5102,14 +5097,13 @@ internal bool EndUpdateInternal(bool invalidate) int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); if (updateCount > 0) { - Debug.Assert(IsHandleCreated, "Handle should be created by now"); updateCount--; Properties.AddOrRemoveValue( s_updateCountProperty, updateCount, defaultValue: 0); - if (updateCount == 0) + if (updateCount == 0 && IsHandleCreated) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)true); if (invalidate) @@ -7445,6 +7439,11 @@ protected virtual void OnHandleCreated(EventArgs e) pszSubAppName: $"{DarkModeIdentifier}_{ExplorerThemeIdentifier}", pszSubIdList: null); } + + if (IsUpdating()) + { + PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); + } } ((EventHandler?)Events[s_handleCreatedEvent])?.Invoke(this, e); 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 fa6b67eb5a2..779afe89757 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 @@ -62,6 +62,41 @@ public async Task Control_SuspendPainting_ScopeSpansAwait_Success() Assert.False(control.IsHandleCreated); } + [WinFormsFact] + public void Control_SuspendPainting_HandleCreatedWithinScope_RemainsSuspended() + { + using RedrawTrackingControl control = new(); + + SuspendPaintingScope scope = control.SuspendPainting(); + Assert.True(control.IsUpdating()); + Assert.NotEqual(IntPtr.Zero, control.Handle); + Assert.Equal([false], control.RedrawStates); + + scope.Dispose(); + + Assert.False(control.IsUpdating()); + Assert.Equal([false, true], control.RedrawStates); + } + + [WinFormsFact] + public void Control_SuspendPainting_HandleRecreatedWithinScope_RemainsSuspended() + { + using RedrawTrackingControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + + SuspendPaintingScope scope = control.SuspendPainting(); + control.RedrawStates.Clear(); + control.RecreateHandle(); + + Assert.True(control.IsUpdating()); + Assert.Equal([false], control.RedrawStates); + + scope.Dispose(); + + Assert.False(control.IsUpdating()); + Assert.Equal([false, true], control.RedrawStates); + } + [WinFormsFact] public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() { @@ -74,6 +109,26 @@ public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() Assert.False(control.IsHandleCreated); } + + /// + /// Records redraw-state messages sent while painting is suspended. + /// + private sealed class RedrawTrackingControl : Control + { + public List RedrawStates { get; } = []; + + public new void RecreateHandle() => base.RecreateHandle(); + + protected override void WndProc(ref Message m) + { + if (m.MsgInternal == PInvokeCore.WM_SETREDRAW) + { + RedrawStates.Add((nint)m.WParamInternal != 0); + } + + base.WndProc(ref m); + } + } #endif public static IEnumerable AccessibilityNotifyClients_AccessibleEvents_Int_TestData() From 6a94824c1d772825ed8b5519d1de5feaedac3db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 15 Jul 2026 23:26:15 -0700 Subject: [PATCH 23/99] Add layout-aware painting suspension Remove the risky relocation API and add controlled layout traversal to painting scopes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9c3dc32b-7202-48c4-b0c7-f40a0a681ee2 --- .../Create-Github-API-Proposal-Prompt.md | 239 ++++++------- ...elocationAndPainting-API-Feature-Prompt.md | 118 +++---- .../PublicAPI.Unshipped.txt | 15 +- 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 + .../Windows/Forms/Control.SuspendMutation.cs | 81 +++-- .../System/Windows/Forms/Control.cs | 4 + .../Forms/ControlMutationExtensions.cs | 39 ++- .../ComboBox/ComboBox.SuspendMutation.cs | 2 +- .../Forms/Controls/ComboBox/ComboBox.cs | 6 +- .../ListBoxes/ListBox.SuspendMutation.cs | 2 +- .../Forms/Controls/ListBoxes/ListBox.cs | 6 +- .../ListView/ListView.SuspendMutation.cs | 2 +- .../Forms/Controls/ListView/ListView.cs | 6 +- .../RichTextBox.SuspendMutation.cs | 2 +- .../TreeView/TreeView.SuspendMutation.cs | 2 +- .../Forms/Controls/TreeView/TreeView.cs | 7 +- .../Forms/ISupportSuspendRelocation.cs | 22 -- .../Windows/Forms/LayoutSuspendTraversal.cs | 27 ++ .../Windows/Forms/SuspendPaintingScope.cs | 236 ++++++++++++- .../Windows/Forms/SuspendRelocationScope.cs | 40 --- .../Windows/Forms/ControlTests.Methods.cs | 315 +++++++++++++++++- 34 files changed, 915 insertions(+), 324 deletions(-) delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs 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 bc73ee83449..ce3ad461cab 100644 --- a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md +++ b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md @@ -1,155 +1,120 @@ -# Copilot Prompt 1 — Author the WinForms API Suggestion (GitHub issue) +# Copilot Prompt 1 — Author the WinForms API Suggestion ## 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**. +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 two related features +for reducing visible intermediate states while WinForms applications update their UI: -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"). +1. Painting suspension with optional layout suspension across a control tree. +2. Deferred top-level form reveal. -## 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): +Use these issue sections: - `## Rationale` -- `## API Proposal` (C# signatures in fenced blocks, `namespace` declared) +- `## API Proposal` - `## 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. +- `### Status Checklist` ---- - -## 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 +## Sub-feature A — painting and layout suspension ### 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. +```csharp +namespace System.Windows.Forms; + +public interface ISupportSuspendPainting +{ + void BeginSuspendPainting(); + void EndSuspendPainting(); +} + +public enum LayoutSuspendTraversal +{ + None = 0, + TopLevelOnly = 1, + Traverse = 2, +} + +public sealed class SuspendPaintingScope : IDisposable +{ + public SuspendPaintingScope(ISupportSuspendPainting? target); + public void Dispose(); +} + +public static class ControlMutationExtensions +{ + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); +} +``` + +- `Control` implements `ISupportSuspendPainting` explicitly and exposes protected virtual + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` route painting + suspension through their existing `BeginUpdate` / `EndUpdate` paths. +- `None` suspends painting only. +- `TopLevelOnly` suspends layout on the target `Control`. +- `Traverse` suspends layout on the target and all existing descendants. +- The predicate is evaluated for the target and every descendant. A `false` result skips + that node but does not prune traversal. +- Selected nodes are suspended even when they currently have no children. +- Layout-aware overloads require the target to derive from `Control`. +- The scope is a sealed class so it can span `await`. + +### Design considerations + +- Snapshot the selected controls when the scope starts. +- Suspend layout root-to-leaf and resume it deepest-first. +- Resume layout before ending painting so recursive invalidation occurs after layout. +- Keep disposal idempotent and preserve nesting through the existing ref counts. +- Explain that controls added after the snapshot are covered by their parent's suspended + layout but do not receive an independently balanced suspension. +- Discuss traversal cost for large control trees and exceptions thrown by predicates. +- Include invalid and non-`Control` target behavior in the proposal. + +## Sub-feature B — deferred form reveal + +Use the current `FormRevealMode` design from the tracked API proposal: + +```csharp +namespace System.Windows.Forms; + +public enum FormRevealMode +{ + Inherit = -1, + Classic = 0, + Deferred = 1, +} + +public partial class Form +{ + public virtual FormRevealMode FormRevealMode { get; set; } +} + +public partial class Application +{ + public static FormRevealMode DefaultFormRevealMode { get; } + public static void SetDefaultFormRevealMode(FormRevealMode mode); + public static bool IsFormRevealDeferred { get; } +} +``` + +Describe DWM cloaking, dark-mode-aware default resolution, designer serialization, +top-level-window limitations, and conservative fallback behavior on unsupported systems. ## 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. +Produce a complete proposal rather than an implementation plan. Clearly distinguish fixed +API shape from implementation choices that remain open for review. diff --git a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md index bcff8090eff..35d765ad3ce 100644 --- a/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md +++ b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md @@ -2,75 +2,75 @@ ## 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. +Read the current upstream API suggestion before implementation. Treat its latest +`API Proposal` section as the public contract and report any conflict instead of silently +choosing a different shape. -## Your task +## Scope -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. +### A — painting suspension with optional layout traversal -## Scope +- Implement `ISupportSuspendPainting` on `Control` with ref-counted + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- Route `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` through their + existing `BeginUpdate` / `EndUpdate` mechanisms. +- Keep `SuspendPaintingScope` as a sealed, idempotent `IDisposable` class so it can span + `await`. +- Provide these extension methods: + + ```csharp + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); + ``` + +- `LayoutSuspendTraversal` has `None`, `TopLevelOnly`, and `Traverse`. +- Snapshot selected controls before suspension. Suspend root-to-leaf and resume + deepest-first. +- The predicate is evaluated for the target and every descendant. A `false` result skips + suspension for that node without pruning its descendants. +- Suspend selected controls even when they currently have no children. +- Validate layout-aware calls before beginning painting. A non-`Control` target is invalid. +- Resume all selected layout scopes before ending painting so the recursive invalidation + occurs after layout. + +### B — deferred form reveal -### 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. +- Implement the approved `FormRevealMode`, `Form.FormRevealMode`, and + `Application` configuration APIs. +- Preserve classic behavior when deferred reveal is not active. +- Cloak eligible top-level form handles and uncloak according to the timing strategy in the + proposal. +- Keep unsupported DWM and window configurations inert and safe. ## 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. +- Match the current repository language version, nullable annotations, code style, and + interop conventions. +- Add XML documentation for every public or protected API. +- Update `PublicAPI.Unshipped.txt`. +- Keep state lazy where existing `Control` property-store patterns apply. +- Do not change existing public `BeginUpdate` / `EndUpdate` 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. +- Painting ref-count balance, nesting, idempotent disposal, handle creation, and handle + recreation. +- `None`, `TopLevelOnly`, and `Traverse` layout selection. +- Predicate selection, continued traversal after a rejected node, and empty containers. +- Deepest-first layout resume and recursive invalidation after layout. +- Null, invalid-enum, and non-`Control` target failures without partial suspension. +- Existing native update paths for the selected built-in controls. +- Classic/deferred form reveal behavior and unsupported-OS 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. +Provide production code, focused tests, updated API tracking, and an updated API proposal. +Call out any behavior that the implementation proves unsafe or unnecessarily costly. diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 869425f33df..249c9f7c5ea 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -29,7 +29,8 @@ System.Windows.Forms.TreeView.NodeLeadingChanged -> System.EventHandler? virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) -> void 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.Func! suspendLayoutContainerFilter) -> System.Windows.Forms.SuspendPaintingScope! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target, System.Windows.Forms.LayoutSuspendTraversal layoutSuspendTraversal) -> System.Windows.Forms.SuspendPaintingScope! System.Windows.Forms.FormRevealMode System.Windows.Forms.FormRevealMode.Classic = 0 -> System.Windows.Forms.FormRevealMode System.Windows.Forms.FormRevealMode.Deferred = 1 -> System.Windows.Forms.FormRevealMode @@ -37,22 +38,18 @@ System.Windows.Forms.FormRevealMode.Inherit = -1 -> System.Windows.Forms.FormRev 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.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.None = 0 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.TopLevelOnly = 1 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.Traverse = 2 -> System.Windows.Forms.LayoutSuspendTraversal System.Windows.Forms.SuspendPaintingScope System.Windows.Forms.SuspendPaintingScope.Dispose() -> 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(System.Windows.Forms.ISupportSuspendRelocation? target) -> void static System.Windows.Forms.Application.DefaultFormRevealMode.get -> System.Windows.Forms.FormRevealMode static System.Windows.Forms.Application.IsFormRevealDeferred.get -> bool static System.Windows.Forms.Application.SetDefaultFormRevealMode(System.Windows.Forms.FormRevealMode mode) -> 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 virtual System.Windows.Forms.Form.FormRevealMode.get -> System.Windows.Forms.FormRevealMode virtual System.Windows.Forms.Form.FormRevealMode.set -> void override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 265c5f0ca61..d556b1991e2 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -1035,6 +1035,9 @@ Specifies the minimum size of the control. + + Layout suspension requires a target derived from Control. + 'child' is not a child control of this parent. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index 8449567c29d..ada8b24e536 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -1697,6 +1697,11 @@ Určuje minimální velikost ovládacího prvku. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. Objekt child není podřízeným ovládacím prvkem tohoto nadřazeného objektu. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index cebc50400ec..b119b5d66db 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -1697,6 +1697,11 @@ Gibt die Mindestgröße des Steuerelements an. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. "child" ist kein untergeordnetes Steuerelement dieses übergeordneten Elements. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 1555f67cdce..a3962b9d0d2 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -1697,6 +1697,11 @@ Especifica el tamaño mínimo del control. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' no es un control secundario de este primario. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index 5912e47c5ee..f4a84e53ec2 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -1697,6 +1697,11 @@ Indique la taille minimale du contrôle. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' n'est pas un contrôle enfant de ce parent. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index abeeb105208..e57b323219c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -1697,6 +1697,11 @@ Specifica le dimensioni minime del controllo. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' non è un controllo figlio di questo elemento. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 1e3cdc4d527..a9b6361d02e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -1697,6 +1697,11 @@ コントロールの最小サイズを指定します。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. '子' はこの親の子コントロールではありません。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 4d1a3a6990e..95e7771c930 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -1697,6 +1697,11 @@ 컨트롤의 최소 크기를 지정합니다. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child'는 이 부모의 자식 컨트롤이 아닙니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index 9ed87cef8e4..2457b8b3cae 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -1697,6 +1697,11 @@ Określa minimalny rozmiar formantu. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. Element 'child' nie jest formantem podrzędnym dla tego 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 060d9859606..9927719999e 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -1697,6 +1697,11 @@ Especifica o tamanho mínimo do controle. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' não é um controle filho deste pai. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index d54ab4f7aee..dcf9cd25e67 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -1697,6 +1697,11 @@ Определяет минимальный размер элемента управления. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' не является дочерним элементом управления этого родительского элемента. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 8824960ed15..7eb5616b6fd 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -1697,6 +1697,11 @@ Denetimin en küçük boyutunu belirtir. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'alt' bu üstün alt denetimi değil. 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 54b1010811d..1282352a5da 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -1697,6 +1697,11 @@ 指定控件的最小大小。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. “child”不是此父级的子控件。 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 3285d9f42b2..8991310a36d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -1697,6 +1697,11 @@ 指定控制項大小的最小值。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' 不是此父系的子控制項。 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 0c2d6cae960..dad7898f68c 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -5,8 +5,7 @@ namespace System.Windows.Forms; #if NET11_0_OR_GREATER public unsafe partial class Control : - ISupportSuspendPainting, - ISupportSuspendRelocation + ISupportSuspendPainting #else public unsafe partial class Control #endif @@ -20,17 +19,11 @@ public unsafe partial class Control /// /// Ends a painting suspension region for this control. /// - void ISupportSuspendPainting.EndSuspendPainting() => EndSuspendPaintingCore(); - - /// - /// Begins a relocation suspension region for this control. - /// - void ISupportSuspendRelocation.BeginSuspendRelocation() => BeginSuspendRelocationCore(); - - /// - /// Ends a relocation suspension region for this control. - /// - void ISupportSuspendRelocation.EndSuspendRelocation() => EndSuspendRelocationCore(); + void ISupportSuspendPainting.EndSuspendPainting() + { + EndSuspendPaintingCore(); + CompleteRecursiveInvalidateAfterSuspendPainting(); + } /// /// When overridden in a derived class, begins a painting suspension region for this control. @@ -56,26 +49,28 @@ protected virtual void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdateInternal(invalidate: true); + EndUpdateInternal(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } - /// - /// When overridden in a derived class, begins a relocation suspension region for this control. - /// - protected virtual void BeginSuspendRelocationCore() => SuspendLayout(); + internal bool IsRecursiveInvalidateAfterSuspendPaintingRequested + => Properties.ContainsKey(s_recursiveInvalidateAfterSuspendPaintingProperty); - /// - /// When overridden in a derived class, ends a relocation suspension region for this control. - /// - protected virtual void EndSuspendRelocationCore() => ResumeLayout(); + internal int SuspendPaintingCount + => Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + + internal void RequestRecursiveInvalidateAfterSuspendPainting() + => Properties.AddValue(s_recursiveInvalidateAfterSuspendPaintingProperty, true); internal bool BeginSuspendPaintingScope() { - int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + int suspendPaintingCount = Properties.GetValueOrDefault( + key: s_suspendPaintingCountProperty, + defaultValue: 0); + Properties.AddOrRemoveValue( - s_suspendPaintingCountProperty, - suspendPaintingCount + 1, + key: s_suspendPaintingCountProperty, + value: suspendPaintingCount + 1, defaultValue: 0); return true; @@ -83,7 +78,10 @@ internal bool BeginSuspendPaintingScope() internal bool EndSuspendPaintingScope() { - int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + int suspendPaintingCount = Properties.GetValueOrDefault( + key: s_suspendPaintingCountProperty, + defaultValue: 0); + if (suspendPaintingCount == 0) { return false; @@ -96,5 +94,36 @@ internal bool EndSuspendPaintingScope() return true; } + + internal void ResumeLayoutAfterSuspendPainting() + { + byte layoutSuspendCount = LayoutSuspendCount; + + try + { + ResumeLayout(); + } + catch + { + if (LayoutSuspendCount == layoutSuspendCount && LayoutSuspendCount > 0) + { + LayoutSuspendCount--; + } + + throw; + } + } + + private void CompleteRecursiveInvalidateAfterSuspendPainting() + { + if (Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0) != 0 + || !Properties.ContainsKey(s_recursiveInvalidateAfterSuspendPaintingProperty)) + { + return; + } + + Properties.RemoveValue(s_recursiveInvalidateAfterSuspendPaintingProperty); + Invalidate(invalidateChildren: 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 92245349aae..c9913404d42 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -232,6 +232,7 @@ public unsafe partial class Control : private static readonly int s_systemVisualSettingsProperty = PropertyStore.CreateKey(); private static readonly int s_systemVisualSettingsRefreshPendingProperty = PropertyStore.CreateKey(); #if NET11_0_OR_GREATER + private static readonly int s_recursiveInvalidateAfterSuspendPaintingProperty = PropertyStore.CreateKey(); private static readonly int s_suspendPaintingCountProperty = PropertyStore.CreateKey(); #endif private static readonly int s_updateCountProperty = PropertyStore.CreateKey(); @@ -5095,9 +5096,11 @@ public void DrawToBitmap(Bitmap bitmap, Rectangle targetBounds) internal bool EndUpdateInternal(bool invalidate) { int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount > 0) { updateCount--; + Properties.AddOrRemoveValue( s_updateCountProperty, updateCount, @@ -5106,6 +5109,7 @@ internal bool EndUpdateInternal(bool invalidate) if (updateCount == 0 && IsHandleCreated) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)true); + if (invalidate) { Invalidate(); diff --git a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs index 3d2a87fcf82..963199a4782 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs @@ -3,6 +3,8 @@ namespace System.Windows.Forms; +using System.ComponentModel; + #if NET11_0_OR_GREATER /// /// Provides extension methods for batching synchronous WinForms UI mutations. @@ -18,11 +20,38 @@ public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting => new(target); /// - /// Suspends relocation work for the specified target until the returned scope is disposed. + /// Suspends painting and the selected layout work 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); + /// The target whose painting and layout should be suspended. + /// + /// A value that specifies which controls in the target's control tree should suspend layout. + /// + /// A scope that resumes layout and painting when disposed. + /// is . + /// + /// is not a valid value. + /// + /// is not a . + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + => new(target, layoutSuspendTraversal); + + /// + /// Suspends painting and layout for selected controls until the returned scope is disposed. + /// + /// The target whose painting and selected layout should be suspended. + /// + /// A predicate that returns for each control whose layout should be suspended. + /// + /// A scope that resumes layout and painting when disposed. + /// + /// or is . + /// + /// is not a . + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + => new(target, suspendLayoutContainerFilter); } #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 index 9a90bcda243..26590de34aa 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 @@ -18,7 +18,7 @@ protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdate(); + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs index 5e4422eaf9c..947b9575eda 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs @@ -1855,7 +1855,9 @@ protected override void Dispose(bool disposing) /// beginUpdate(), any redrawing caused by operations performed on the /// combo box is deferred until the call to endUpdate(). /// - public unsafe void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private unsafe void EndUpdate(bool invalidate) { _updateCount--; if (_updateCount == 0 && AutoCompleteSource == AutoCompleteSource.ListItems) @@ -1863,7 +1865,7 @@ public unsafe void EndUpdate() SetAutoComplete(false, false); } - if (EndUpdateInternal()) + if (EndUpdateInternal(invalidate) && invalidate) { if (_childEdit is not null && !_childEdit.HWND.IsNull) { 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 e26361db974..20964896aa1 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 @@ -18,7 +18,7 @@ protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdate(); + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs index ef7c6daab90..bbfa4ad39cc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs @@ -1350,9 +1350,11 @@ public void ClearSelected() /// way of doing this. BeginUpdate should be called first, and this method /// should be called when you want the control to start painting again. /// - public void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) { - EndUpdateInternal(); + EndUpdateInternal(invalidate); --_updateCount; } 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 1fbe0660b39..2a266c72548 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 @@ -18,7 +18,7 @@ protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdate(); + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs index f290e8ce8eb..65335aea479 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs @@ -3158,7 +3158,9 @@ private bool ClearingInnerListOnDispose /// /// Cancels the effect of BeginUpdate. /// - public void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) { // On the final EndUpdate, check to see if we've got any cached items. // If we do, insert them as normal, then turn off the painting freeze. @@ -3167,7 +3169,7 @@ public void EndUpdate() ApplyUpdateCachedItems(); } - EndUpdateInternal(); + EndUpdateInternal(invalidate); } private void EnsureDefaultGroup() 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 4625a1cb225..f5d35fe41e3 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 @@ -18,7 +18,7 @@ protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdateInternal(); + EndUpdateInternal(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } #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 index f9fc5d4d629..1c738bb8360 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 @@ -18,7 +18,7 @@ protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { - EndUpdate(); + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); } } #endif 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 5a68e10a41f..90e74d24f1e 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 @@ -1705,10 +1705,9 @@ protected override void Dispose(bool disposing) /// beginUpdate(), any redrawing caused by operations performed on the /// combo box is deferred until the call to endUpdate(). /// - public void EndUpdate() - { - EndUpdateInternal(); - } + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) => EndUpdateInternal(invalidate); /// /// Expands all nodes at the root level. diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs deleted file mode 100644 index 23338950127..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs +++ /dev/null @@ -1,22 +0,0 @@ -// 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/LayoutSuspendTraversal.cs b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs new file mode 100644 index 00000000000..119c35d6cb4 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Specifies how layout suspension traverses a control tree while painting is suspended. +/// +public enum LayoutSuspendTraversal +{ + /// + /// Does not suspend layout. + /// + None = 0, + + /// + /// Suspends layout only for the target control. + /// + TopLevelOnly = 1, + + /// + /// Suspends layout for the target control and all its descendants. + /// + Traverse = 2, +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs index c596dfbc452..e65deea5d20 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -3,6 +3,9 @@ namespace System.Windows.Forms; +using System.ComponentModel; +using System.Runtime.ExceptionServices; + #if NET11_0_OR_GREATER /// /// Suspends painting for a target until the scope is disposed. @@ -18,6 +21,7 @@ namespace System.Windows.Forms; public sealed class SuspendPaintingScope : IDisposable { private ISupportSuspendPainting? _target; + private Control[]? _layoutControls; /// /// Initializes a new instance of the class. @@ -26,18 +30,240 @@ public sealed class SuspendPaintingScope : IDisposable public SuspendPaintingScope(ISupportSuspendPainting? target) { _target = target; - _target?.BeginSuspendPainting(); + + if (target is null) + { + return; + } + + int initialPaintingCount = target is Control control + ? control.SuspendPaintingCount + : 0; + + try + { + target.BeginSuspendPainting(); + } + catch (Exception exception) + { + _target = null; + List exceptions = [exception]; + UnwindFailedPaintingAcquisition(target, initialPaintingCount, exceptions); + ThrowExceptions(exceptions); + } + } + + internal SuspendPaintingScope( + ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + : this(target, GetLayoutControls(target, layoutSuspendTraversal)) + { + } + + internal SuspendPaintingScope( + ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + : this(target, GetLayoutControls(target, suspendLayoutContainerFilter)) + { + } + + private SuspendPaintingScope( + ISupportSuspendPainting target, + Control[] layoutControls) + { + _target = target; + _layoutControls = layoutControls; + int initialPaintingCount = ((Control)target).SuspendPaintingCount; + int suspendedLayoutCount = 0; + + try + { + target.BeginSuspendPainting(); + + foreach (Control control in layoutControls) + { + control.SuspendLayout(); + suspendedLayoutCount++; + } + } + catch (Exception exception) + { + List exceptions = [exception]; + ResumeLayouts(layoutControls, suspendedLayoutCount, exceptions); + UnwindFailedPaintingAcquisition(target, initialPaintingCount, exceptions); + + _target = null; + _layoutControls = null; + ThrowExceptions(exceptions); + } } /// - /// Resumes painting for the target associated with this scope. + /// Resumes layout and painting for the target associated with this scope. /// 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(); + ISupportSuspendPainting? target = _target; + Control[] layoutControls = _layoutControls ?? []; _target = null; + _layoutControls = null; + + if (target is null) + { + return; + } + + List exceptions = []; + ResumeLayouts(layoutControls, layoutControls.Length, exceptions); + EndPainting(target, exceptions, requestRecursiveInvalidate: target is Control); + + ThrowExceptions(exceptions); + } + + private static Control[] GetLayoutControls( + ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + { + Control control = GetControl(target); + + return layoutSuspendTraversal switch + { + LayoutSuspendTraversal.None => [], + LayoutSuspendTraversal.TopLevelOnly => [control], + LayoutSuspendTraversal.Traverse => GetLayoutControls(control, static _ => true), + _ => throw new InvalidEnumArgumentException( + nameof(layoutSuspendTraversal), + (int)layoutSuspendTraversal, + typeof(LayoutSuspendTraversal)) + }; + } + + private static Control[] GetLayoutControls( + ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + { + ArgumentNullException.ThrowIfNull(suspendLayoutContainerFilter); + + return GetLayoutControls(GetControl(target), suspendLayoutContainerFilter); + } + + private static Control GetControl(ISupportSuspendPainting target) + { + ArgumentNullException.ThrowIfNull(target); + + return target as Control + ?? throw new InvalidOperationException(SR.ControlMutationExtensionsLayoutSuspensionRequiresControl); + } + + private static Control[] GetLayoutControls( + Control target, + Func suspendLayoutContainerFilter) + { + List layoutControls = []; + Stack controlsToVisit = new(); + controlsToVisit.Push(target); + + while (controlsToVisit.TryPop(out Control? control)) + { + if (suspendLayoutContainerFilter(control)) + { + layoutControls.Add(control); + } + + if (control.ChildControls is not { } children) + { + continue; + } + + for (int i = children.Count - 1; i >= 0; i--) + { + controlsToVisit.Push(children[i]); + } + } + + return [.. layoutControls]; + } + + private static void ResumeLayouts( + Control[] layoutControls, + int suspendedLayoutCount, + List exceptions) + { + for (int i = suspendedLayoutCount - 1; i >= 0; i--) + { + try + { + layoutControls[i].ResumeLayoutAfterSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + } + } + } + + private static void EndPainting( + ISupportSuspendPainting target, + List exceptions, + bool requestRecursiveInvalidate = false) + { + try + { + if (requestRecursiveInvalidate) + { + ((Control)target).RequestRecursiveInvalidateAfterSuspendPainting(); + } + + target.EndSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + } + } + + private static void UnwindFailedPaintingAcquisition( + ISupportSuspendPainting target, + int initialPaintingCount, + List exceptions) + { + if (target is not Control control) + { + return; + } + + while (control.SuspendPaintingCount > initialPaintingCount) + { + int paintingCount = control.SuspendPaintingCount; + + try + { + target.EndSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + return; + } + + if (control.SuspendPaintingCount >= paintingCount) + { + return; + } + } + } + + private static void ThrowExceptions(List exceptions) + { + if (exceptions.Count == 1) + { + ExceptionDispatchInfo.Throw(exceptions[0]); + } + + if (exceptions.Count > 1) + { + throw new AggregateException(exceptions); + } } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs deleted file mode 100644 index a86acb0e42f..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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. -/// -/// -/// -/// 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 ISupportSuspendRelocation? _target; - - /// - /// Initializes a new instance of the class. - /// - /// 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(); - _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 779afe89757..5d47db2cf35 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 @@ -98,22 +98,282 @@ public void Control_SuspendPainting_HandleRecreatedWithinScope_RemainsSuspended( } [WinFormsFact] - public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + public void Control_SuspendPainting_WithNone_DoesNotSuspendLayout() { using SubControl control = new(); - ISupportSuspendRelocation suspendRelocation = control; - suspendRelocation.BeginSuspendRelocation(); - suspendRelocation.EndSuspendRelocation(); - suspendRelocation.EndSuspendRelocation(); + using SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.None); + Assert.Equal((byte)0, control.LayoutSuspendCount); Assert.False(control.IsHandleCreated); } + [WinFormsFact] + public void Control_SuspendPainting_WithTopLevelOnly_SuspendsOnlyTargetLayout() + { + using SubControl control = new(); + using Control child = new(); + control.Controls.Add(child); + + SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + + Assert.Equal((byte)1, control.LayoutSuspendCount); + Assert.Equal((byte)0, child.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); + Assert.False(child.IsHandleCreated); + + scope.Dispose(); + + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.Equal((byte)0, child.LayoutSuspendCount); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithTraverse_SuspendsEntireTreeLayout() + { + using SubControl control = new(); + using Control child = new(); + using Control grandchild = new(); + control.Controls.Add(child); + child.Controls.Add(grandchild); + + SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.Traverse); + + Assert.Equal((byte)1, control.LayoutSuspendCount); + Assert.Equal((byte)1, child.LayoutSuspendCount); + Assert.Equal((byte)1, grandchild.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); + Assert.False(child.IsHandleCreated); + Assert.False(grandchild.IsHandleCreated); + + scope.Dispose(); + + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.Equal((byte)0, child.LayoutSuspendCount); + Assert.Equal((byte)0, grandchild.LayoutSuspendCount); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithFilter_SkipsControlAndContinuesTraversal() + { + using SubControl control = new(); + using Control child = new(); + using Control grandchild = new(); + control.Controls.Add(child); + child.Controls.Add(grandchild); + List visitedControls = []; + + SuspendPaintingScope scope = control.SuspendPainting(candidate => + { + visitedControls.Add(candidate); + return candidate != child; + }); + + Assert.Equal([control, child, grandchild], visitedControls); + Assert.Equal((byte)1, control.LayoutSuspendCount); + Assert.Equal((byte)0, child.LayoutSuspendCount); + Assert.Equal((byte)1, grandchild.LayoutSuspendCount); + + scope.Dispose(); + + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.Equal((byte)0, child.LayoutSuspendCount); + Assert.Equal((byte)0, grandchild.LayoutSuspendCount); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithLayoutSuspension_DisposeIsIdempotentAndBalancesNesting() + { + using RedrawTrackingControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + int invalidatedCallCount = 0; + control.Invalidated += (sender, e) => invalidatedCallCount++; + + SuspendPaintingScope outerScope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + SuspendPaintingScope innerScope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + + Assert.Equal((byte)2, control.LayoutSuspendCount); + Assert.Equal([false], control.RedrawStates); + + innerScope.Dispose(); + innerScope.Dispose(); + Assert.Equal((byte)1, control.LayoutSuspendCount); + Assert.Equal([false], control.RedrawStates); + Assert.Equal(0, invalidatedCallCount); + + outerScope.Dispose(); + outerScope.Dispose(); + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.Equal([false, true], control.RedrawStates); + Assert.Equal(1, invalidatedCallCount); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithLayoutSuspension_ResumesChildrenBeforeParentAndThenInvalidatesTree() + { + using SubControl control = new(); + using Control child = new(); + control.Controls.Add(child); + control.CreateControl(); + child.CreateControl(); + List events = []; + control.Layout += (sender, e) => events.Add("parent-layout"); + child.Layout += (sender, e) => events.Add("child-layout"); + control.Invalidated += (sender, e) => events.Add("parent-invalidated"); + + SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.Traverse); + control.PerformLayout(); + child.PerformLayout(); + + Assert.Empty(events); + + scope.Dispose(); + + Assert.Equal("child-layout", events[0]); + Assert.Equal("parent-layout", events[1]); + Assert.Contains("parent-invalidated", events); + Assert.All(events.Skip(2), entry => Assert.EndsWith("-invalidated", entry)); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithInvalidTraversal_ThrowsInvalidEnumArgumentException() + { + using SubControl control = new(); + + Assert.Throws( + () => control.SuspendPainting((LayoutSuspendTraversal)(-1))); + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithNullFilter_ThrowsArgumentNullException() + { + using SubControl control = new(); + + Assert.Throws( + () => control.SuspendPainting((Func)null)); + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_SuspendPainting_WithThrowingFilter_DoesNotBeginSuspension() + { + using SubControl control = new(); + + Assert.Throws( + () => control.SuspendPainting(static _ => throw new InvalidOperationException())); + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); + } + + [WinFormsTheory] + [EnumData] + public void ISupportSuspendPainting_SuspendPainting_WithNonControlTarget_ThrowsInvalidOperationException( + LayoutSuspendTraversal layoutSuspendTraversal) + { + SuspendPaintingTarget target = new(); + + Assert.Throws( + () => target.SuspendPainting(layoutSuspendTraversal)); + Assert.Equal(0, target.BeginCallCount); + Assert.Equal(0, target.EndCallCount); + } + + [WinFormsFact] + public void ISupportSuspendPainting_SuspendPainting_WithFilterAndNonControlTarget_ThrowsInvalidOperationException() + { + SuspendPaintingTarget target = new(); + int filterCallCount = 0; + + Assert.Throws( + () => target.SuspendPainting(control => + { + filterCallCount++; + return true; + })); + Assert.Equal(0, filterCallCount); + Assert.Equal(0, target.BeginCallCount); + Assert.Equal(0, target.EndCallCount); + } + + [WinFormsFact] + public void Control_SuspendPainting_WhenBeginThrows_BalancesPaintingSuspension() + { + using ThrowingBeginSuspendPaintingControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + + Assert.Throws(() => control.SuspendPainting()); + Assert.False(control.IsUpdating()); + Assert.Equal([false, true], control.RedrawStates); + + control.ThrowOnBegin = false; + using (control.SuspendPainting()) + { + Assert.True(control.IsUpdating()); + } + + Assert.False(control.IsUpdating()); + Assert.Equal([false, true, false, true], control.RedrawStates); + } + + [WinFormsFact] + public void Control_SuspendPainting_WhenNestedBeginThrowsBeforeAcquisition_PreservesOuterSuspension() + { + using ThrowingBeginSuspendPaintingControl control = new() + { + ThrowOnBegin = false + }; + Assert.NotEqual(IntPtr.Zero, control.Handle); + SuspendPaintingScope outerScope = control.SuspendPainting(); + control.ThrowOnBegin = true; + control.CallBaseBeforeThrow = false; + + Assert.Throws(() => control.SuspendPainting()); + Assert.True(control.IsUpdating()); + Assert.Equal([false], control.RedrawStates); + + outerScope.Dispose(); + + Assert.False(control.IsUpdating()); + Assert.Equal([false, true], control.RedrawStates); + } + + [WinFormsFact] + public void Control_SuspendPainting_WhenLayoutResumeThrows_BalancesLayoutAndPaintingSuspension() + { + using ThrowingLayoutResumingControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + control.ThrowOnLayoutResuming = true; + + Assert.Throws(() => scope.Dispose()); + control.ThrowOnLayoutResuming = false; + + Assert.Equal((byte)0, control.LayoutSuspendCount); + Assert.False(control.IsUpdating()); + Assert.Equal([false, true], control.RedrawStates); + } + + /// + /// Records painting suspension calls without deriving from . + /// + private sealed class SuspendPaintingTarget : ISupportSuspendPainting + { + public int BeginCallCount { get; private set; } + + public int EndCallCount { get; private set; } + + public void BeginSuspendPainting() => BeginCallCount++; + + public void EndSuspendPainting() => EndCallCount++; + } + /// /// Records redraw-state messages sent while painting is suspended. /// - private sealed class RedrawTrackingControl : Control + private class RedrawTrackingControl : Control { public List RedrawStates { get; } = []; @@ -129,6 +389,49 @@ protected override void WndProc(ref Message m) base.WndProc(ref m); } } + + /// + /// Throws after acquiring the base painting suspension. + /// + private sealed class ThrowingBeginSuspendPaintingControl : RedrawTrackingControl + { + public bool CallBaseBeforeThrow { get; set; } = true; + + public bool ThrowOnBegin { get; set; } = true; + + protected override void BeginSuspendPaintingCore() + { + if (ThrowOnBegin && !CallBaseBeforeThrow) + { + throw new InvalidOperationException(); + } + + base.BeginSuspendPaintingCore(); + + if (ThrowOnBegin) + { + throw new InvalidOperationException(); + } + } + } + + /// + /// Throws while resuming layout. + /// + private sealed class ThrowingLayoutResumingControl : RedrawTrackingControl + { + public bool ThrowOnLayoutResuming { get; set; } + + internal override void OnLayoutResuming(bool performLayout) + { + if (ThrowOnLayoutResuming) + { + throw new InvalidOperationException(); + } + + base.OnLayoutResuming(performLayout); + } + } #endif public static IEnumerable AccessibilityNotifyClients_AccessibleEvents_Int_TestData() From 69888a53cdd7d21a613d163e33ab309b7a8f3ae0 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 13 Jun 2026 13:26:48 -0700 Subject: [PATCH 24/99] 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 b50d6dcb4c619ae64e8f1b863caba05d96bbdf00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Tue, 19 May 2026 15:11:44 -0700 Subject: [PATCH 25/99] Move component code files to component folder. --- src/System.Windows.Forms.Primitives/src/NativeMethods.txt | 3 +++ .../Forms/{ => Components}/ErrorProvider/ErrorBlinkStyle.cs | 0 .../Forms/{ => Components}/ErrorProvider/ErrorIconAlignment.cs | 0 .../ErrorProvider.ControlItem.ControlItemAccessibleObject.cs | 0 .../ErrorProvider/ErrorProvider.ControlItem.cs | 0 .../ErrorProvider/ErrorProvider.ErrorProviderStates.cs | 0 .../ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs | 0 .../ErrorProvider/ErrorProvider.ErrorWindow.cs | 0 .../{ => Components}/ErrorProvider/ErrorProvider.IconRegion.cs | 0 .../Forms/{ => Components}/ErrorProvider/ErrorProvider.cs | 0 .../System/Windows/Forms/{Help => Components}/HelpProvider.cs | 0 .../Forms/{Controls => Components}/ImageList/ColorDepth.cs | 0 .../ImageList/ImageList.ImageCollection.ImageInfo.cs | 0 .../ImageList/ImageList.ImageCollection.cs | 0 .../{Controls => Components}/ImageList/ImageList.Indexer.cs | 0 .../ImageList/ImageList.NativeImageList.cs | 0 .../{Controls => Components}/ImageList/ImageList.Original.cs | 0 .../ImageList/ImageList.OriginalOptions.cs | 0 .../Forms/{Controls => Components}/ImageList/ImageList.cs | 0 .../{Controls => Components}/ImageList/ImageListConverter.cs | 0 .../{Controls => Components}/ImageList/ImageListStreamer.cs | 0 .../ImageList/RelatedImageListAttribute.cs | 0 .../System/Windows/Forms/{ => Components}/Timer.cs | 0 23 files changed, 3 insertions(+) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorBlinkStyle.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorIconAlignment.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.ControlItem.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.ErrorProviderStates.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.ErrorWindow.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.IconRegion.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/ErrorProvider/ErrorProvider.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Help => Components}/HelpProvider.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ColorDepth.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.ImageCollection.ImageInfo.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.ImageCollection.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.Indexer.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.NativeImageList.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.Original.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.OriginalOptions.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageList.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageListConverter.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/ImageListStreamer.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{Controls => Components}/ImageList/RelatedImageListAttribute.cs (100%) rename src/System.Windows.Forms/System/Windows/Forms/{ => Components}/Timer.cs (100%) diff --git a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt index 751a668cde2..ecb393942e5 100644 --- a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt +++ b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt @@ -516,6 +516,9 @@ PD_RESULT_* PostQuitMessage PostQuitMessage PostThreadMessage +PowerCreateRequest +PowerClearRequest +PowerSetRequest PRF_* PROGRESS_CLASS PROPERTYKEY diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorBlinkStyle.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorBlinkStyle.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorBlinkStyle.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorBlinkStyle.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorIconAlignment.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorIconAlignment.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorIconAlignment.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorIconAlignment.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorProviderStates.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorProviderStates.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorProviderStates.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorProviderStates.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.IconRegion.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.IconRegion.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.IconRegion.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.IconRegion.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Help/HelpProvider.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/HelpProvider.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Help/HelpProvider.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/HelpProvider.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ColorDepth.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ColorDepth.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ColorDepth.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ColorDepth.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.ImageInfo.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.ImageInfo.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.ImageInfo.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.ImageInfo.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Indexer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Indexer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Indexer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Indexer.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.NativeImageList.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.NativeImageList.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.NativeImageList.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.NativeImageList.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Original.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Original.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Original.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Original.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.OriginalOptions.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.OriginalOptions.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.OriginalOptions.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.OriginalOptions.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListConverter.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListConverter.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListConverter.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListConverter.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListStreamer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListStreamer.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/RelatedImageListAttribute.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/RelatedImageListAttribute.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/RelatedImageListAttribute.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/RelatedImageListAttribute.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Timer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/Timer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Timer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/Timer.cs From 614fcf9e3f78ed2ead2559cbd1ee5c60da2c2edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Tue, 19 May 2026 15:12:12 -0700 Subject: [PATCH 26/99] Introduce KioskModeManager component. * Refactor KioskModeManager component and introduce WakeUp-Events incl.. new WakeUpEventArgs. --- src/BuildAssist/BuildAssist.msbuildproj | 2 +- .../src/NativeMethods.txt | 2 + .../PublicAPI.Unshipped.txt | 42 + src/System.Windows.Forms/Resources/SR.resx | 41 +- .../Resources/xlf/SR.cs.xlf | 65 + .../Resources/xlf/SR.de.xlf | 65 + .../Resources/xlf/SR.es.xlf | 65 + .../Resources/xlf/SR.fr.xlf | 65 + .../Resources/xlf/SR.it.xlf | 65 + .../Resources/xlf/SR.ja.xlf | 65 + .../Resources/xlf/SR.ko.xlf | 65 + .../Resources/xlf/SR.pl.xlf | 65 + .../Resources/xlf/SR.pt-BR.xlf | 65 + .../Resources/xlf/SR.ru.xlf | 65 + .../Resources/xlf/SR.tr.xlf | 65 + .../Resources/xlf/SR.zh-Hans.xlf | 65 + .../Resources/xlf/SR.zh-Hant.xlf | 65 + .../KioskModeManager/KioskModeManager.cs | 1145 +++++++++++++++++ .../KioskModeWakeupEventArgs.cs | 36 + .../KioskModeWakeupEventHandler.cs | 14 + .../KioskModeManager/KioskModeWakeupSource.cs | 35 + .../Windows/Forms/KioskModeManagerTests.cs | 423 ++++++ 22 files changed, 2583 insertions(+), 2 deletions(-) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs diff --git a/src/BuildAssist/BuildAssist.msbuildproj b/src/BuildAssist/BuildAssist.msbuildproj index 2e188f76ff6..400c78f44e6 100644 --- a/src/BuildAssist/BuildAssist.msbuildproj +++ b/src/BuildAssist/BuildAssist.msbuildproj @@ -37,7 +37,7 @@ void override System.Windows.Forms.RichTextBox.EndSuspendPaintingCore() -> void override System.Windows.Forms.TreeView.BeginSuspendPaintingCore() -> void override System.Windows.Forms.TreeView.EndSuspendPaintingCore() -> void +#nullable enable +System.Windows.Forms.KioskModeManager +System.Windows.Forms.KioskModeManager.ContainerControl.get -> System.Windows.Forms.ContainerControl? +System.Windows.Forms.KioskModeManager.ContainerControl.set -> void +System.Windows.Forms.KioskModeManager.ContainerControlChanged -> System.EventHandler? +override System.Windows.Forms.KioskModeManager.Dispose(bool disposing) -> void +System.Windows.Forms.KioskModeManager.EscapeExitsFullScreen.get -> bool +System.Windows.Forms.KioskModeManager.EscapeExitsFullScreen.set -> void +System.Windows.Forms.KioskModeManager.FullScreen.get -> bool +System.Windows.Forms.KioskModeManager.FullScreen.set -> void +System.Windows.Forms.KioskModeManager.FullScreenChanged -> System.EventHandler? +System.Windows.Forms.KioskModeManager.HideTaskbar.get -> bool +System.Windows.Forms.KioskModeManager.HideTaskbar.set -> void +System.Windows.Forms.KioskModeManager.KioskModeManager() -> void +System.Windows.Forms.KioskModeManager.KioskModeManager(System.ComponentModel.IContainer! container) -> void +System.Windows.Forms.KioskModeManager.MousePointerAutoHideDelay.get -> int +System.Windows.Forms.KioskModeManager.MousePointerAutoHideDelay.set -> void +virtual System.Windows.Forms.KioskModeManager.OnContainerControlChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.KioskModeManager.OnFullScreenChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.KioskModeManager.OnWakeup(System.Windows.Forms.KioskModeWakeupEventArgs! e) -> void +override System.Windows.Forms.KioskModeManager.Site.get -> System.ComponentModel.ISite? +override System.Windows.Forms.KioskModeManager.Site.set -> void +System.Windows.Forms.KioskModeManager.SuppressPowerSaving.get -> bool +System.Windows.Forms.KioskModeManager.SuppressPowerSaving.set -> void +System.Windows.Forms.KioskModeManager.ToggleFullScreen() -> void +System.Windows.Forms.KioskModeManager.ToggleFullScreenKey.get -> System.Windows.Forms.Keys +System.Windows.Forms.KioskModeManager.ToggleFullScreenKey.set -> void +System.Windows.Forms.KioskModeManager.TopMostInFullScreen.get -> bool +System.Windows.Forms.KioskModeManager.TopMostInFullScreen.set -> void +System.Windows.Forms.KioskModeManager.Wakeup -> System.Windows.Forms.KioskModeWakeupEventHandler? +System.Windows.Forms.KioskModeManager.WakeUpCommand.get -> System.Windows.Input.ICommand? +System.Windows.Forms.KioskModeManager.WakeUpCommand.set -> void +System.Windows.Forms.KioskModeWakeupEventArgs +System.Windows.Forms.KioskModeWakeupEventArgs.KioskModeWakeupEventArgs(System.Windows.Forms.KioskModeWakeupSource source) -> void +System.Windows.Forms.KioskModeWakeupEventArgs.Source.get -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupEventHandler +virtual System.Windows.Forms.KioskModeWakeupEventHandler.Invoke(object? sender, System.Windows.Forms.KioskModeWakeupEventArgs! e) -> void +System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Keyboard = 0 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Mouse = 1 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.PowerResume = 2 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Session = 3 -> System.Windows.Forms.KioskModeWakeupSource diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index d556b1991e2..bad8516f342 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -2685,6 +2685,45 @@ To replace this default dialog please handle the DataError event. Manages a collection of images that are typically used by other controls such as ListView, TreeView, or ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + + The container whose containing form is controlled by the kiosk mode manager. + + + Occurs when the ContainerControl property value changes. + + + Indicates whether pressing Escape exits fullscreen mode. + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + Occurs when the fullscreen state changes. + + + Indicates whether fullscreen mode covers the Windows taskbar. + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + Indicates whether Windows should keep the display and system awake while this component is active. + + + The key that toggles between fullscreen and restored mode. + + + Indicates whether the form is topmost while fullscreen. + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + The command that is executed whenever the kiosk experience is woken. + Provides run-time information or descriptive text for a control. @@ -7060,4 +7099,4 @@ Stack trace where the illegal operation occurred was: The FormScreenCaptureMode property can only be changed on top-level Forms with their TopLevel property set to true. - \ No newline at end of file + diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ada8b24e536..6d534892a88 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -4529,6 +4529,11 @@ Chcete-li nahradit toto výchozí dialogové okno, nastavte popisovač události Spravuje kolekci obrázků, které standardně používají ovládací prvky, jako například ListView, TreeView nebo ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Poskytuje běhové informace nebo popisný text pro ovládací prvek. @@ -6119,6 +6124,66 @@ Trasování zásobníku, kde došlo k neplatné operaci: Kombinace klíčů je neplatná. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Umožňuje automatické zpracování textu, který je delší než šířka ovládacího prvku popisku. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b119b5d66db..66ab2523ba7 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -4529,6 +4529,11 @@ Behandeln Sie das DataError-Ereignis, um dieses Standarddialogfeld zu ersetzen.< Verwaltet eine Sammlung von Bildern, die in der Regel von anderen Steuerelementen wie ListView, TreeView oder ToolStrip verwendet werden. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Stellt Laufzeitinformationen oder deskriptiven Text für ein Steuerelement bereit. @@ -6119,6 +6124,66 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Ungültige Schlüsselkombination. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Aktiviert die automatische Behandlung von Text, der über die Breite des Bezeichnungssteuerelements hinausgeht. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index a3962b9d0d2..472d3047e83 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -4529,6 +4529,11 @@ Para reemplazar este cuadro de diálogo predeterminado controle el evento DataEr Controla una colección de imágenes que suelen utilizar otros controles como ListView, TreeView o ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Proporciona información en tiempo de ejecución o texto descriptivo para un control. @@ -6119,6 +6124,66 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: La combinación de teclas no es válida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Permite el control automático del texto que se extiende más allá del ancho del control de la etiqueta. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f4a84e53ec2..1217a92943d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -4529,6 +4529,11 @@ Pour remplacer cette boîte de dialogue par défaut, traitez l'événement DataE Gère une collection d'images qui sont généralement utilisées par d'autres contrôles tels que ListView, TreeView et ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fournit des informations d'exécution ou un texte descriptif pour un contrôle. @@ -6119,6 +6124,66 @@ Cette opération non conforme s'est produite sur la trace de la pile : La combinaison de touches n'est pas valide. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Active la gestion automatique du texte qui dépasse les limites de largeur du contrôle label. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index e57b323219c..4717ba4bada 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -4529,6 +4529,11 @@ Per sostituire questa finestra di dialogo, gestisci l'evento DataError. Gestisce una raccolta di immagini utilizzate in genere da altri controlli, quali ListView, TreeView o ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fornisce informazioni di runtime o testo descrittivo per un controllo. @@ -6119,6 +6124,66 @@ Analisi dello stack dove si è verificata l'operazione non valida: Combinazione di tasti non valida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Consente la gestione automatica del testo che non è contenuto all'interno della larghezza del controllo etichetta. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index a9b6361d02e..9e8a7942a99 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -4529,6 +4529,11 @@ To replace this default dialog please handle the DataError event. ListView、TreeView、または ToolStrip のような他のコントロールが通常使用するイメージのコレクションを管理します。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. コントロールの実行時の情報または説明用のテキストを提供します。 @@ -6119,6 +6124,66 @@ Stack trace where the illegal operation occurred was: キーの組み合わせが有効ではありません。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. ラベル コントロールの幅を越えるテキストの自動処理を有効にします。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 95e7771c930..ee8b4d9fe51 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -4529,6 +4529,11 @@ To replace this default dialog please handle the DataError event. ListView, TreeView 또는 ToolStrip과 같은 다른 컨트롤에서 일반적으로 사용하는 이미지 컬렉션을 관리합니다. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 컨트롤에 대한 설명 텍스트나 런타임 정보를 제공합니다. @@ -6119,6 +6124,66 @@ Stack trace where the illegal operation occurred was: 키 조합이 잘못되었습니다. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. 레이블 컨트롤의 너비보다 큰 텍스트를 자동으로 처리합니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index 2457b8b3cae..dcad9cff559 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -4529,6 +4529,11 @@ Aby zamienić to domyślne okno dialogowe, obsłuż zdarzenie DataError.Zarządza kolekcją obrazów, które są zazwyczaj używane przez inne formanty, takie jak ListView, TreeView lub ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Wyświetla informacje o wykonaniu lub tekst opisowy dla formantu. @@ -6119,6 +6124,66 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Kombinacja kluczy jest nieprawidłowa. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Włącza automatyczną obsługę tekstu, który mieści się w szerokości formantu etykiety. 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 9927719999e..a269486b5de 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -4529,6 +4529,11 @@ Para substituir a caixa de diálogo padrão, manipule o evento DataError.Gerencia uma coleção de imagens geralmente usadas por outros controles, como ListView, TreeView ou ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fornece informações em tempo de execução ou texto descritivo para um controle. @@ -6119,6 +6124,66 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: A combinação de chave não é válida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Permite a manipulação automática de texto que ultrapassa a largura do controle de rótulo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index dcf9cd25e67..ca251ad870a 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -4529,6 +4529,11 @@ To replace this default dialog please handle the DataError event. Управляет коллекцией изображений, которые обычно используются другими элементами управления (например, ListView, TreeView или ToolStrip). + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Предоставляет элементу управления текст описания либо информацию во время выполнения. @@ -6119,6 +6124,66 @@ Stack trace where the illegal operation occurred was: Недопустимая комбинация ключа. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Включает автоматическую обработку текста, выходящего за пределы ширины элемента управления с подписью. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 7eb5616b6fd..109cb3e858b 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -4529,6 +4529,11 @@ Bu varsayılan iletişim kutusunu değiştirmek için, lütfen DataError olayın Genellikle ListView, TreeView veya ToolStrip gibi diğer denetimler tarafından kullanılan resim koleksiyonunu düzenler. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Bir denetim için çalışma zamanı bilgileri veya açıklayıcı metin sağlar. @@ -6119,6 +6124,66 @@ Geçersiz işlemin gerçekleştiği yığın izi: Anahtar birleşimi geçerli değil. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Etiket denetimi genişliğini aşan metnin otomatik işlenmesini sağlar. 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 1282352a5da..b306cdbaa51 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -4529,6 +4529,11 @@ To replace this default dialog please handle the DataError event. 管理通常由其他控件(如 ListView、TreeView 或 ToolStrip)使用的图像集合。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 为控件提供运行时信息或说明性文字。 @@ -6119,6 +6124,66 @@ Stack trace where the illegal operation occurred was: 组合键无效。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label 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 8991310a36d..dcfb9722778 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -4529,6 +4529,11 @@ To replace this default dialog please handle the DataError event. 管理通常由其他控制項 (例如 ListView、TreeView 或 ToolStrip) 使用的影像集合。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 提供控制項的執行階段資訊或描述文字。 @@ -6119,6 +6124,66 @@ Stack trace where the illegal operation occurred was: 組合鍵無效。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. 啟用自動處理,以處理超出標籤控制項寬度的文件。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs new file mode 100644 index 00000000000..30bf82ff0b8 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs @@ -0,0 +1,1145 @@ +// 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 System.ComponentModel.Design; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Input; +using Windows.Win32.System.Power; +using Windows.Win32.System.Threading; + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Manages common kiosk-mode behavior for a WinForms form. +/// +/// +/// +/// is a component that can be dropped on a +/// form or on a user control at design time. Set +/// to the owning container and the component resolves the containing +/// when fullscreen behavior is needed. +/// +/// +/// The component can make the resolved form fullscreen, optionally cover the +/// taskbar, keep the form topmost while fullscreen, suppress display and +/// system sleep, hide the mouse pointer after inactivity, and notify the +/// application when user or system activity wakes the kiosk experience. +/// +/// +/// +/// +/// public partial class MainForm : Form +/// { +/// private readonly KioskModeManager _kioskModeManager; +/// +/// public MainForm() +/// { +/// InitializeComponent(); +/// +/// _kioskModeManager = new KioskModeManager +/// { +/// ContainerControl = this, +/// HideTaskbar = true, +/// TopMostInFullScreen = true, +/// MousePointerAutoHideDelay = 3000, +/// SuppressPowerSaving = true +/// }; +/// +/// _kioskModeManager.Wakeup += (sender, e) => +/// { +/// if (e.Source == KioskModeWakeupSource.Mouse) +/// { +/// // Refresh the kiosk surface after mouse activity. +/// } +/// }; +/// +/// if (!_kioskModeManager.FullScreen) +/// { +/// _kioskModeManager.ToggleFullScreen(); +/// } +/// } +/// } +/// +/// +[DefaultProperty(nameof(ToggleFullScreenKey))] +[ToolboxItemFilter("System.Windows.Forms")] +[SRDescription(nameof(SR.DescriptionKioskModeManager))] +public class KioskModeManager : Component, ISupportInitialize +{ + private const uint PowerRequestContextVersion = 0; + private const POWER_REQUEST_CONTEXT_FLAGS PowerRequestContextSimpleString = (POWER_REQUEST_CONTEXT_FLAGS)0x00000001; + private const uint NotifyForThisSession = 0; + private const nint PbtApmResumeSuspend = 0x0007; + private const nint PbtApmResumeAutomatic = 0x0012; + private const nint WtsConsoleConnect = 0x0001; + private const nint WtsRemoteConnect = 0x0003; + private const nint WtsSessionLogon = 0x0005; + private const nint WtsSessionUnlock = 0x0008; + private const string PowerRequestReason = "WinForms KioskModeManager: Preventing screen saver and sleep"; + + private static readonly object s_containerControlChangedEvent = new(); + private static readonly object s_fullScreenChangedEvent = new(); + private static readonly object s_wakeupEvent = new(); + + private ContainerControl? _containerControl; + private bool _containerControlExplicitlySet; + private Form? _targetForm; + private bool _isFullScreen; + private bool _pendingFullScreen; + private bool _initializing; + private bool _isCursorHidden; + private KioskModeMessageFilter? _messageFilter; + private KioskModeFormObserver? _formObserver; + private Timer? _mousePointerAutoHideTimer; + + private FormBorderStyle _savedBorderStyle; + private FormWindowState _savedWindowState; + private Rectangle _savedBounds; + private bool _savedTopMost; + + private bool _hideTaskbar; + private bool _topMostInFullScreen; + private Keys _toggleFullScreenKey = Keys.F11; + private bool _escapeExitsFullScreen = true; + private bool _suppressPowerSaving; + private int _mousePointerAutoHideDelay; + private ICommand? _wakeUpCommand; + + private HANDLE _powerRequestHandle; + + /// + /// Initializes a new instance of the class. + /// + public KioskModeManager() + { + } + + /// + /// Initializes a new instance of the class + /// and adds it to the specified container. + /// + /// The container that owns the component. + /// + /// is . + /// + public KioskModeManager(IContainer container) + { + ArgumentNullException.ThrowIfNull(container); + container.Add(this); + } + + /// + /// Gets or sets the associated with the component. + /// + /// + /// + /// When the component is sited at design time and + /// has not been set explicitly, the component resolves the root component of the + /// designer host (typically the owning or ) + /// and assigns it as the . An explicitly assigned + /// is never overwritten. + /// + /// + public override ISite? Site + { + get => base.Site; + set + { + base.Site = value; + + if (value is not null + && !_containerControlExplicitlySet + && value.GetService(typeof(IDesignerHost)) is IDesignerHost host + && host.RootComponent is ContainerControl root) + { + SetContainerControl(root, isExplicitAssignment: false); + } + } + } + + /// + /// Gets or sets the container whose containing form is controlled by this + /// component. + /// + /// + /// A that is either a or + /// a child container that can resolve a parent form; otherwise, + /// . + /// + /// + /// + /// This property intentionally uses rather + /// than . A can be placed + /// on a at design time, and that user control + /// can later be hosted by a form. Restricting the property to + /// would make that design-time scenario inconsistent. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerContainerControlDescr))] + [DefaultValue(null)] + public ContainerControl? ContainerControl + { + get => _containerControl; + set => SetContainerControl(value, isExplicitAssignment: true); + } + + /// + /// Occurs when the value of changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.KioskModeManagerContainerControlChangedDescr))] + public event EventHandler? ContainerControlChanged + { + add => Events.AddHandler(s_containerControlChangedEvent, value); + remove => Events.RemoveHandler(s_containerControlChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnContainerControlChanged(EventArgs e) + { + if (Events[s_containerControlChangedEvent] is EventHandler handler) + { + handler(this, e); + } + } + + /// + /// Gets or sets a value indicating whether fullscreen mode covers the + /// Windows taskbar. + /// + /// + /// to size the form to the complete screen bounds; + /// to use normal maximized form behavior. + /// The default is . + /// + /// + /// + /// When this property is , the form is maximized + /// and Windows keeps the taskbar available according to the user's shell + /// settings. When this property is , the form is + /// sized to the screen bounds so the kiosk surface covers the taskbar. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerHideTaskbarDescr))] + [DefaultValue(false)] + public bool HideTaskbar + { + get => _hideTaskbar; + set + { + if (_hideTaskbar == value) + { + return; + } + + _hideTaskbar = value; + + if (_isFullScreen && _targetForm is not null) + { + ApplyFullScreen(_targetForm); + } + } + } + + /// + /// Gets or sets a value indicating whether the form is topmost while + /// fullscreen. + /// + /// + /// to set while + /// fullscreen; otherwise, . The default is + /// . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerTopMostInFullScreenDescr))] + [DefaultValue(false)] + public bool TopMostInFullScreen + { + get => _topMostInFullScreen; + set + { + if (_topMostInFullScreen == value) + { + return; + } + + _topMostInFullScreen = value; + + if (_isFullScreen && _targetForm is not null) + { + _targetForm.TopMost = value; + } + } + } + + /// + /// Gets or sets the key that toggles fullscreen mode. + /// + /// + /// A value that is handled without modifiers. The + /// default is . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerToggleFullScreenKeyDescr))] + [DefaultValue(Keys.F11)] + public Keys ToggleFullScreenKey + { + get => _toggleFullScreenKey; + set + { + if (_toggleFullScreenKey == value) + { + return; + } + + _toggleFullScreenKey = value; + } + } + + /// + /// Gets or sets a value indicating whether pressing Escape exits + /// fullscreen mode. + /// + /// + /// to exit fullscreen mode when Escape is pressed + /// without modifiers; otherwise, . The default is + /// . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerEscapeExitsFullScreenDescr))] + [DefaultValue(true)] + public bool EscapeExitsFullScreen + { + get => _escapeExitsFullScreen; + set + { + if (_escapeExitsFullScreen == value) + { + return; + } + + _escapeExitsFullScreen = value; + } + } + + /// + /// Gets or sets a value indicating whether the component requests that + /// Windows keep the display and system awake. + /// + /// + /// to request that Windows suppress display sleep + /// and system sleep; otherwise, . The default is + /// . + /// + /// + /// + /// This property uses the Windows power request APIs. It prevents ordinary + /// display and system idle sleep while enabled, but it does not override + /// every system policy and does not configure wake timers, Wake-on-LAN, or + /// voice activation. + /// + /// + /// + /// Windows could not create or activate the power request. + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerSuppressPowerSavingDescr))] + [DefaultValue(false)] + public bool SuppressPowerSaving + { + get => _suppressPowerSaving; + set + { + if (_suppressPowerSaving == value) + { + return; + } + + _suppressPowerSaving = value; + UpdatePowerRequest(); + } + } + + /// + /// Gets or sets the amount of time, in milliseconds, before the mouse + /// pointer is hidden while fullscreen. + /// + /// + /// The inactivity delay in milliseconds. A value of 0 disables automatic + /// pointer hiding. The default is 0. + /// + /// + /// + /// The timer is active only while the component is in fullscreen mode. + /// When the user moves the mouse after the component hid the pointer, the + /// pointer is shown immediately and the timer starts again. + /// + /// + /// + /// is less than 0. + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerMousePointerAutoHideDelayDescr))] + [DefaultValue(0)] + public int MousePointerAutoHideDelay + { + get => _mousePointerAutoHideDelay; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + + if (_mousePointerAutoHideDelay == value) + { + return; + } + + _mousePointerAutoHideDelay = value; + + if (value == 0) + { + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + } + else if (_isFullScreen) + { + RestartMousePointerAutoHideTimer(); + } + } + } + + /// + /// Gets or sets a value indicating whether the resolved form is currently + /// fullscreen. + /// + /// + /// if the component has placed the form in + /// fullscreen mode; otherwise, . + /// + /// + /// + /// Setting this property to enters fullscreen mode and + /// setting it to restores the previously saved form + /// state. The property is bindable so it can participate in two-way data + /// binding. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerFullScreenDescr))] + [Bindable(true)] + [DefaultValue(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool FullScreen + { + get => _pendingFullScreen || _isFullScreen; + set + { + if (!value) + { + _pendingFullScreen = false; + ExitFullScreen(); + } + else if (_initializing || DesignMode || !EnterFullScreen()) + { + _pendingFullScreen = true; + } + else + { + _pendingFullScreen = false; + } + } + } + + /// + /// Occurs when the fullscreen state changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.KioskModeManagerFullScreenChangedDescr))] + public event EventHandler? FullScreenChanged + { + add => Events.AddHandler(s_fullScreenChangedEvent, value); + remove => Events.RemoveHandler(s_fullScreenChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnFullScreenChanged(EventArgs e) + { + if (Events[s_fullScreenChangedEvent] is EventHandler handler) + { + handler(this, e); + } + } + + /// + /// Occurs when keyboard, mouse, power resume, or session activity wakes + /// the kiosk experience. + /// + /// + /// + /// The event reports activity that the component can observe on the UI + /// thread or through Windows power/session notifications. It does not mean + /// that the component caused the computer to wake from sleep, and it does + /// not configure voice wake, network wake, or wake timers. + /// + /// + [SRCategory(nameof(SR.CatAction))] + [SRDescription(nameof(SR.KioskModeManagerWakeupDescr))] + public event KioskModeWakeupEventHandler? Wakeup + { + add => Events.AddHandler(s_wakeupEvent, value); + remove => Events.RemoveHandler(s_wakeupEvent, value); + } + + /// + /// Raises the event. + /// + /// A that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnWakeup(KioskModeWakeupEventArgs e) + { + if (Events[s_wakeupEvent] is KioskModeWakeupEventHandler handler) + { + handler(this, e); + } + + if (_wakeUpCommand is not null) + { + // The wakeup source is passed as its string name so the bound command stays + // independent of the WinForms-specific enum type. + string parameter = e.Source.ToString(); + if (_wakeUpCommand.CanExecute(parameter)) + { + _wakeUpCommand.Execute(parameter); + } + } + } + + /// + /// Gets or sets the command that is executed whenever the + /// event is raised. + /// + /// + /// An to execute on every wakeup, or + /// to execute no command. The default is . + /// + /// + /// + /// The command is executed with the wakeup source name (the result of + /// .) as the command + /// parameter, keeping the command independent of the WinForms-specific enum type. + /// + /// + [SRCategory(nameof(SR.CatAction))] + [SRDescription(nameof(SR.KioskModeManagerWakeUpCommandDescr))] + [Bindable(true)] + [DefaultValue(null)] + public ICommand? WakeUpCommand + { + get => _wakeUpCommand; + set => _wakeUpCommand = value; + } + + /// + /// Places the resolved form in fullscreen mode. + /// + /// + /// +/// The component saves the form's border style, window state, bounds, and +/// topmost state before applying fullscreen mode. Call +/// again to restore the saved state. + /// + /// + private bool EnterFullScreen() + { + if (_isFullScreen || _initializing || DesignMode) + { + return _isFullScreen; + } + + Form? targetForm = ResolveTargetForm(); + if (targetForm is null) + { + return false; + } + + _savedBorderStyle = targetForm.FormBorderStyle; + _savedWindowState = targetForm.WindowState; + _savedBounds = targetForm.WindowState == FormWindowState.Normal + ? targetForm.Bounds + : targetForm.RestoreBounds; + _savedTopMost = targetForm.TopMost; + + ApplyFullScreen(targetForm); + SetFullScreenState(true); + _pendingFullScreen = false; + RestartMousePointerAutoHideTimer(); + return true; + } + + /// + /// Restores the form state that was saved when fullscreen mode was + /// entered. + /// + private void ExitFullScreen() + { + if (!_isFullScreen || _targetForm is null) + { + return; + } + + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + + _targetForm.FormBorderStyle = _savedBorderStyle; + _targetForm.TopMost = _savedTopMost; + _targetForm.WindowState = FormWindowState.Normal; + _targetForm.Bounds = _savedBounds; + _targetForm.WindowState = _savedWindowState; + + SetFullScreenState(false); + } + + /// + /// Updates the fullscreen state and raises + /// when the value changes. + /// + /// The new fullscreen state. + private void SetFullScreenState(bool value) + { + if (_isFullScreen == value) + { + return; + } + + _isFullScreen = value; + OnFullScreenChanged(EventArgs.Empty); + } + + /// + /// Toggles the resolved form between fullscreen and restored mode. + /// + /// + /// + /// Use to determine the current state before + /// calling this method when the application needs an explicit target + /// state. + /// + /// + public void ToggleFullScreen() + => FullScreen = !FullScreen; + + void ISupportInitialize.BeginInit() + { + _initializing = true; + } + + void ISupportInitialize.EndInit() + { + _initializing = false; + + AttachToForm(); + UpdatePowerRequest(); + + if (_pendingFullScreen) + { + FullScreen = true; + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _pendingFullScreen = false; + ExitFullScreen(); + DetachFromForm(); + _mousePointerAutoHideTimer?.Dispose(); + _mousePointerAutoHideTimer = null; + ReleasePowerRequest(); + } + + base.Dispose(disposing); + } + + private void SetContainerControl(ContainerControl? value, bool isExplicitAssignment) + { + if (_containerControl == value) + { + if (isExplicitAssignment) + { + _containerControlExplicitlySet = true; + } + + return; + } + + if (_isFullScreen) + { + _pendingFullScreen = false; + ExitFullScreen(); + } + + if (value is null) + { + _pendingFullScreen = false; + } + + DetachFromForm(); + _containerControl = value; + if (isExplicitAssignment) + { + _containerControlExplicitlySet = true; + } + + AttachToForm(); + OnContainerControlChanged(EventArgs.Empty); + } + + private void AttachToForm() + { + if (_initializing || DesignMode || _containerControl is null) + { + return; + } + + EnsureMessageMonitoring(); + ResolveTargetForm(); + if (_pendingFullScreen && _targetForm is not null) + { + EnterFullScreen(); + } + } + + private void DetachFromForm() + { + if (_messageFilter is not null) + { + Application.RemoveMessageFilter(_messageFilter); + _messageFilter = null; + } + + _formObserver?.Detach(); + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + _targetForm = null; + } + + private Form? ResolveTargetForm() + { + // ContainerControl intentionally supports both Form and UserControl + // design-time placement. FindForm handles the UserControl case. + Form? targetForm = _containerControl as Form ?? _containerControl?.FindForm(); + if (!ReferenceEquals(_targetForm, targetForm)) + { + _targetForm = targetForm; + UpdateFormObserver(); + } + + return _targetForm; + } + + private void EnsureMessageMonitoring() + { + // IMessageFilter observes input for child controls without changing + // Form.KeyPreview or installing global keyboard/mouse hooks. + if (_messageFilter is null) + { + _messageFilter = new KioskModeMessageFilter(this); + Application.AddMessageFilter(_messageFilter); + } + } + + private void ApplyFullScreen(Form form) + { + // Apply fullscreen from a normal state so bounds and border changes do + // not operate on Windows' maximized window rectangle. + form.WindowState = FormWindowState.Normal; + form.FormBorderStyle = FormBorderStyle.None; + + if (_topMostInFullScreen) + { + form.TopMost = true; + } + + if (_hideTaskbar) + { + Screen screen = Screen.FromControl(form); + form.Bounds = screen.Bounds; + } + else + { + form.WindowState = FormWindowState.Maximized; + } + } + + private void UpdateFormObserver() + { + if (DesignMode) + { + return; + } + + _formObserver ??= new KioskModeFormObserver(this); + _formObserver.Attach(_targetForm); + } + + private void UpdatePowerRequest() + { + if (_initializing || DesignMode) + { + return; + } + + if (_suppressPowerSaving) + { + CreatePowerRequest(); + } + else + { + ReleasePowerRequest(); + } + } + + private void ProcessPowerBroadcast(WPARAM powerEvent) + { + nint value = (nint)powerEvent.Value; + if (value is PbtApmResumeAutomatic or PbtApmResumeSuspend) + { + RaiseWakeup(KioskModeWakeupSource.PowerResume); + } + } + + private void ProcessSessionChange(WPARAM sessionEvent) + { + nint value = (nint)sessionEvent.Value; + if (value is WtsConsoleConnect or WtsRemoteConnect or WtsSessionLogon or WtsSessionUnlock) + { + RaiseWakeup(KioskModeWakeupSource.Session); + } + } + + private void RestartMousePointerAutoHideTimer() + { + // Use a WinForms timer so cursor changes happen on the UI thread that + // receives the input messages restarting this timer. + if (!_isFullScreen || _mousePointerAutoHideDelay == 0) + { + return; + } + + _mousePointerAutoHideTimer ??= new Timer(); + _mousePointerAutoHideTimer.Stop(); + _mousePointerAutoHideTimer.Interval = _mousePointerAutoHideDelay; + _mousePointerAutoHideTimer.Tick -= OnMousePointerAutoHideTimerTick; + _mousePointerAutoHideTimer.Tick += OnMousePointerAutoHideTimerTick; + _mousePointerAutoHideTimer.Start(); + } + + private void StopMousePointerAutoHideTimer() + { + _mousePointerAutoHideTimer?.Stop(); + } + + private void OnMousePointerAutoHideTimerTick(object? sender, EventArgs e) + { + StopMousePointerAutoHideTimer(); + + if (!_isCursorHidden) + { + Cursor.Hide(); + _isCursorHidden = true; + } + } + + private void ProcessKeyboardActivity(Keys keyData, Keys modifiers) + { + // Any keyboard input wakes the kiosk experience, even when it does not + // match one of the configured fullscreen control keys. + RaiseWakeup(KioskModeWakeupSource.Keyboard); + + Keys keyCode = keyData & Keys.KeyCode; + + if (keyCode == _toggleFullScreenKey && modifiers == Keys.None) + { + ToggleFullScreen(); + } + else if (keyCode == Keys.Escape && modifiers == Keys.None + && _escapeExitsFullScreen && _isFullScreen) + { + ExitFullScreen(); + } + } + + private void ProcessMouseActivity() + { + ShowMousePointerIfHidden(); + RestartMousePointerAutoHideTimer(); + RaiseWakeup(KioskModeWakeupSource.Mouse); + } + + private void ShowMousePointerIfHidden() + { + // Cursor.Hide and Cursor.Show are counter-based. Only balance hides + // performed by this component. + if (!_isCursorHidden) + { + return; + } + + Cursor.Show(); + _isCursorHidden = false; + } + + private void RaiseWakeup(KioskModeWakeupSource source) + => OnWakeup(new KioskModeWakeupEventArgs(source)); + + private unsafe void CreatePowerRequest() + { + // PowerClearRequest clears the active request, but only CloseHandle + // releases the native HANDLE returned by PowerCreateRequest. + if (!_powerRequestHandle.IsNull) + { + return; + } + + REASON_CONTEXT context = new() + { + Version = PowerRequestContextVersion, + Flags = PowerRequestContextSimpleString, + }; + + nint reasonString = Marshal.StringToHGlobalUni(PowerRequestReason); + + try + { + context.Reason.SimpleReasonString = (PWSTR)(char*)reasonString; + _powerRequestHandle = PInvoke.PowerCreateRequest(in context); + } + finally + { + Marshal.FreeHGlobal(reasonString); + } + + if (_powerRequestHandle.IsNull) + { + throw new Win32Exception(); + } + + try + { + if (!PInvoke.PowerSetRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestDisplayRequired) + || !PInvoke.PowerSetRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestSystemRequired)) + { + throw new Win32Exception(); + } + } + catch + { + ReleasePowerRequest(); + throw; + } + } + + private void ReleasePowerRequest() + { + if (_powerRequestHandle.IsNull) + { + return; + } + + PInvoke.PowerClearRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestDisplayRequired); + PInvoke.PowerClearRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestSystemRequired); + PInvoke.CloseHandle(_powerRequestHandle); + _powerRequestHandle = default; + } + + private bool IsMessageFromMonitoredControl(HWND hwnd) + { + if (_targetForm is not null + && !_targetForm.IsDisposed + && (hwnd == _targetForm.HWND || PInvoke.IsChild(_targetForm, hwnd))) + { + return true; + } + + return _containerControl is not null + && !_containerControl.IsDisposed + && _containerControl.IsHandleCreated + && (hwnd == _containerControl.HWND || PInvoke.IsChild(_containerControl, hwnd)); + } + + private bool ProcessMessage(Message message) + { + if (_containerControl is null || DesignMode) + { + return false; + } + + if (_targetForm is null || _targetForm.IsDisposed) + { + ResolveTargetForm(); + if (_pendingFullScreen && _targetForm is not null) + { + EnterFullScreen(); + } + } + + if (!IsMessageFromMonitoredControl(message.HWND)) + { + return false; + } + + if (message.MsgInternal == PInvokeCore.WM_KEYDOWN + || message.MsgInternal == PInvokeCore.WM_SYSKEYDOWN) + { + ProcessKeyboardActivity((Keys)(nint)message.WParamInternal, Control.ModifierKeys & Keys.Modifiers); + } + else if (message.MsgInternal >= PInvokeCore.WM_MOUSEFIRST + && message.MsgInternal <= PInvokeCore.WM_MOUSELAST) + { + ProcessMouseActivity(); + } + + return false; + } + + /// + /// Observes keyboard and mouse messages that belong to the target form. + /// + private sealed class KioskModeMessageFilter : IMessageFilter + { + private readonly KioskModeManager _owner; + + public KioskModeMessageFilter(KioskModeManager owner) + { + _owner = owner; + } + + public bool PreFilterMessage(ref Message m) + => _owner.ProcessMessage(m); + } + + /// + /// Observes power and session messages that belong to the target form. + /// + private sealed class KioskModeFormObserver : NativeWindow + { + private readonly KioskModeManager _owner; + private Form? _form; + private bool _sessionNotificationsRegistered; + + public KioskModeFormObserver(KioskModeManager owner) + { + _owner = owner; + } + + public void Attach(Form? form) + { + if (ReferenceEquals(_form, form)) + { + return; + } + + Detach(); + + if (form is null || form.IsDisposed) + { + return; + } + + _form = form; + form.HandleCreated += OnFormHandleCreated; + form.HandleDestroyed += OnFormHandleDestroyed; + + if (form.IsHandleCreated) + { + OnFormHandleCreated(form, EventArgs.Empty); + } + } + + public void Detach() + { + if (_form is not null) + { + _form.HandleCreated -= OnFormHandleCreated; + _form.HandleDestroyed -= OnFormHandleDestroyed; + _form = null; + } + + UnregisterSessionNotifications(); + if (!HWND.IsNull) + { + ReleaseHandle(); + } + } + + private void OnFormHandleCreated(object? sender, EventArgs e) + { + if (_form is null || _form.IsDisposed || !_form.IsHandleCreated) + { + return; + } + + AssignHandle(_form.HWND); + RegisterSessionNotifications(); + } + + private void OnFormHandleDestroyed(object? sender, EventArgs e) + { + UnregisterSessionNotifications(); + if (!HWND.IsNull) + { + ReleaseHandle(); + } + } + + private void RegisterSessionNotifications() + { + if (_sessionNotificationsRegistered || HWND.IsNull) + { + return; + } + + _sessionNotificationsRegistered = PInvoke.WTSRegisterSessionNotification(HWND, NotifyForThisSession); + } + + private void UnregisterSessionNotifications() + { + if (!_sessionNotificationsRegistered) + { + return; + } + + PInvoke.WTSUnRegisterSessionNotification(HWND); + _sessionNotificationsRegistered = false; + } + + protected override void WndProc(ref Message m) + { + if (m.MsgInternal == PInvokeCore.WM_POWERBROADCAST) + { + _owner.ProcessPowerBroadcast(m.WParamInternal); + } + else if (m.MsgInternal == PInvokeCore.WM_WTSSESSION_CHANGE) + { + _owner.ProcessSessionChange(m.WParamInternal); + } + + base.WndProc(ref m); + } + } +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs new file mode 100644 index 00000000000..1ad14d4fcc2 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs @@ -0,0 +1,36 @@ +// 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 data for the event. +/// +/// +/// +/// The property identifies the kind of activity the +/// component observed. Applications can use this value to distinguish direct +/// user activity, such as mouse or keyboard input, from system activity such +/// as power resume or session unlock. +/// +/// +public class KioskModeWakeupEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the + /// class. + /// + /// The source of the wakeup notification. + public KioskModeWakeupEventArgs(KioskModeWakeupSource source) + { + SourceGenerated.EnumValidator.Validate(source, nameof(source)); + Source = source; + } + + /// + /// Gets the source of the wakeup notification. + /// + public KioskModeWakeupSource Source { get; } +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs new file mode 100644 index 00000000000..b2784bb3b3c --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs @@ -0,0 +1,14 @@ +// 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 +/// +/// Represents the method that will handle the +/// event. +/// +/// The source of the event. +/// A that contains the event data. +public delegate void KioskModeWakeupEventHandler(object? sender, KioskModeWakeupEventArgs e); +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs new file mode 100644 index 00000000000..6c9a05784c4 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs @@ -0,0 +1,35 @@ +// 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 the source of a +/// notification. +/// +public enum KioskModeWakeupSource +{ + /// + /// The wakeup notification was caused by keyboard activity. + /// + Keyboard = 0, + + /// + /// The wakeup notification was caused by mouse activity. + /// + Mouse = 1, + + /// + /// The wakeup notification was caused by the system resuming from a low + /// power state. + /// + PowerResume = 2, + + /// + /// The wakeup notification was caused by a Windows session activity, such + /// as logon or unlock. + /// + Session = 3, +} +#endif diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs new file mode 100644 index 00000000000..c5d3adcdff4 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs @@ -0,0 +1,423 @@ +// 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 System.ComponentModel.Design; +using System.Drawing; +using System.Windows.Input; +using Moq; + +namespace System.Windows.Forms.Tests; + +public class KioskModeManagerTests +{ + [WinFormsFact] + public void KioskModeManager_Ctor_Default() + { + using SubKioskModeManager manager = new(); + + Assert.Null(manager.Container); + Assert.Null(manager.ContainerControl); + Assert.False(manager.DesignMode); + Assert.True(manager.EscapeExitsFullScreen); + Assert.False(manager.HideTaskbar); + Assert.False(manager.FullScreen); + Assert.Equal(0, manager.MousePointerAutoHideDelay); + Assert.Null(manager.Site); + Assert.False(manager.SuppressPowerSaving); + Assert.Equal(Keys.F11, manager.ToggleFullScreenKey); + Assert.False(manager.TopMostInFullScreen); + } + + [WinFormsFact] + public void KioskModeManager_Ctor_IContainer() + { + using Container container = new(); + using KioskModeManager manager = new(container); + + Assert.Same(container, manager.Container); + Assert.NotNull(manager.Site); + } + + [WinFormsFact] + public void KioskModeManager_Ctor_NullContainer_ThrowsArgumentNullException() + { + Assert.Throws("container", () => new KioskModeManager(null)); + } + + [WinFormsFact] + public void KioskModeManager_ContainerControl_Set_GetReturnsExpected() + { + using Form form = new(); + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + Assert.Same(form, manager.ContainerControl); + + manager.ContainerControl = form; + Assert.Same(form, manager.ContainerControl); + + manager.ContainerControl = null; + Assert.Null(manager.ContainerControl); + } + + [WinFormsFact] + public void KioskModeManager_ContainerControl_SetWithHandler_CallsContainerControlChanged() + { + using Form form = new(); + using KioskModeManager manager = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(manager, sender); + Assert.Same(EventArgs.Empty, e); + callCount++; + }; + + manager.ContainerControlChanged += handler; + manager.ContainerControl = form; + Assert.Equal(1, callCount); + + manager.ContainerControl = form; + Assert.Equal(1, callCount); + + manager.ContainerControl = null; + Assert.Equal(2, callCount); + + manager.ContainerControlChanged -= handler; + manager.ContainerControl = form; + Assert.Equal(2, callCount); + } + + [WinFormsTheory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3000)] + public void KioskModeManager_MousePointerAutoHideDelay_Set_GetReturnsExpected(int value) + { + using KioskModeManager manager = new() + { + MousePointerAutoHideDelay = value + }; + + Assert.Equal(value, manager.MousePointerAutoHideDelay); + + manager.MousePointerAutoHideDelay = value; + Assert.Equal(value, manager.MousePointerAutoHideDelay); + } + + [WinFormsFact] + public void KioskModeManager_MousePointerAutoHideDelay_SetNegative_ThrowsArgumentOutOfRangeException() + { + using KioskModeManager manager = new(); + + Assert.Throws("value", () => manager.MousePointerAutoHideDelay = -1); + } + + [WinFormsTheory] + [EnumData] + public void KioskModeWakeupEventArgs_Ctor_Source(KioskModeWakeupSource source) + { + KioskModeWakeupEventArgs eventArgs = new(source); + + Assert.Equal(source, eventArgs.Source); + } + + [WinFormsTheory] + [InvalidEnumData] + public void KioskModeWakeupEventArgs_Ctor_InvalidSource_ThrowsInvalidEnumArgumentException(KioskModeWakeupSource source) + { + Assert.Throws("source", () => new KioskModeWakeupEventArgs(source)); + } + + [WinFormsTheory] + [EnumData] + public void KioskModeManager_OnWakeup_Invoke_CallsWakeup(KioskModeWakeupSource source) + { + using SubKioskModeManager manager = new(); + KioskModeWakeupEventArgs eventArgs = new(source); + int callCount = 0; + KioskModeWakeupEventHandler handler = (sender, e) => + { + Assert.Same(manager, sender); + Assert.Same(eventArgs, e); + Assert.Equal(source, e.Source); + callCount++; + }; + + manager.Wakeup += handler; + manager.OnWakeup(eventArgs); + Assert.Equal(1, callCount); + + manager.Wakeup -= handler; + manager.OnWakeup(eventArgs); + Assert.Equal(1, callCount); + } + + [WinFormsFact] + public void KioskModeManager_ToggleFullScreen_Form_RestoresExpected() + { + using Form form = new() + { + Bounds = new Rectangle(10, 20, 300, 200), + FormBorderStyle = FormBorderStyle.FixedDialog, + WindowState = FormWindowState.Normal, + TopMost = false + }; + + using KioskModeManager manager = new() + { + ContainerControl = form, + HideTaskbar = false, + TopMostInFullScreen = true + }; + + manager.ToggleFullScreen(); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + Assert.True(form.TopMost); + Assert.Equal(FormWindowState.Maximized, form.WindowState); + + manager.ToggleFullScreen(); + + Assert.False(manager.FullScreen); + Assert.Equal(FormBorderStyle.FixedDialog, form.FormBorderStyle); + Assert.False(form.TopMost); + Assert.Equal(FormWindowState.Normal, form.WindowState); + Assert.Equal(new Rectangle(10, 20, 300, 200), form.Bounds); + } + + [WinFormsFact] + public void KioskModeManager_ToggleFullScreen_UserControlContainer_UsesParentForm() + { + using Form form = new(); + using UserControl userControl = new(); + form.Controls.Add(userControl); + using KioskModeManager manager = new() + { + ContainerControl = userControl + }; + + manager.ToggleFullScreen(); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + } + + [WinFormsFact] + public void KioskModeManager_ToggleFullScreen_UserControlContainerParentedAfterSet_UsesParentForm() + { + using Form form = new(); + using UserControl userControl = new(); + using KioskModeManager manager = new() + { + ContainerControl = userControl + }; + + form.Controls.Add(userControl); + manager.ToggleFullScreen(); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + } + + [WinFormsFact] + public void KioskModeManager_ContainerControl_SetWhileFullScreen_RestoresPreviousForm() + { + using Form form = new() + { + Bounds = new Rectangle(10, 20, 300, 200), + FormBorderStyle = FormBorderStyle.FixedDialog, + WindowState = FormWindowState.Normal + }; + + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + manager.ToggleFullScreen(); + Assert.True(manager.FullScreen); + + manager.ContainerControl = null; + + Assert.False(manager.FullScreen); + Assert.Equal(FormBorderStyle.FixedDialog, form.FormBorderStyle); + Assert.Equal(FormWindowState.Normal, form.WindowState); + Assert.Equal(new Rectangle(10, 20, 300, 200), form.Bounds); + } + + [WinFormsFact] + public void KioskModeManager_ContainerControl_Set_DoesNotChangeFormKeyPreview() + { + using Form form = new() + { + KeyPreview = false + }; + + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + Assert.False(form.KeyPreview); + } + + [WinFormsFact] + public void KioskModeManager_FullScreen_SetTrueThenFalse_EntersAndExitsFullScreen() + { + using Form form = new() + { + Bounds = new Rectangle(10, 20, 300, 200), + FormBorderStyle = FormBorderStyle.FixedDialog, + WindowState = FormWindowState.Normal + }; + + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + manager.FullScreen = true; + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + + manager.FullScreen = false; + + Assert.False(manager.FullScreen); + Assert.Equal(FormBorderStyle.FixedDialog, form.FormBorderStyle); + Assert.Equal(FormWindowState.Normal, form.WindowState); + Assert.Equal(new Rectangle(10, 20, 300, 200), form.Bounds); + } + + [WinFormsFact] + public void KioskModeManager_FullScreen_SetWithHandler_CallsFullScreenChanged() + { + using Form form = new(); + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(manager, sender); + Assert.Same(EventArgs.Empty, e); + callCount++; + }; + + manager.FullScreenChanged += handler; + + manager.FullScreen = true; + Assert.Equal(1, callCount); + + manager.FullScreen = true; + Assert.Equal(1, callCount); + + manager.FullScreen = false; + Assert.Equal(2, callCount); + + manager.FullScreenChanged -= handler; + manager.FullScreen = true; + Assert.Equal(2, callCount); + } + + [WinFormsTheory] + [EnumData] + public void KioskModeManager_WakeUpCommand_ExecutedOnWakeup_WithSourceName(KioskModeWakeupSource source) + { + using SubKioskModeManager manager = new(); + TestCommand command = new(); + manager.WakeUpCommand = command; + + Assert.Same(command, manager.WakeUpCommand); + + manager.OnWakeup(new KioskModeWakeupEventArgs(source)); + + Assert.Equal(1, command.ExecuteCount); + Assert.Equal(source.ToString(), command.LastParameter); + } + + [WinFormsFact] + public void KioskModeManager_WakeUpCommand_Null_DoesNotThrowOnWakeup() + { + using SubKioskModeManager manager = new(); + + Assert.Null(manager.WakeUpCommand); + + manager.OnWakeup(new KioskModeWakeupEventArgs(KioskModeWakeupSource.Keyboard)); + } + + [WinFormsFact] + public void KioskModeManager_Site_Set_AssignsRootComponentAsContainerControl() + { + using Form form = new(); + Mock host = new(); + host.Setup(h => h.RootComponent).Returns(form); + + Mock site = new(); + site.Setup(s => s.GetService(typeof(IDesignerHost))).Returns(host.Object); + + using KioskModeManager manager = new(); + manager.Site = site.Object; + + Assert.Same(form, manager.ContainerControl); + } + + [WinFormsFact] + public void KioskModeManager_Site_Set_DoesNotOverwriteExplicitContainerControl() + { + using Form explicitForm = new(); + using Form rootForm = new(); + Mock host = new(); + host.Setup(h => h.RootComponent).Returns(rootForm); + + Mock site = new(); + site.Setup(s => s.GetService(typeof(IDesignerHost))).Returns(host.Object); + + using KioskModeManager manager = new() + { + ContainerControl = explicitForm + }; + + manager.Site = site.Object; + + Assert.Same(explicitForm, manager.ContainerControl); + } + + private class TestCommand : ICommand + { + public int ExecuteCount { get; private set; } + + public object LastParameter { get; private set; } + + public event EventHandler CanExecuteChanged + { + add { } + remove { } + } + + public bool CanExecute(object parameter) => true; + + public void Execute(object parameter) + { + ExecuteCount++; + LastParameter = parameter; + } + } + + private class SubKioskModeManager : KioskModeManager + { + public new bool DesignMode => base.DesignMode; + + public new void OnWakeup(KioskModeWakeupEventArgs e) + => base.OnWakeup(e); + } +} From 48f3255f22ced6fb99c6d2e451e0f4d2e58063e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Wed, 1 Jul 2026 17:39:18 -0700 Subject: [PATCH 27/99] Expand KioskModeManager tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Windows/Forms/KioskModeManagerTests.cs | 272 +++++++++++++++++- 1 file changed, 271 insertions(+), 1 deletion(-) diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs index c5d3adcdff4..6c3f1c2d652 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs @@ -11,6 +11,7 @@ namespace System.Windows.Forms.Tests; +#if NET11_0_OR_GREATER public class KioskModeManagerTests { [WinFormsFact] @@ -329,6 +330,50 @@ public void KioskModeManager_FullScreen_SetWithHandler_CallsFullScreenChanged() Assert.Equal(2, callCount); } + [WinFormsTheory] + [NewAndDefaultData] + public void KioskModeManager_OnContainerControlChanged_Invoke_CallsContainerControlChanged(EventArgs eventArgs) + { + using SubKioskModeManager manager = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(manager, sender); + Assert.Same(eventArgs, e); + callCount++; + }; + + manager.ContainerControlChanged += handler; + manager.OnContainerControlChanged(eventArgs); + Assert.Equal(1, callCount); + + manager.ContainerControlChanged -= handler; + manager.OnContainerControlChanged(eventArgs); + Assert.Equal(1, callCount); + } + + [WinFormsTheory] + [NewAndDefaultData] + public void KioskModeManager_OnFullScreenChanged_Invoke_CallsFullScreenChanged(EventArgs eventArgs) + { + using SubKioskModeManager manager = new(); + int callCount = 0; + EventHandler handler = (sender, e) => + { + Assert.Same(manager, sender); + Assert.Same(eventArgs, e); + callCount++; + }; + + manager.FullScreenChanged += handler; + manager.OnFullScreenChanged(eventArgs); + Assert.Equal(1, callCount); + + manager.FullScreenChanged -= handler; + manager.OnFullScreenChanged(eventArgs); + Assert.Equal(1, callCount); + } + [WinFormsTheory] [EnumData] public void KioskModeManager_WakeUpCommand_ExecutedOnWakeup_WithSourceName(KioskModeWakeupSource source) @@ -341,6 +386,8 @@ public void KioskModeManager_WakeUpCommand_ExecutedOnWakeup_WithSourceName(Kiosk manager.OnWakeup(new KioskModeWakeupEventArgs(source)); + Assert.Equal(1, command.CanExecuteCount); + Assert.Equal(source.ToString(), command.LastCanExecuteParameter); Assert.Equal(1, command.ExecuteCount); Assert.Equal(source.ToString(), command.LastParameter); } @@ -355,6 +402,24 @@ public void KioskModeManager_WakeUpCommand_Null_DoesNotThrowOnWakeup() manager.OnWakeup(new KioskModeWakeupEventArgs(KioskModeWakeupSource.Keyboard)); } + [WinFormsFact] + public void KioskModeManager_WakeUpCommand_CanExecuteFalse_DoesNotExecute() + { + using SubKioskModeManager manager = new(); + TestCommand command = new() + { + CanExecuteResult = false + }; + + manager.WakeUpCommand = command; + manager.OnWakeup(new KioskModeWakeupEventArgs(KioskModeWakeupSource.Mouse)); + + Assert.Equal(1, command.CanExecuteCount); + Assert.Equal(KioskModeWakeupSource.Mouse.ToString(), command.LastCanExecuteParameter); + Assert.Equal(0, command.ExecuteCount); + Assert.Null(command.LastParameter); + } + [WinFormsFact] public void KioskModeManager_Site_Set_AssignsRootComponentAsContainerControl() { @@ -392,8 +457,201 @@ public void KioskModeManager_Site_Set_DoesNotOverwriteExplicitContainerControl() Assert.Same(explicitForm, manager.ContainerControl); } + [WinFormsFact] + public void KioskModeManager_Site_SetWithExplicitNull_DoesNotAssignRootComponent() + { + using Form form = new(); + Mock host = new(); + host.Setup(h => h.RootComponent).Returns(form); + + Mock site = new(); + site.Setup(s => s.GetService(typeof(IDesignerHost))).Returns(host.Object); + + using KioskModeManager manager = new() + { + ContainerControl = null + }; + + manager.Site = site.Object; + + Assert.Null(manager.ContainerControl); + } + + [WinFormsFact] + public void KioskModeManager_Site_SetWithoutExplicitContainerControl_UpdatesResolvedRootComponent() + { + using Form firstForm = new(); + using Form secondForm = new(); + + Mock firstHost = new(); + firstHost.Setup(h => h.RootComponent).Returns(firstForm); + Mock secondHost = new(); + secondHost.Setup(h => h.RootComponent).Returns(secondForm); + + Mock firstSite = new(); + firstSite.Setup(s => s.GetService(typeof(IDesignerHost))).Returns(firstHost.Object); + Mock secondSite = new(); + secondSite.Setup(s => s.GetService(typeof(IDesignerHost))).Returns(secondHost.Object); + + using KioskModeManager manager = new(); + manager.Site = firstSite.Object; + Assert.Same(firstForm, manager.ContainerControl); + + manager.Site = secondSite.Object; + Assert.Same(secondForm, manager.ContainerControl); + } + + [WinFormsFact] + public void KioskModeManager_BeginInit_FullScreenSet_DoesNotEnterUntilEndInit() + { + using Form form = new() + { + Bounds = new Rectangle(10, 20, 300, 200), + FormBorderStyle = FormBorderStyle.FixedDialog, + WindowState = FormWindowState.Normal + }; + + using KioskModeManager manager = new(); + ISupportInitialize supportInitialize = manager; + + supportInitialize.BeginInit(); + manager.ContainerControl = form; + manager.FullScreen = true; + + Assert.Equal(FormBorderStyle.FixedDialog, form.FormBorderStyle); + Assert.Equal(FormWindowState.Normal, form.WindowState); + + supportInitialize.EndInit(); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + Assert.Equal(FormWindowState.Maximized, form.WindowState); + } + + [WinFormsFact] + public void KioskModeManager_DisposeWhileFullScreen_RestoresExpected() + { + using Form form = new() + { + Bounds = new Rectangle(10, 20, 300, 200), + FormBorderStyle = FormBorderStyle.FixedDialog, + WindowState = FormWindowState.Normal, + TopMost = false + }; + + KioskModeManager manager = new() + { + ContainerControl = form, + TopMostInFullScreen = true, + FullScreen = true + }; + manager.Dispose(); + + Assert.Equal(FormBorderStyle.FixedDialog, form.FormBorderStyle); + Assert.Equal(FormWindowState.Normal, form.WindowState); + Assert.Equal(new Rectangle(10, 20, 300, 200), form.Bounds); + Assert.False(form.TopMost); + } + + [WinFormsFact] + public void KioskModeManager_ProcessMessage_KeyDownThenKeyUp_TogglesOnce() + { + using Form form = new(); + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + Message keyDownMessage = Message.Create(form.Handle, (int)PInvokeCore.WM_KEYDOWN, (nint)Keys.F11, 0); + Message keyUpMessage = Message.Create(form.Handle, (int)PInvokeCore.WM_KEYUP, (nint)Keys.F11, 0); + + manager.TestAccessor.Dynamic.ProcessMessage(keyDownMessage); + Assert.True(manager.FullScreen); + + manager.TestAccessor.Dynamic.ProcessMessage(keyUpMessage); + Assert.True(manager.FullScreen); + } + + [WinFormsFact] + public void KioskModeManager_ProcessMessage_MouseMove_CallsWakeup() + { + using Form form = new(); + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + int callCount = 0; + KioskModeWakeupEventArgs lastEventArgs = null; + manager.Wakeup += (sender, e) => + { + Assert.Same(manager, sender); + callCount++; + lastEventArgs = e; + }; + + Message message = Message.Create(form.Handle, (int)PInvokeCore.WM_MOUSEMOVE, 0, 0); + manager.TestAccessor.Dynamic.ProcessMessage(message); + + Assert.Equal(1, callCount); + Assert.NotNull(lastEventArgs); + Assert.Equal(KioskModeWakeupSource.Mouse, lastEventArgs.Source); + } + + [WinFormsFact] + public void KioskModeManager_ProcessPowerBroadcast_Resume_CallsWakeup() + { + using SubKioskModeManager manager = new(); + int callCount = 0; + KioskModeWakeupEventArgs lastEventArgs = null; + manager.Wakeup += (sender, e) => + { + Assert.Same(manager, sender); + callCount++; + lastEventArgs = e; + }; + + Message message = Message.Create(IntPtr.Zero, (int)PInvokeCore.WM_POWERBROADCAST, (nint)0x0012, 0); + manager.TestAccessor.Dynamic.ProcessPowerBroadcast(message.WParamInternal); + + Assert.Equal(1, callCount); + Assert.NotNull(lastEventArgs); + Assert.Equal(KioskModeWakeupSource.PowerResume, lastEventArgs.Source); + } + + [WinFormsTheory] + [InlineData(0x0001)] + [InlineData(0x0003)] + [InlineData(0x0005)] + [InlineData(0x0008)] + public void KioskModeManager_ProcessSessionChange_RecognizedReason_CallsWakeup(nint sessionReason) + { + using SubKioskModeManager manager = new(); + int callCount = 0; + KioskModeWakeupEventArgs lastEventArgs = null; + manager.Wakeup += (sender, e) => + { + Assert.Same(manager, sender); + callCount++; + lastEventArgs = e; + }; + + Message message = Message.Create(IntPtr.Zero, 0, sessionReason, 0); + manager.TestAccessor.Dynamic.ProcessSessionChange(message.WParamInternal); + + Assert.Equal(1, callCount); + Assert.NotNull(lastEventArgs); + Assert.Equal(KioskModeWakeupSource.Session, lastEventArgs.Source); + } + private class TestCommand : ICommand { + public bool CanExecuteResult { get; set; } = true; + + public int CanExecuteCount { get; private set; } + + public object LastCanExecuteParameter { get; private set; } + public int ExecuteCount { get; private set; } public object LastParameter { get; private set; } @@ -404,7 +662,12 @@ public event EventHandler CanExecuteChanged remove { } } - public bool CanExecute(object parameter) => true; + public bool CanExecute(object parameter) + { + CanExecuteCount++; + LastCanExecuteParameter = parameter; + return CanExecuteResult; + } public void Execute(object parameter) { @@ -417,7 +680,14 @@ private class SubKioskModeManager : KioskModeManager { public new bool DesignMode => base.DesignMode; + public new void OnContainerControlChanged(EventArgs e) + => base.OnContainerControlChanged(e); + + public new void OnFullScreenChanged(EventArgs e) + => base.OnFullScreenChanged(e); + public new void OnWakeup(KioskModeWakeupEventArgs e) => base.OnWakeup(e); } } +#endif From eaacd24b7ae907efcfd2aff1d246067213c572b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klaus=20L=C3=B6ffelmann?= Date: Fri, 10 Jul 2026 01:35:51 -0700 Subject: [PATCH 28/99] Harden KioskModeManager lifecycle behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 20883488-cf1e-4ad3-a681-0724e9f16777 --- ...04_KioskManagerComponent.HighRiskReview.md | 38 +++++ .../KioskModeManager/KioskModeManager.cs | 84 +++++++++-- .../Windows/Forms/KioskModeManagerTests.cs | 136 +++++++++++++++++- 3 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md diff --git a/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md b/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md new file mode 100644 index 00000000000..b3cb075a1a1 --- /dev/null +++ b/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md @@ -0,0 +1,38 @@ +# Net11Api_04 KioskModeManager High-Risk Review + +This document records review findings that require an API or lifecycle design decision before they can be patched safely. + +## FullScreen requested state and actual state differ + +`KioskModeManager.FullScreen` currently returns `true` when fullscreen has been requested but no target form is available. `FullScreenChanged`, however, is raised only when the resolved form actually enters or exits fullscreen. This creates two observable state models: + +- The property reports requested state through `_pendingFullScreen || _isFullScreen`. +- The event reports actual window state through changes to `_isFullScreen`. + +Consequently, setting `FullScreen = true` without a resolved form changes the property without raising the event. When a form later becomes available, the event is raised even though the property value remains `true`. + +Before changing this behavior, decide whether `FullScreen` represents requested state or actual window state. The decision must cover: + +- initialization and delayed parenting; +- missing, replaced, reparented, and disposed container controls; +- event ordering and two-way data binding; +- `ToggleFullScreen` behavior while a request is pending; +- disposal and failed fullscreen transitions; +- compatibility for applications already observing the property or event. + +Tests should define the complete transition matrix for requested, pending, entered, exited, reparented, and disposed states. + +## Session-notification registration is not retried + +`KioskModeFormObserver.RegisterSessionNotifications` records a failed `WTSRegisterSessionNotification` call as `false` and retries only after form handle recreation. During Windows startup, registration can fail with `RPC_S_INVALID_BINDING` before `Global\TermSrvReadyEvent` is signaled. A kiosk application started during that interval can therefore miss session notifications for the lifetime of its form handle. + +A safe fix needs a non-blocking retry design that specifies: + +- which Win32 errors are retryable and which are terminal; +- how waiting for `Global\TermSrvReadyEvent` is cancelled; +- how completion is marshalled to the form's UI thread; +- how stale callbacks are rejected after handle recreation; +- how registration and unregistration remain paired; +- how disposal races with an outstanding wait. + +Tests should cover service-not-ready startup, successful retry, handle recreation during the wait, and disposal before the wait completes. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs index 30bf82ff0b8..87befa212f9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs @@ -72,7 +72,6 @@ public class KioskModeManager : Component, ISupportInitialize private const uint PowerRequestContextVersion = 0; private const POWER_REQUEST_CONTEXT_FLAGS PowerRequestContextSimpleString = (POWER_REQUEST_CONTEXT_FLAGS)0x00000001; private const uint NotifyForThisSession = 0; - private const nint PbtApmResumeSuspend = 0x0007; private const nint PbtApmResumeAutomatic = 0x0012; private const nint WtsConsoleConnect = 0x0001; private const nint WtsRemoteConnect = 0x0003; @@ -91,6 +90,7 @@ public class KioskModeManager : Component, ISupportInitialize private bool _pendingFullScreen; private bool _initializing; private bool _isCursorHidden; + private readonly List _parentChain = []; private KioskModeMessageFilter? _messageFilter; private KioskModeFormObserver? _formObserver; private Timer? _mousePointerAutoHideTimer; @@ -364,8 +364,18 @@ public bool SuppressPowerSaving return; } + bool previousValue = _suppressPowerSaving; _suppressPowerSaving = value; - UpdatePowerRequest(); + + try + { + UpdatePowerRequest(); + } + catch + { + _suppressPowerSaving = previousValue; + throw; + } } } @@ -715,6 +725,7 @@ private void AttachToForm() return; } + UpdateParentChangedSubscriptions(); EnsureMessageMonitoring(); ResolveTargetForm(); if (_pendingFullScreen && _targetForm is not null) @@ -725,6 +736,8 @@ private void AttachToForm() private void DetachFromForm() { + ClearParentChangedSubscriptions(); + if (_messageFilter is not null) { Application.RemoveMessageFilter(_messageFilter); @@ -737,6 +750,52 @@ private void DetachFromForm() _targetForm = null; } + private void OnContainerControlParentChanged(object? sender, EventArgs e) + { + UpdateParentChangedSubscriptions(); + + Form? newTarget = _containerControl as Form ?? _containerControl?.FindForm(); + if (ReferenceEquals(_targetForm, newTarget)) + { + return; + } + + bool restoreFullScreen = _pendingFullScreen || _isFullScreen; + + if (_isFullScreen) + { + ExitFullScreen(); + } + + ResolveTargetForm(); + + if (restoreFullScreen) + { + FullScreen = true; + } + } + + private void UpdateParentChangedSubscriptions() + { + ClearParentChangedSubscriptions(); + + for (Control? current = _containerControl; current is not null; current = current.Parent) + { + current.ParentChanged += OnContainerControlParentChanged; + _parentChain.Add(current); + } + } + + private void ClearParentChangedSubscriptions() + { + foreach (Control control in _parentChain) + { + control.ParentChanged -= OnContainerControlParentChanged; + } + + _parentChain.Clear(); + } + private Form? ResolveTargetForm() { // ContainerControl intentionally supports both Form and UserControl @@ -769,10 +828,7 @@ private void ApplyFullScreen(Form form) form.WindowState = FormWindowState.Normal; form.FormBorderStyle = FormBorderStyle.None; - if (_topMostInFullScreen) - { - form.TopMost = true; - } + form.TopMost = _topMostInFullScreen; if (_hideTaskbar) { @@ -815,8 +871,7 @@ private void UpdatePowerRequest() private void ProcessPowerBroadcast(WPARAM powerEvent) { - nint value = (nint)powerEvent.Value; - if (value is PbtApmResumeAutomatic or PbtApmResumeSuspend) + if ((nint)powerEvent.Value == PbtApmResumeAutomatic) { RaiseWakeup(KioskModeWakeupSource.PowerResume); } @@ -864,12 +919,17 @@ private void OnMousePointerAutoHideTimerTick(object? sender, EventArgs e) } } - private void ProcessKeyboardActivity(Keys keyData, Keys modifiers) + private void ProcessKeyboardActivity(Keys keyData, Keys modifiers, bool isRepeat) { // Any keyboard input wakes the kiosk experience, even when it does not // match one of the configured fullscreen control keys. RaiseWakeup(KioskModeWakeupSource.Keyboard); + if (isRepeat) + { + return; + } + Keys keyCode = keyData & Keys.KeyCode; if (keyCode == _toggleFullScreenKey && modifiers == Keys.None) @@ -1005,7 +1065,11 @@ private bool ProcessMessage(Message message) if (message.MsgInternal == PInvokeCore.WM_KEYDOWN || message.MsgInternal == PInvokeCore.WM_SYSKEYDOWN) { - ProcessKeyboardActivity((Keys)(nint)message.WParamInternal, Control.ModifierKeys & Keys.Modifiers); + bool isRepeat = ((nuint)(nint)message.LParamInternal & (1u << 30)) != 0; + ProcessKeyboardActivity( + (Keys)(nint)message.WParamInternal, + Control.ModifierKeys & Keys.Modifiers, + isRepeat); } else if (message.MsgInternal >= PInvokeCore.WM_MOUSEFIRST && message.MsgInternal <= PInvokeCore.WM_MOUSELAST) diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs index 6c3f1c2d652..d6a877d8bac 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/KioskModeManagerTests.cs @@ -227,6 +227,90 @@ public void KioskModeManager_ToggleFullScreen_UserControlContainerParentedAfterS Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); } + [WinFormsFact] + public void KioskModeManager_FullScreen_SetBeforeUserControlIsParented_EntersWhenParented() + { + using Form form = new(); + using UserControl userControl = new(); + using KioskModeManager manager = new() + { + ContainerControl = userControl, + FullScreen = true + }; + + form.Controls.Add(userControl); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + } + + [WinFormsFact] + public void KioskModeManager_FullScreen_UserControlReparented_TransfersToNewForm() + { + using Form firstForm = new() + { + FormBorderStyle = FormBorderStyle.FixedDialog + }; + using Form secondForm = new(); + using UserControl userControl = new(); + firstForm.Controls.Add(userControl); + using KioskModeManager manager = new() + { + ContainerControl = userControl, + FullScreen = true + }; + + secondForm.Controls.Add(userControl); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.FixedDialog, firstForm.FormBorderStyle); + Assert.Equal(FormBorderStyle.None, secondForm.FormBorderStyle); + } + + [WinFormsFact] + public void KioskModeManager_FullScreen_AncestorReparented_TransfersToNewForm() + { + using Form firstForm = new() + { + FormBorderStyle = FormBorderStyle.FixedDialog + }; + using Form secondForm = new(); + using Panel panel = new(); + using UserControl userControl = new(); + panel.Controls.Add(userControl); + firstForm.Controls.Add(panel); + using KioskModeManager manager = new() + { + ContainerControl = userControl, + FullScreen = true + }; + + secondForm.Controls.Add(panel); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.FixedDialog, firstForm.FormBorderStyle); + Assert.Equal(FormBorderStyle.None, secondForm.FormBorderStyle); + } + + [WinFormsFact] + public void KioskModeManager_FullScreen_SetBeforeAncestorIsParented_EntersWhenParented() + { + using Form form = new(); + using Panel panel = new(); + using UserControl userControl = new(); + panel.Controls.Add(userControl); + using KioskModeManager manager = new() + { + ContainerControl = userControl, + FullScreen = true + }; + + form.Controls.Add(panel); + + Assert.True(manager.FullScreen); + Assert.Equal(FormBorderStyle.None, form.FormBorderStyle); + } + [WinFormsFact] public void KioskModeManager_ContainerControl_SetWhileFullScreen_RestoresPreviousForm() { @@ -297,6 +381,28 @@ public void KioskModeManager_FullScreen_SetTrueThenFalse_EntersAndExitsFullScree Assert.Equal(new Rectangle(10, 20, 300, 200), form.Bounds); } + [WinFormsFact] + public void KioskModeManager_FullScreen_TopMostDisabled_OverridesAndRestoresFormTopMost() + { + using Form form = new() + { + TopMost = true + }; + using KioskModeManager manager = new() + { + ContainerControl = form, + TopMostInFullScreen = false + }; + + manager.FullScreen = true; + + Assert.False(form.TopMost); + + manager.FullScreen = false; + + Assert.True(form.TopMost); + } + [WinFormsFact] public void KioskModeManager_FullScreen_SetWithHandler_CallsFullScreenChanged() { @@ -572,6 +678,28 @@ public void KioskModeManager_ProcessMessage_KeyDownThenKeyUp_TogglesOnce() Assert.True(manager.FullScreen); } + [WinFormsFact] + public void KioskModeManager_ProcessMessage_RepeatedKeyDown_DoesNotToggleAgain() + { + using Form form = new(); + using KioskModeManager manager = new() + { + ContainerControl = form + }; + + Message keyDownMessage = Message.Create(form.Handle, (int)PInvokeCore.WM_KEYDOWN, (nint)Keys.F11, 0); + Message repeatedKeyDownMessage = Message.Create( + form.Handle, + (int)PInvokeCore.WM_KEYDOWN, + (nint)Keys.F11, + 1 << 30); + + manager.TestAccessor.Dynamic.ProcessMessage(keyDownMessage); + manager.TestAccessor.Dynamic.ProcessMessage(repeatedKeyDownMessage); + + Assert.True(manager.FullScreen); + } + [WinFormsFact] public void KioskModeManager_ProcessMessage_MouseMove_CallsWakeup() { @@ -599,7 +727,7 @@ public void KioskModeManager_ProcessMessage_MouseMove_CallsWakeup() } [WinFormsFact] - public void KioskModeManager_ProcessPowerBroadcast_Resume_CallsWakeup() + public void KioskModeManager_ProcessPowerBroadcast_InteractiveResume_CallsWakeupOnce() { using SubKioskModeManager manager = new(); int callCount = 0; @@ -611,8 +739,10 @@ public void KioskModeManager_ProcessPowerBroadcast_Resume_CallsWakeup() lastEventArgs = e; }; - Message message = Message.Create(IntPtr.Zero, (int)PInvokeCore.WM_POWERBROADCAST, (nint)0x0012, 0); - manager.TestAccessor.Dynamic.ProcessPowerBroadcast(message.WParamInternal); + Message automaticResume = Message.Create(IntPtr.Zero, (int)PInvokeCore.WM_POWERBROADCAST, (nint)0x0012, 0); + Message interactiveResume = Message.Create(IntPtr.Zero, (int)PInvokeCore.WM_POWERBROADCAST, (nint)0x0007, 0); + manager.TestAccessor.Dynamic.ProcessPowerBroadcast(automaticResume.WParamInternal); + manager.TestAccessor.Dynamic.ProcessPowerBroadcast(interactiveResume.WParamInternal); Assert.Equal(1, callCount); Assert.NotNull(lastEventArgs); From 80e777eea889d24bb92241fe123c7422dfbb13f3 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 13 Jun 2026 13:38:11 -0700 Subject: [PATCH 29/99] 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 8dc15332326..501e003465d 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/ApplyApplicationDefaultsEventArgs.vb @@ -40,6 +40,12 @@ Namespace Microsoft.VisualBasic.ApplicationServices ''' Public Property FormRevealMode As FormRevealMode + ''' + ''' 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 ee64edcc086..6beb932a0d8 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb @@ -72,6 +72,9 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' The FormRevealMode the user assigned to the ApplyApplicationsDefault event. Private _formRevealMode As FormRevealMode = FormRevealMode.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 @@ -220,6 +223,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. ''' @@ -769,7 +788,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices ColorMode, FormRevealMode) With { - .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime + .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime, + .VisualStylesMode = VisualStylesMode } RaiseEvent ApplyApplicationDefaults(Me, applicationDefaultsEventArgs) @@ -787,6 +807,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices _highDpiMode = applicationDefaultsEventArgs.HighDpiMode _colorMode = applicationDefaultsEventArgs.ColorMode _formRevealMode = applicationDefaultsEventArgs.FormRevealMode + _visualStylesMode = applicationDefaultsEventArgs.VisualStylesMode ' Then, it's applying what we got back as HighDpiMode. Dim dpiSetResult As Boolean = Application.SetHighDpiMode(_highDpiMode) @@ -802,6 +823,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices Application.EnableVisualStyles() End If + Application.SetDefaultVisualStylesMode(_visualStylesMode) + Application.SetColorMode(_colorMode) Application.SetDefaultFormRevealMode(_formRevealMode) diff --git a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt index 71b1f47c484..93b55542d62 100644 --- a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt @@ -2,3 +2,7 @@ Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.Form 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 +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 e69c2a940ed..ee28ed9f1b3 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -104,3 +104,22 @@ System.Windows.Forms.KioskModeWakeupSource.Keyboard = 0 -> System.Windows.Forms. System.Windows.Forms.KioskModeWakeupSource.Mouse = 1 -> System.Windows.Forms.KioskModeWakeupSource System.Windows.Forms.KioskModeWakeupSource.PowerResume = 2 -> System.Windows.Forms.KioskModeWakeupSource System.Windows.Forms.KioskModeWakeupSource.Session = 3 -> System.Windows.Forms.KioskModeWakeupSource +#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 bad8516f342..12cc2573362 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 @@ -6985,6 +6988,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 6d534892a88..8b6348ffc22 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 @@ -2182,6 +2187,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 66ab2523ba7..3cd561a8663 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 @@ -2182,6 +2187,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 472d3047e83..32f885f254c 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 @@ -2182,6 +2187,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 1217a92943d..2aee40316db 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 @@ -2182,6 +2187,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 4717ba4bada..294d6c4d938 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 @@ -2182,6 +2187,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 9e8a7942a99..e1b389208ce 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) @@ -2182,6 +2187,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 ee8b4d9fe51..c5874a39b86 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) @@ -2182,6 +2187,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 dcad9cff559..a7a66b9b29e 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 @@ -2182,6 +2187,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 a269486b5de..55da2368a29 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 @@ -2182,6 +2187,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 ca251ad870a..39d3fe22146 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 &Применить @@ -2182,6 +2187,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 109cb3e858b..fec6707440e 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 @@ -2182,6 +2187,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 b306cdbaa51..1a1aee75784 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) @@ -2182,6 +2187,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 dcfb9722778..0124bc21503 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) @@ -2182,6 +2187,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 b40d50ad9fc..06a7a41b10a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -48,6 +48,8 @@ public sealed partial class Application private static FormRevealMode? s_defaultFormRevealMode; #endif + 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; @@ -664,6 +666,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. @@ -1052,6 +1102,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 c9913404d42..e5515a37b37 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -147,6 +147,7 @@ public unsafe partial class Control : private static readonly object s_previewKeyDownEvent = new(); private static readonly object s_dataContextEvent = new(); private static readonly object s_systemVisualSettingsChangedEvent = new(); + private static readonly object s_visualStylesModeChangedEvent = new(); private static MessageId s_threadCallbackMessage; private static ContextCallback? s_invokeMarshaledCallbackHelperDelegate; @@ -226,6 +227,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(); @@ -864,6 +866,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. @@ -3747,6 +3823,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 @@ -6863,6 +6952,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) { @@ -7076,6 +7193,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