From cb8e6bbba29936182c2046e0e5f386653d204549 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Sat, 18 Jul 2026 01:26:39 -0700 Subject: [PATCH 01/18] 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 753f1e370a110fec229fc81a253bf2030c33a22e Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 13:53:19 -0700 Subject: [PATCH 02/18] 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 76028996ea8c179135e0ca0ab617275bbe626b01 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:34 -0700 Subject: [PATCH 03/18] 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 d524e07eb0ac92bc9a30b024e2b63336e880117a Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:45 -0700 Subject: [PATCH 04/18] Add flicker-free UI mutation APIs Implements suspend painting and relocation scopes, deferred child positioning, and form appearance mode infrastructure for .NET 11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/NativeMethods.txt | 6 +- .../PublicAPI.Unshipped.txt | 48 ++++ .../System/Windows/Forms/Application.cs | 40 ++++ .../Windows/Forms/Control.SuspendMutation.cs | 105 +++++++++ .../System/Windows/Forms/Control.cs | 28 ++- .../Forms/ControlMutationExtensions.cs | 28 +++ .../ComboBox/ComboBox.SuspendMutation.cs | 25 ++ .../ListBoxes/ListBox.SuspendMutation.cs | 25 ++ .../ListView/ListView.SuspendMutation.cs | 25 ++ .../RichTextBox.SuspendMutation.cs | 25 ++ .../TreeView/TreeView.SuspendMutation.cs | 25 ++ .../Windows/Forms/DeferLocationChangeScope.cs | 221 ++++++++++++++++++ .../Windows/Forms/Form.AppearanceMode.cs | 64 +++++ .../System/Windows/Forms/Form.cs | 16 ++ .../Windows/Forms/FormAppearanceMode.cs | 28 +++ .../Windows/Forms/ISupportSuspendPainting.cs | 22 ++ .../Forms/ISupportSuspendRelocation.cs | 22 ++ .../Windows/Forms/SuspendPaintingScope.cs | 29 +++ .../Windows/Forms/SuspendRelocationScope.cs | 29 +++ 19 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs create mode 100644 src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs diff --git a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt index a56634792b1..751a668cde2 100644 --- a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt +++ b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt @@ -7,6 +7,7 @@ ADVF AreDpiAwarenessContextsEqual ARW_* AUTOCOMPLETEOPTIONS +BeginDeferWindowPos BFFM_* BIF_* BITMAP @@ -62,6 +63,7 @@ DATETIMEPICK_CLASS DeactivateActCtx DefFrameProc DefMDIChildProc +DeferWindowPos DESKTOP_ACCESS_FLAGS DestroyAcceleratorTable DestroyCursor @@ -92,6 +94,7 @@ DTM_* DTN_* DTS_* DuplicateHandle +DWMWINDOWATTRIBUTE DWM_WINDOW_CORNER_PREFERENCE DwmGetWindowAttribute DwmSetWindowAttribute @@ -104,6 +107,7 @@ EN_* EnableMenuItem EnableScrollBar EnableWindow +EndDeferWindowPos EndDialog ENM_* EnumDisplaySettings @@ -697,4 +701,4 @@ WINEVENT_INCONTEXT WSF_VISIBLE XBUTTON1 XBUTTON2 -XFORMCOORDS \ No newline at end of file +XFORMCOORDS diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..7c4f02d4a7f 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,48 @@ +System.Windows.Forms.ControlMutationExtensions +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope +static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope +System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.FormAppearanceMode.Classic = 0 -> System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.FormAppearanceMode.Deferred = 1 -> System.Windows.Forms.FormAppearanceMode +System.Windows.Forms.ISupportSuspendPainting +System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void +System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> void +System.Windows.Forms.ISupportSuspendRelocation +System.Windows.Forms.ISupportSuspendRelocation.BeginSuspendRelocation() -> void +System.Windows.Forms.ISupportSuspendRelocation.EndSuspendRelocation() -> void +System.Windows.Forms.SuspendPaintingScope +System.Windows.Forms.SuspendPaintingScope.Dispose() -> void +System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope() -> void +System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Forms.ISupportSuspendPainting? target) -> void +System.Windows.Forms.SuspendRelocationScope +System.Windows.Forms.SuspendRelocationScope.Dispose() -> void +System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope() -> void +System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope(System.Windows.Forms.ISupportSuspendRelocation? target) -> void +override System.Windows.Forms.ComboBox.BeginSuspendPainting() -> void +override System.Windows.Forms.ComboBox.EndSuspendPainting() -> void +override System.Windows.Forms.ListBox.BeginSuspendPainting() -> void +override System.Windows.Forms.ListBox.EndSuspendPainting() -> void +override System.Windows.Forms.ListView.BeginSuspendPainting() -> void +override System.Windows.Forms.ListView.EndSuspendPainting() -> void +override System.Windows.Forms.RichTextBox.BeginSuspendPainting() -> void +override System.Windows.Forms.RichTextBox.EndSuspendPainting() -> void +override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void +override System.Windows.Forms.TreeView.EndSuspendPainting() -> void +static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode +static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void +System.Windows.Forms.Control.DeferLocationChange() -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.Control.DeferLocationChange(bool suppressRender) -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.Control.DeferLocationChange(bool suppressRender, bool suspendLayout) -> System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.DeferLocationChangeScope +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope() -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y) -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y, int width, int height) -> void +System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, System.Drawing.Rectangle bounds) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender) -> void +System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender, bool suspendLayout) -> void +System.Windows.Forms.DeferLocationChangeScope.Dispose() -> void +virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void +virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void +virtual System.Windows.Forms.Control.EndSuspendPainting() -> void +virtual System.Windows.Forms.Control.EndSuspendRelocation() -> void diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index aab496c2744..fd1fc9ba89c 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -44,6 +44,9 @@ public sealed partial class Application private static bool s_useWaitCursor; private static SystemColorMode? s_colorMode; +#if NET11_0_OR_GREATER + private static FormAppearanceMode? s_formAppearanceMode; +#endif private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; private const string DarkModeKey = "AppsUseLightTheme"; @@ -249,6 +252,21 @@ internal static bool CustomThreadExceptionHandlerAttached /// public static SystemColorMode ColorMode => s_colorMode ?? SystemColorMode.Classic; +#if NET11_0_OR_GREATER + /// + /// Gets the configured form appearance mode for the application. + /// + /// + /// + /// If no mode has been configured with , + /// WinForms uses . + /// + /// + public static FormAppearanceMode FormAppearanceMode + => s_formAppearanceMode ?? FormAppearanceMode.Deferred; + +#endif + /// /// True if the has been set at least once. /// @@ -381,6 +399,28 @@ static void NotifySystemEventsOfColorChange() } } +#if NET11_0_OR_GREATER + /// + /// Sets the process-wide form appearance mode. + /// + /// The form appearance mode to use for newly created forms. + /// + /// + /// Set the form appearance mode before creating UI to ensure newly created forms use the intended + /// startup presentation behavior. + /// + /// + /// + /// is not a valid value. + /// + public static void SetFormAppearanceMode(FormAppearanceMode mode) + { + SourceGenerated.EnumValidator.Validate(mode, nameof(mode)); + s_formAppearanceMode = mode; + } + +#endif + internal static Font DefaultFont => s_defaultFontScaled ?? s_defaultFont!; /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs new file mode 100644 index 00000000000..bd4c5c3398d --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +public unsafe partial class Control : + ISupportSuspendPainting, + ISupportSuspendRelocation +#else +public unsafe partial class Control +#endif +{ +#if NET11_0_OR_GREATER + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange() + => new(this); + + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange(bool suppressRender) + => new(this, suppressRender); + + /// + /// Defers child-control location changes until the returned scope is disposed. + /// + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// + /// to suspend layout while changes are deferred; otherwise, + /// . + /// + /// A scope that applies deferred location changes when disposed. + public DeferLocationChangeScope DeferLocationChange(bool suppressRender, bool suspendLayout) + => new(this, suppressRender, suspendLayout); + + /// + /// Begins a painting suspension region for this control. + /// + public virtual void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + /// Ends a painting suspension region for this control. + /// + public virtual void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(invalidate: true); + } + } + + /// + /// Begins a relocation suspension region for this control. + /// + public virtual void BeginSuspendRelocation() => SuspendLayout(); + + /// + /// Ends a relocation suspension region for this control. + /// + public virtual void EndSuspendRelocation() => ResumeLayout(); + + internal bool BeginSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + Properties.AddOrRemoveValue( + s_suspendPaintingCountProperty, + suspendPaintingCount + 1, + defaultValue: 0); + + return true; + } + + internal bool EndSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + if (suspendPaintingCount == 0) + { + return false; + } + + Properties.AddOrRemoveValue( + s_suspendPaintingCountProperty, + suspendPaintingCount - 1, + defaultValue: 0); + + return true; + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 14efc5014ff..f4961ae73d6 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -227,6 +227,10 @@ public unsafe partial class Control : private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_suspendPaintingCountProperty = PropertyStore.CreateKey(); +#endif + private static readonly int s_updateCountProperty = PropertyStore.CreateKey(); private static bool s_needToLoadComCtl = true; @@ -268,7 +272,6 @@ public unsafe partial class Control : // bits 0-4: BoundsSpecified stored in RequiredScaling property. Bit 5: RequiredScalingEnabled property. private byte _requiredScaling; private TRACKMOUSEEVENT _trackMouseEvent; - private short _updateCount; private LayoutEventArgs? _cachedLayoutEventArgs; private Queue? _threadCallbackList; @@ -4379,12 +4382,16 @@ internal void BeginUpdateInternal() return; } - if (_updateCount == 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount == 0) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); } - _updateCount++; + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount + 1, + defaultValue: 0); } /// @@ -5070,11 +5077,17 @@ public void DrawToBitmap(Bitmap bitmap, Rectangle targetBounds) internal bool EndUpdateInternal(bool invalidate) { - if (_updateCount > 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount > 0) { Debug.Assert(IsHandleCreated, "Handle should be created by now"); - _updateCount--; - if (_updateCount == 0) + updateCount--; + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount, + defaultValue: 0); + + if (updateCount == 0) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)true); if (invalidate) @@ -5349,7 +5362,8 @@ private static bool IsFocusManagingContainerControl(Control ctl) /// by calling "WM_SETREDRAW" even if the control in "Begin - End" update cycle. Using this Function we can guard /// against repetitively redrawing the control. /// - internal bool IsUpdating() => _updateCount > 0; + internal bool IsUpdating() + => Properties.GetValueOrDefault(s_updateCountProperty, 0) > 0; /// /// This is a helper method that is called by ScaleControl to retrieve the bounds diff --git a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs new file mode 100644 index 00000000000..3d2a87fcf82 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides extension methods for batching synchronous WinForms UI mutations. +/// +public static class ControlMutationExtensions +{ + /// + /// Suspends painting for the specified target until the returned scope is disposed. + /// + /// The target whose painting should be suspended. + /// A scope that resumes painting when disposed. + public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting target) + => new(target); + + /// + /// Suspends relocation work for the specified target until the returned scope is disposed. + /// + /// The target whose relocation work should be suspended. + /// A scope that resumes relocation work when disposed. + public static SuspendRelocationScope SuspendRelocation(this ISupportSuspendRelocation target) + => new(target); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs new file mode 100644 index 00000000000..348ab18cb24 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ComboBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs new file mode 100644 index 00000000000..cd21cc7e8ee --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs new file mode 100644 index 00000000000..dbee7d27836 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListView +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs new file mode 100644 index 00000000000..213ee92a195 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class RichTextBox +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs new file mode 100644 index 00000000000..16817872b60 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class TreeView +{ +#if NET11_0_OR_GREATER + /// + public override void BeginSuspendPainting() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + public override void EndSuspendPainting() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs new file mode 100644 index 00000000000..c310dbb9e9d --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs @@ -0,0 +1,221 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Batches child-control location changes until the scope is disposed. +/// +/// +/// +/// This scope uses the Win32 deferred window-position API when possible. If a deferred native batch +/// cannot be created or is lost, the collected changes are applied individually when the scope is disposed. +/// +/// +public readonly ref struct DeferLocationChangeScope +{ + private readonly State? _state; + private readonly SuspendPaintingScope _paintingScope; + private readonly SuspendRelocationScope _relocationScope; + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + public DeferLocationChangeScope(Control parent) + : this(parent, suppressRender: true, suspendLayout: true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + public DeferLocationChangeScope(Control parent, bool suppressRender) + : this(parent, suppressRender, suspendLayout: true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The parent control whose child-control location changes should be deferred. + /// + /// to suppress rendering while changes are deferred; otherwise, + /// . + /// + /// + /// to suspend layout while changes are deferred; otherwise, + /// . + /// + public DeferLocationChangeScope(Control parent, bool suppressRender, bool suspendLayout) + { + ArgumentNullException.ThrowIfNull(parent); + + _state = new(parent); + _paintingScope = suppressRender + ? new SuspendPaintingScope(parent) + : default; + _relocationScope = suspendLayout + ? new SuspendRelocationScope(parent) + : default; + } + + /// + /// Defers moving a control to the specified location. + /// + /// The control to move. + /// The deferred x-coordinate. + /// The deferred y-coordinate. + /// is . + public void Defer(Control control, int x, int y) + { + ArgumentNullException.ThrowIfNull(control); + Size size = control.Size; + _state?.Defer(control, new Rectangle(x, y, size.Width, size.Height)); + } + + /// + /// Defers moving and resizing a control to the specified bounds. + /// + /// The control to move and resize. + /// The deferred x-coordinate. + /// The deferred y-coordinate. + /// The deferred width. + /// The deferred height. + /// is . + public void Defer(Control control, int x, int y, int width, int height) + { + ArgumentNullException.ThrowIfNull(control); + _state?.Defer(control, new Rectangle(x, y, width, height)); + } + + /// + /// Defers moving and resizing a control to the specified bounds. + /// + /// The control to move and resize. + /// The deferred bounds. + /// is . + public void Defer(Control control, Rectangle bounds) + { + ArgumentNullException.ThrowIfNull(control); + _state?.Defer(control, bounds); + } + + /// + /// Applies the deferred location changes and resumes any bundled suspension scopes. + /// + public void Dispose() + { + _state?.Dispose(); + _relocationScope.Dispose(); + _paintingScope.Dispose(); + } + + /// + /// Holds mutable deferred-position state for . + /// + private sealed class State + { + private readonly Control _parent; + private readonly List _deferredPositions = []; + private HDWP _hdwp; + private bool _batchFailed; + + public State(Control parent) + { + _parent = parent; + _hdwp = parent.IsHandleCreated && parent.Controls.Count > 0 + ? PInvoke.BeginDeferWindowPos(parent.Controls.Count) + : HDWP.Null; + _batchFailed = _hdwp.IsNull || !parent.IsHandleCreated; + } + + public void Defer(Control control, Rectangle bounds) + { + DeferredWindowPosition deferredPosition = new(control, bounds); + _deferredPositions.Add(deferredPosition); + + if (_batchFailed) + { + return; + } + + if (!control.IsHandleCreated) + { + _batchFailed = true; + + return; + } + + HDWP hdwp = PInvoke.DeferWindowPos( + _hdwp, + (HWND)control.Handle, + HWND.Null, + bounds.X, + bounds.Y, + bounds.Width, + bounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + + if (hdwp.IsNull) + { + _hdwp = HDWP.Null; + _batchFailed = true; + + return; + } + + _hdwp = hdwp; + } + + public void Dispose() + { + if (!_batchFailed && !_hdwp.IsNull && PInvoke.EndDeferWindowPos(_hdwp)) + { + return; + } + + if (_batchFailed && !_hdwp.IsNull) + { + PInvoke.EndDeferWindowPos(_hdwp); + } + + foreach (DeferredWindowPosition deferredPosition in _deferredPositions) + { + Control control = deferredPosition.Control; + Rectangle bounds = deferredPosition.Bounds; + if (control.IsHandleCreated) + { + PInvoke.SetWindowPos( + control, + HWND.Null, + bounds.X, + bounds.Y, + bounds.Width, + bounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + } + else + { + control.Bounds = bounds; + } + } + + _parent.Invalidate(invalidateChildren: true); + } + } + + /// + /// Represents a deferred window-position request. + /// + private readonly record struct DeferredWindowPosition(Control Control, Rectangle Bounds); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs new file mode 100644 index 00000000000..853bdd55346 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Windows.Win32.Graphics.Dwm; + +namespace System.Windows.Forms; + +public partial class Form +{ +#if NET11_0_OR_GREATER + private bool DeferredAppearanceCloaked + { + get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); + set => Properties.AddOrRemoveValue(s_propFormAppearanceCloaked, value, defaultValue: false); + } + + private void CloakForDeferredAppearanceIfNeeded() + { + if (!ShouldUseDeferredAppearanceCloak()) + { + return; + } + + if (SetDwmCloak(cloaked: true)) + { + DeferredAppearanceCloaked = true; + } + } + + private void UncloakDeferredAppearanceIfNeeded() + { + if (!DeferredAppearanceCloaked) + { + return; + } + + if (SetDwmCloak(cloaked: false)) + { + DeferredAppearanceCloaked = false; + } + } + + private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; + + private bool ShouldUseDeferredAppearanceCloak() + => Application.FormAppearanceMode == FormAppearanceMode.Deferred + && TopLevel + && !IsMdiChild + && Visible + && IsHandleCreated; + + private unsafe bool SetDwmCloak(bool cloaked) + { + BOOL cloak = cloaked; + HRESULT result = PInvoke.DwmSetWindowAttribute( + HWND, + DWMWINDOWATTRIBUTE.DWMWA_CLOAK, + &cloak, + (uint)sizeof(BOOL)); + + return result.Succeeded; + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index c3900d09c6b..62055889de5 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -137,6 +137,9 @@ public partial class Form : ContainerControl private static readonly int s_propFormCaptionTextColor = PropertyStore.CreateKey(); private static readonly int s_propFormCaptionBackColor = PropertyStore.CreateKey(); private static readonly int s_propFormScreenCaptureMode = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_propFormAppearanceCloaked = PropertyStore.CreateKey(); +#endif // Form per instance members // Note: Do not add anything to this list unless absolutely necessary. @@ -4209,6 +4212,10 @@ protected override void OnHandleCreated(EventArgs e) { SetScreenCaptureModeInternal(FormScreenCaptureMode); } + +#if NET11_0_OR_GREATER + CloakForDeferredAppearanceIfNeeded(); +#endif } /// @@ -4219,6 +4226,9 @@ protected override void OnHandleCreated(EventArgs e) [EditorBrowsable(EditorBrowsableState.Advanced)] protected override void OnHandleDestroyed(EventArgs e) { +#if NET11_0_OR_GREATER + ClearDeferredAppearanceCloakState(); +#endif base.OnHandleDestroyed(e); _formStateEx[s_formStateExUseMdiChildProc] = 0; @@ -7175,6 +7185,12 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_ERASEBKGND: WmEraseBkgnd(ref m); break; + case PInvokeCore.WM_PAINT: + base.WndProc(ref m); +#if NET11_0_OR_GREATER + UncloakDeferredAppearanceIfNeeded(); +#endif + break; case PInvokeCore.WM_NCDESTROY: WmNCDestroy(ref m); diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs new file mode 100644 index 00000000000..f5e76f27151 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Specifies how WinForms presents a form while its initial appearance is prepared. +/// +public enum FormAppearanceMode +{ + /// + /// Uses the classic WinForms form presentation behavior. + /// + Classic = 0, + + /// + /// Defers the initial top-level form presentation to help prevent default-background flash. + /// + /// + /// + /// Deferral applies to the form background. Deep child-control trees can still produce visible + /// updates after the form is shown. + /// + /// + Deferred = 1 +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs new file mode 100644 index 00000000000..77c3ea39ee5 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides methods for temporarily suspending and resuming painting. +/// +public interface ISupportSuspendPainting +{ + /// + /// Begins a painting suspension region. + /// + void BeginSuspendPainting(); + + /// + /// Ends a painting suspension region. + /// + void EndSuspendPainting(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs new file mode 100644 index 00000000000..23338950127 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendRelocation.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides methods for temporarily suspending and resuming relocation work. +/// +public interface ISupportSuspendRelocation +{ + /// + /// Begins a relocation suspension region. + /// + void BeginSuspendRelocation(); + + /// + /// Ends a relocation suspension region. + /// + void EndSuspendRelocation(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs new file mode 100644 index 00000000000..1a3bf4a0c4f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Suspends painting for a target until the scope is disposed. +/// +public readonly ref struct SuspendPaintingScope +{ + private readonly ISupportSuspendPainting? _target; + + /// + /// Initializes a new instance of the struct. + /// + /// The target whose painting should be suspended. + public SuspendPaintingScope(ISupportSuspendPainting? target) + { + _target = target; + _target?.BeginSuspendPainting(); + } + + /// + /// Resumes painting for the target associated with this scope. + /// + public void Dispose() => _target?.EndSuspendPainting(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs new file mode 100644 index 00000000000..5289fedd284 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Suspends relocation work for a target until the scope is disposed. +/// +public readonly ref struct SuspendRelocationScope +{ + private readonly ISupportSuspendRelocation? _target; + + /// + /// Initializes a new instance of the struct. + /// + /// The target whose relocation work should be suspended. + public SuspendRelocationScope(ISupportSuspendRelocation? target) + { + _target = target; + _target?.BeginSuspendRelocation(); + } + + /// + /// Resumes relocation work for the target associated with this scope. + /// + public void Dispose() => _target?.EndSuspendRelocation(); +} +#endif From 2e55d5b3372dc9d18461556803c129b284437615 Mon Sep 17 00:00:00 2001 From: Klaus Loffelmann Date: Wed, 20 May 2026 14:38:49 -0700 Subject: [PATCH 05/18] Add tests for flicker-free mutation APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System/Windows/Forms/ApplicationTests.cs | 25 +++++++++ .../Windows/Forms/ControlTests.Methods.cs | 51 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs index 47013617e97..a4eeb2cf967 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs @@ -180,6 +180,31 @@ public void Application_SetColorMode_PlausibilityTests() #pragma warning restore SYSLIB5002 +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Application_FormAppearanceMode_Default_ReturnsDeferred() + { + Assert.Equal(FormAppearanceMode.Deferred, Application.FormAppearanceMode); + } + + [WinFormsTheory] + [InlineData(FormAppearanceMode.Classic)] + [InlineData(FormAppearanceMode.Deferred)] + public void Application_SetFormAppearanceMode_GetReturnsExpected(FormAppearanceMode mode) + { + Application.SetFormAppearanceMode(mode); + Assert.Equal(mode, Application.FormAppearanceMode); + } + + [WinFormsFact] + public void Application_SetFormAppearanceMode_Invalid_ThrowsInvalidEnumArgumentException() + { + Assert.Throws( + "mode", + () => Application.SetFormAppearanceMode((FormAppearanceMode)int.MaxValue)); + } +#endif + [WinFormsFact] public void Application_DefaultFont_ReturnsNull_IfNoFontSet() { diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs index 18ee42ff6be..d2c8d96dba5 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs @@ -15,6 +15,57 @@ namespace System.Windows.Forms.Tests; public partial class ControlTests { +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Control_BeginEndSuspendPainting_InvokeWithoutHandle_Success() + { + using SubControl control = new(); + + control.BeginSuspendPainting(); + control.EndSuspendPainting(); + control.EndSuspendPainting(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_SuspendPainting_ScopeDisposes_Success() + { + using SubControl control = new(); + + using SuspendPaintingScope scope = control.SuspendPainting(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + { + using SubControl control = new(); + + control.BeginSuspendRelocation(); + control.EndSuspendRelocation(); + control.EndSuspendRelocation(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_DeferLocationChange_DisposeWithoutHandles_AppliesBounds() + { + using SubControl parent = new(); + using SubControl child = new(); + parent.Controls.Add(child); + + using (DeferLocationChangeScope scope = parent.DeferLocationChange()) + { + scope.Defer(child, new Rectangle(1, 2, 3, 4)); + } + + Assert.Equal(new Rectangle(1, 2, 3, 4), child.Bounds); + } +#endif + public static IEnumerable AccessibilityNotifyClients_AccessibleEvents_Int_TestData() { yield return new object[] { AccessibleEvents.DescriptionChange, int.MinValue }; From afcb2e727283cb2d02775db7344754306bc8fc4b 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 06/18] Remove DeferLocationChange; make suspend-mutation hooks explicit-interface Splits DeferLocationChange out of this branch entirely (postponed pending an anchor-layout-engine integration fix; tracked separately in KlausLoeffelmann/winforms#12) and reworks ISupportSuspendPainting / ISupportSuspendRelocation on Control to match the final API shape agreed in dotnet/winforms#14585: - Control implements ISupportSuspendPainting/ISupportSuspendRelocation via explicit interface implementation instead of public virtual methods, so the manual Begin/End pair does not become the primary IntelliSense surface on every Control-derived type. Protected virtual BeginSuspendPaintingCore() / EndSuspendPaintingCore() / BeginSuspendRelocationCore() / EndSuspendRelocationCore() are the new override points. - ListView, ListBox, ComboBox, TreeView, RichTextBox override the ...Core() hooks instead of the old public virtual methods, still routing through their existing BeginUpdate/EndUpdate. - SuspendPaintingScope and SuspendRelocationScope change from readonly ref struct to sealed class : IDisposable, so the scope can span an await in an asynchronous UI event handler (a ref struct cannot be hoisted into an async state machine - this was the flagship usage scenario in the original API proposal, and it did not compile against the ref struct version). Dispose is idempotent. - Deletes DeferLocationChangeScope.cs and the Control.DeferLocationChange overloads entirely. - Updates/removes tests accordingly; updates PublicAPI.Unshipped.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 12 - .../Windows/Forms/Control.SuspendMutation.cs | 59 +++-- .../ComboBox/ComboBox.SuspendMutation.cs | 4 +- .../ListBoxes/ListBox.SuspendMutation.cs | 4 +- .../ListView/ListView.SuspendMutation.cs | 4 +- .../RichTextBox.SuspendMutation.cs | 4 +- .../TreeView/TreeView.SuspendMutation.cs | 4 +- .../Windows/Forms/DeferLocationChangeScope.cs | 221 ------------------ ...m.AppearanceMode.cs => Form.RevealMode.cs} | 0 ...ormAppearanceMode.cs => FormRevealMode.cs} | 0 .../Windows/Forms/SuspendPaintingScope.cs | 22 +- .../Windows/Forms/SuspendRelocationScope.cs | 19 +- .../Windows/Forms/ControlTests.Methods.cs | 42 ++-- 13 files changed, 96 insertions(+), 299 deletions(-) delete mode 100644 src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs rename src/System.Windows.Forms/System/Windows/Forms/{Form.AppearanceMode.cs => Form.RevealMode.cs} (100%) rename src/System.Windows.Forms/System/Windows/Forms/{FormAppearanceMode.cs => FormRevealMode.cs} (100%) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 7c4f02d4a7f..72180973d18 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -30,18 +30,6 @@ override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void override System.Windows.Forms.TreeView.EndSuspendPainting() -> void static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void -System.Windows.Forms.Control.DeferLocationChange() -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.Control.DeferLocationChange(bool suppressRender) -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.Control.DeferLocationChange(bool suppressRender, bool suspendLayout) -> System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.DeferLocationChangeScope -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope() -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y) -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, int x, int y, int width, int height) -> void -System.Windows.Forms.DeferLocationChangeScope.Defer(System.Windows.Forms.Control! control, System.Drawing.Rectangle bounds) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender) -> void -System.Windows.Forms.DeferLocationChangeScope.DeferLocationChangeScope(System.Windows.Forms.Control! parent, bool suppressRender, bool suspendLayout) -> void -System.Windows.Forms.DeferLocationChangeScope.Dispose() -> void virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void virtual System.Windows.Forms.Control.EndSuspendPainting() -> void diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs index bd4c5c3398d..0c2d6cae960 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -13,51 +13,46 @@ public unsafe partial class Control { #if NET11_0_OR_GREATER /// - /// Defers child-control location changes until the returned scope is disposed. + /// Begins a painting suspension region for this control. /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange() - => new(this); + void ISupportSuspendPainting.BeginSuspendPainting() => BeginSuspendPaintingCore(); /// - /// Defers child-control location changes until the returned scope is disposed. + /// Ends a painting suspension region for this control. /// - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange(bool suppressRender) - => new(this, suppressRender); + void ISupportSuspendPainting.EndSuspendPainting() => EndSuspendPaintingCore(); /// - /// Defers child-control location changes until the returned scope is disposed. + /// Begins a relocation suspension region for this control. /// - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// - /// to suspend layout while changes are deferred; otherwise, - /// . - /// - /// A scope that applies deferred location changes when disposed. - public DeferLocationChangeScope DeferLocationChange(bool suppressRender, bool suspendLayout) - => new(this, suppressRender, suspendLayout); + void ISupportSuspendRelocation.BeginSuspendRelocation() => BeginSuspendRelocationCore(); /// - /// Begins a painting suspension region for this control. + /// Ends a relocation suspension region for this control. /// - public virtual void BeginSuspendPainting() + void ISupportSuspendRelocation.EndSuspendRelocation() => EndSuspendRelocationCore(); + + /// + /// When overridden in a derived class, begins a painting suspension region for this control. + /// + /// + /// + /// The default implementation suppresses native painting through . + /// Controls that already have a public update mechanism (such as ) + /// should override this method to route through that existing mechanism instead of introducing a + /// second, competing suspension path. + /// + /// + protected virtual void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdateInternal(); } /// - /// Ends a painting suspension region for this control. + /// When overridden in a derived class, ends a painting suspension region for this control. /// - public virtual void EndSuspendPainting() + protected virtual void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { @@ -66,14 +61,14 @@ public virtual void EndSuspendPainting() } /// - /// Begins a relocation suspension region for this control. + /// When overridden in a derived class, begins a relocation suspension region for this control. /// - public virtual void BeginSuspendRelocation() => SuspendLayout(); + protected virtual void BeginSuspendRelocationCore() => SuspendLayout(); /// - /// Ends a relocation suspension region for this control. + /// When overridden in a derived class, ends a relocation suspension region for this control. /// - public virtual void EndSuspendRelocation() => ResumeLayout(); + protected virtual void EndSuspendRelocationCore() => ResumeLayout(); internal bool BeginSuspendPaintingScope() { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs index 348ab18cb24..9a90bcda243 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ComboBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs index cd21cc7e8ee..e26361db974 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ListBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs index dbee7d27836..1fbe0660b39 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class ListView { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs index 213ee92a195..4625a1cb225 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class RichTextBox { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdateInternal(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs index 16817872b60..f9fc5d4d629 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs @@ -7,14 +7,14 @@ public partial class TreeView { #if NET11_0_OR_GREATER /// - public override void BeginSuspendPainting() + protected override void BeginSuspendPaintingCore() { BeginSuspendPaintingScope(); BeginUpdate(); } /// - public override void EndSuspendPainting() + protected override void EndSuspendPaintingCore() { if (EndSuspendPaintingScope()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs b/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs deleted file mode 100644 index c310dbb9e9d..00000000000 --- a/src/System.Windows.Forms/System/Windows/Forms/DeferLocationChangeScope.cs +++ /dev/null @@ -1,221 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Drawing; - -namespace System.Windows.Forms; - -#if NET11_0_OR_GREATER -/// -/// Batches child-control location changes until the scope is disposed. -/// -/// -/// -/// This scope uses the Win32 deferred window-position API when possible. If a deferred native batch -/// cannot be created or is lost, the collected changes are applied individually when the scope is disposed. -/// -/// -public readonly ref struct DeferLocationChangeScope -{ - private readonly State? _state; - private readonly SuspendPaintingScope _paintingScope; - private readonly SuspendRelocationScope _relocationScope; - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - public DeferLocationChangeScope(Control parent) - : this(parent, suppressRender: true, suspendLayout: true) - { - } - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - public DeferLocationChangeScope(Control parent, bool suppressRender) - : this(parent, suppressRender, suspendLayout: true) - { - } - - /// - /// Initializes a new instance of the struct. - /// - /// The parent control whose child-control location changes should be deferred. - /// - /// to suppress rendering while changes are deferred; otherwise, - /// . - /// - /// - /// to suspend layout while changes are deferred; otherwise, - /// . - /// - public DeferLocationChangeScope(Control parent, bool suppressRender, bool suspendLayout) - { - ArgumentNullException.ThrowIfNull(parent); - - _state = new(parent); - _paintingScope = suppressRender - ? new SuspendPaintingScope(parent) - : default; - _relocationScope = suspendLayout - ? new SuspendRelocationScope(parent) - : default; - } - - /// - /// Defers moving a control to the specified location. - /// - /// The control to move. - /// The deferred x-coordinate. - /// The deferred y-coordinate. - /// is . - public void Defer(Control control, int x, int y) - { - ArgumentNullException.ThrowIfNull(control); - Size size = control.Size; - _state?.Defer(control, new Rectangle(x, y, size.Width, size.Height)); - } - - /// - /// Defers moving and resizing a control to the specified bounds. - /// - /// The control to move and resize. - /// The deferred x-coordinate. - /// The deferred y-coordinate. - /// The deferred width. - /// The deferred height. - /// is . - public void Defer(Control control, int x, int y, int width, int height) - { - ArgumentNullException.ThrowIfNull(control); - _state?.Defer(control, new Rectangle(x, y, width, height)); - } - - /// - /// Defers moving and resizing a control to the specified bounds. - /// - /// The control to move and resize. - /// The deferred bounds. - /// is . - public void Defer(Control control, Rectangle bounds) - { - ArgumentNullException.ThrowIfNull(control); - _state?.Defer(control, bounds); - } - - /// - /// Applies the deferred location changes and resumes any bundled suspension scopes. - /// - public void Dispose() - { - _state?.Dispose(); - _relocationScope.Dispose(); - _paintingScope.Dispose(); - } - - /// - /// Holds mutable deferred-position state for . - /// - private sealed class State - { - private readonly Control _parent; - private readonly List _deferredPositions = []; - private HDWP _hdwp; - private bool _batchFailed; - - public State(Control parent) - { - _parent = parent; - _hdwp = parent.IsHandleCreated && parent.Controls.Count > 0 - ? PInvoke.BeginDeferWindowPos(parent.Controls.Count) - : HDWP.Null; - _batchFailed = _hdwp.IsNull || !parent.IsHandleCreated; - } - - public void Defer(Control control, Rectangle bounds) - { - DeferredWindowPosition deferredPosition = new(control, bounds); - _deferredPositions.Add(deferredPosition); - - if (_batchFailed) - { - return; - } - - if (!control.IsHandleCreated) - { - _batchFailed = true; - - return; - } - - HDWP hdwp = PInvoke.DeferWindowPos( - _hdwp, - (HWND)control.Handle, - HWND.Null, - bounds.X, - bounds.Y, - bounds.Width, - bounds.Height, - SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); - - if (hdwp.IsNull) - { - _hdwp = HDWP.Null; - _batchFailed = true; - - return; - } - - _hdwp = hdwp; - } - - public void Dispose() - { - if (!_batchFailed && !_hdwp.IsNull && PInvoke.EndDeferWindowPos(_hdwp)) - { - return; - } - - if (_batchFailed && !_hdwp.IsNull) - { - PInvoke.EndDeferWindowPos(_hdwp); - } - - foreach (DeferredWindowPosition deferredPosition in _deferredPositions) - { - Control control = deferredPosition.Control; - Rectangle bounds = deferredPosition.Bounds; - if (control.IsHandleCreated) - { - PInvoke.SetWindowPos( - control, - HWND.Null, - bounds.X, - bounds.Y, - bounds.Width, - bounds.Height, - SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); - } - else - { - control.Bounds = bounds; - } - } - - _parent.Invalidate(invalidateChildren: true); - } - } - - /// - /// Represents a deferred window-position request. - /// - private readonly record struct DeferredWindowPosition(Control Control, Rectangle Bounds); -} -#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Form.AppearanceMode.cs rename to src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/FormAppearanceMode.cs rename to src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs index 1a3bf4a0c4f..c596dfbc452 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -7,12 +7,20 @@ namespace System.Windows.Forms; /// /// Suspends painting for a target until the scope is disposed. /// -public readonly ref struct SuspendPaintingScope +/// +/// +/// This is a sealed class rather than a ref struct so the scope can span an +/// in an asynchronous UI event handler (for example, suspending painting for +/// the duration of an async data reload). is idempotent: disposing the scope more +/// than once only resumes painting once. +/// +/// +public sealed class SuspendPaintingScope : IDisposable { - private readonly ISupportSuspendPainting? _target; + private ISupportSuspendPainting? _target; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The target whose painting should be suspended. public SuspendPaintingScope(ISupportSuspendPainting? target) @@ -24,6 +32,12 @@ public SuspendPaintingScope(ISupportSuspendPainting? target) /// /// Resumes painting for the target associated with this scope. /// - public void Dispose() => _target?.EndSuspendPainting(); + public void Dispose() + { + // Idempotent: only the first Dispose call should resume painting, since the underlying + // refcount on the target was only incremented once, in the constructor. + _target?.EndSuspendPainting(); + _target = null; + } } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs index 5289fedd284..a86acb0e42f 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendRelocationScope.cs @@ -7,12 +7,19 @@ namespace System.Windows.Forms; /// /// Suspends relocation work for a target until the scope is disposed. /// -public readonly ref struct SuspendRelocationScope +/// +/// +/// This is a sealed class rather than a ref struct so the scope can span an +/// in an asynchronous UI event handler. is idempotent: +/// disposing the scope more than once only resumes relocation work once. +/// +/// +public sealed class SuspendRelocationScope : IDisposable { - private readonly ISupportSuspendRelocation? _target; + private ISupportSuspendRelocation? _target; /// - /// Initializes a new instance of the struct. + /// Initializes a new instance of the class. /// /// The target whose relocation work should be suspended. public SuspendRelocationScope(ISupportSuspendRelocation? target) @@ -24,6 +31,10 @@ public SuspendRelocationScope(ISupportSuspendRelocation? target) /// /// Resumes relocation work for the target associated with this scope. /// - public void Dispose() => _target?.EndSuspendRelocation(); + public void Dispose() + { + _target?.EndSuspendRelocation(); + _target = null; + } } #endif diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs index d2c8d96dba5..fa6b67eb5a2 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.Methods.cs @@ -20,10 +20,11 @@ public partial class ControlTests public void Control_BeginEndSuspendPainting_InvokeWithoutHandle_Success() { using SubControl control = new(); + ISupportSuspendPainting suspendPainting = control; - control.BeginSuspendPainting(); - control.EndSuspendPainting(); - control.EndSuspendPainting(); + suspendPainting.BeginSuspendPainting(); + suspendPainting.EndSuspendPainting(); + suspendPainting.EndSuspendPainting(); Assert.False(control.IsHandleCreated); } @@ -39,30 +40,39 @@ public void Control_SuspendPainting_ScopeDisposes_Success() } [WinFormsFact] - public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + public void Control_SuspendPainting_ScopeDispose_IsIdempotent() { using SubControl control = new(); - control.BeginSuspendRelocation(); - control.EndSuspendRelocation(); - control.EndSuspendRelocation(); + SuspendPaintingScope scope = control.SuspendPainting(); + scope.Dispose(); + scope.Dispose(); Assert.False(control.IsHandleCreated); } [WinFormsFact] - public void Control_DeferLocationChange_DisposeWithoutHandles_AppliesBounds() + public async Task Control_SuspendPainting_ScopeSpansAwait_Success() { - using SubControl parent = new(); - using SubControl child = new(); - parent.Controls.Add(child); + using SubControl control = new(); - using (DeferLocationChangeScope scope = parent.DeferLocationChange()) - { - scope.Defer(child, new Rectangle(1, 2, 3, 4)); - } + using SuspendPaintingScope scope = control.SuspendPainting(); + await Task.Yield(); + + Assert.False(control.IsHandleCreated); + } + + [WinFormsFact] + public void Control_BeginEndSuspendRelocation_Invoke_SuspendsLayout() + { + using SubControl control = new(); + ISupportSuspendRelocation suspendRelocation = control; - Assert.Equal(new Rectangle(1, 2, 3, 4), child.Bounds); + suspendRelocation.BeginSuspendRelocation(); + suspendRelocation.EndSuspendRelocation(); + suspendRelocation.EndSuspendRelocation(); + + Assert.False(control.IsHandleCreated); } #endif From 4d7a3ee2cfac0cafd62299fbf9c748eced5400b8 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 07/18] Rename FormAppearanceMode to FormRevealMode with Inherit ambient sentinel Matches the final API shape agreed in dotnet/winforms#14585: - FormAppearanceMode -> FormRevealMode, adding Inherit = -1 as the ambient sentinel (Classic stays the CLR default value 0, matching the RightToLeft.Inherit / VisualStylesMode.Inherit precedent for ambient enums - the sentinel is a distinct value, not the zero value, so default(FormRevealMode) stays conservative). - Form.FormRevealMode is now a real public virtual, PropertyStore-backed, [AmbientValue(Inherit)] property (previously there was no per-Form property at all - only the flat, process-wide Application.FormAppearanceMode existed). This lets a form such as a splash screen opt itself out of deferred reveal without touching the process-wide default. Resolution is flat (Form only, no Control-parent-chain): DWM cloaking only ever applies to top-level, non-MDI-child windows, so there is no hierarchy to walk, unlike VisualStylesMode's genuine control-nesting cascade. - Application.FormAppearanceMode / SetFormAppearanceMode are replaced by three members: DefaultFormRevealMode (get; may return the unresolved Inherit sentinel, mirroring ColorMode returning the unresolved System value), SetDefaultFormRevealMode (freely reassignable, unlike the write-once SetDefaultVisualStylesMode - the effective default is derived in part from ColorMode/IsDarkModeEnabled, which are themselves mutable for the life of the process), and IsFormRevealDeferred (bool; the fully resolved answer: Deferred, or Inherit + IsDarkModeEnabled). This also fixes a compatibility problem in the original design: the old default was unconditionally Deferred whenever SetFormAppearanceMode was never called, an opt-out behavior change for every existing app; tying the Inherit resolution to dark mode means an app that never touches SystemColorMode sees no behavior change, while the scenario the feature exists for (dark-mode startup flash) is fixed by default. - ShouldUseDeferredAppearanceCloak now reads the resolved Form.FormRevealMode instead of the old flat Application-only check, so a per-Form override is actually honored. - Adds SR.resx/xlf entries for the new property's designer description. - Updates/adds tests; updates PublicAPI.Unshipped.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PublicAPI.Unshipped.txt | 34 ++++---- src/System.Windows.Forms/Resources/SR.resx | 3 + .../Resources/xlf/SR.cs.xlf | 5 ++ .../Resources/xlf/SR.de.xlf | 5 ++ .../Resources/xlf/SR.es.xlf | 5 ++ .../Resources/xlf/SR.fr.xlf | 5 ++ .../Resources/xlf/SR.it.xlf | 5 ++ .../Resources/xlf/SR.ja.xlf | 5 ++ .../Resources/xlf/SR.ko.xlf | 5 ++ .../Resources/xlf/SR.pl.xlf | 5 ++ .../Resources/xlf/SR.pt-BR.xlf | 5 ++ .../Resources/xlf/SR.ru.xlf | 5 ++ .../Resources/xlf/SR.tr.xlf | 5 ++ .../Resources/xlf/SR.zh-Hans.xlf | 5 ++ .../Resources/xlf/SR.zh-Hant.xlf | 5 ++ .../System/Windows/Forms/Application.cs | 60 ++++++++++---- .../System/Windows/Forms/Form.RevealMode.cs | 62 ++++++++++++++- .../System/Windows/Forms/FormRevealMode.cs | 20 ++++- .../System/Windows/Forms/ApplicationTests.cs | 45 ++++++++--- .../Windows/Forms/FormTests.RevealMode.cs | 78 +++++++++++++++++++ 20 files changed, 322 insertions(+), 45 deletions(-) create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 72180973d18..aabf184383a 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,6 +1,6 @@ System.Windows.Forms.ControlMutationExtensions -static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope -static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope! +static System.Windows.Forms.ControlMutationExtensions.SuspendRelocation(this System.Windows.Forms.ISupportSuspendRelocation! target) -> System.Windows.Forms.SuspendRelocationScope! System.Windows.Forms.FormAppearanceMode System.Windows.Forms.FormAppearanceMode.Classic = 0 -> System.Windows.Forms.FormAppearanceMode System.Windows.Forms.FormAppearanceMode.Deferred = 1 -> System.Windows.Forms.FormAppearanceMode @@ -12,25 +12,23 @@ System.Windows.Forms.ISupportSuspendRelocation.BeginSuspendRelocation() -> void System.Windows.Forms.ISupportSuspendRelocation.EndSuspendRelocation() -> void System.Windows.Forms.SuspendPaintingScope System.Windows.Forms.SuspendPaintingScope.Dispose() -> void -System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope() -> void System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Forms.ISupportSuspendPainting? target) -> void System.Windows.Forms.SuspendRelocationScope System.Windows.Forms.SuspendRelocationScope.Dispose() -> void -System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope() -> void System.Windows.Forms.SuspendRelocationScope.SuspendRelocationScope(System.Windows.Forms.ISupportSuspendRelocation? target) -> void -override System.Windows.Forms.ComboBox.BeginSuspendPainting() -> void -override System.Windows.Forms.ComboBox.EndSuspendPainting() -> void -override System.Windows.Forms.ListBox.BeginSuspendPainting() -> void -override System.Windows.Forms.ListBox.EndSuspendPainting() -> void -override System.Windows.Forms.ListView.BeginSuspendPainting() -> void -override System.Windows.Forms.ListView.EndSuspendPainting() -> void -override System.Windows.Forms.RichTextBox.BeginSuspendPainting() -> void -override System.Windows.Forms.RichTextBox.EndSuspendPainting() -> void -override System.Windows.Forms.TreeView.BeginSuspendPainting() -> void -override System.Windows.Forms.TreeView.EndSuspendPainting() -> void static System.Windows.Forms.Application.FormAppearanceMode.get -> System.Windows.Forms.FormAppearanceMode static System.Windows.Forms.Application.SetFormAppearanceMode(System.Windows.Forms.FormAppearanceMode mode) -> void -virtual System.Windows.Forms.Control.BeginSuspendPainting() -> void -virtual System.Windows.Forms.Control.BeginSuspendRelocation() -> void -virtual System.Windows.Forms.Control.EndSuspendPainting() -> void -virtual System.Windows.Forms.Control.EndSuspendRelocation() -> void +virtual System.Windows.Forms.Control.BeginSuspendPaintingCore() -> void +virtual System.Windows.Forms.Control.BeginSuspendRelocationCore() -> void +virtual System.Windows.Forms.Control.EndSuspendPaintingCore() -> void +virtual System.Windows.Forms.Control.EndSuspendRelocationCore() -> void +override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ComboBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.EndSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.EndSuspendPaintingCore() -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..d4e99f5154c 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -3286,6 +3286,9 @@ Do you want to replace it? Indicates whether the form always appears above all other forms that do not have this property set to true. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + A color which will appear transparent when painted on the form. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..2b625c173a0 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -5420,6 +5420,11 @@ Chcete ho nahradit? Hodnota, kterou tento formulář vrátí, pokud bude zobrazen jako dialogové okno. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Událost aktivovaná při kliknutí na tlačítko nápovědy diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..dda4e94fb6c 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -5420,6 +5420,11 @@ Möchten Sie den Pfad ersetzen? Der Wert, den dieses Formular zurückgibt, wenn es als Dialogfeld angezeigt wird. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Das ausgelöste Ereignis, wenn auf die Schaltfläche "Hilfe" geklickt wird. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..2c0d87878de 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? Valor de este formulario si se muestra como un cuadro de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento que se desencadena cuando se hace clic en el botón Ayuda. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..05e2b3ef5bb 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -5420,6 +5420,11 @@ Voulez-vous le remplacer ? La valeur que ce formulaire retourne s'il est affiché en tant que boîte de dialogue. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Événement qui est déclenché à la suite d'un clic sur le bouton d'aide. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..a81d4d556b3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -5420,6 +5420,11 @@ Sostituirlo? Il valore che questo form restituirà se viene visualizzato come finestra di dialogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento generato quando si fa clic sul pulsante ?. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..80348613b82 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? ダイアログ ボックスとして表示された場合に、このフォームが返す値です。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [ヘルプ] ボタンがクリックされたときに発生するイベントです。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..1bc9c1a2274 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 대화 상자로 표시할 경우, 이 폼에서 반환하는 값입니다. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [도움말] 단추를 클릭하면 이벤트가 발생합니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..ab8fa7646e8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -5420,6 +5420,11 @@ Czy chcesz zastąpić? Wartość zwracana przez formularz, gdy jest on wyświetlany jako okno dialogowe. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Zdarzenie wywoływane po kliknięciu przycisku pomocy. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..c3a6f26c36f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -5420,6 +5420,11 @@ Deseja substituí-lo? O valor que este formulário retornará se exibido como uma caixa de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento gerado quando o usuário clica no botão de ajuda. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..7a7d81aee22 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? Значение, возвращаемое этой формой при ее отображении в виде диалогового окна. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Событие возникает при нажатии кнопки справки. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..16eb4fe27c8 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -5420,6 +5420,11 @@ Değiştirmek istiyor musunuz? Bu formun iletişim kutusu olarak görüntülendiğinde döndüreceği değer. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Yardım düğmesi tıklatıldığında harekete geçirilen olay. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..de3c271aae3 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 此窗体在显示为对话框时将返回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 单击“帮助”按钮时引发的事件。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..d1df8cf8af1 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -5420,6 +5420,11 @@ Do you want to replace it? 如果顯示為對話方塊,此表單會傳回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 按下說明按鈕時引發的事件。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index fd1fc9ba89c..b40d50ad9fc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -45,7 +45,7 @@ public sealed partial class Application private static SystemColorMode? s_colorMode; #if NET11_0_OR_GREATER - private static FormAppearanceMode? s_formAppearanceMode; + private static FormRevealMode? s_defaultFormRevealMode; #endif private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; @@ -254,16 +254,40 @@ internal static bool CustomThreadExceptionHandlerAttached #if NET11_0_OR_GREATER /// - /// Gets the configured form appearance mode for the application. + /// Gets the configured default used as the reveal behavior for + /// top-level forms that do not set explicitly. /// /// /// - /// If no mode has been configured with , - /// WinForms uses . + /// If no mode has been configured with , this + /// returns . Unlike , this + /// getter may return the unresolved sentinel; use + /// for the fully resolved answer. /// /// - public static FormAppearanceMode FormAppearanceMode - => s_formAppearanceMode ?? FormAppearanceMode.Deferred; + public static FormRevealMode DefaultFormRevealMode + => s_defaultFormRevealMode ?? FormRevealMode.Inherit; + + /// + /// Gets a value indicating whether newly created top-level forms use deferred reveal by default. + /// + /// + /// + /// This is the fully resolved answer: when + /// is , or when it is + /// (the default, when + /// has never been called) and is . + /// Accessibility trumps compatibility here: an application that opts into dark mode gets + /// flash-reduced form reveal automatically, without also having to call + /// explicitly. + /// + /// + public static bool IsFormRevealDeferred => DefaultFormRevealMode switch + { + FormRevealMode.Deferred => true, + FormRevealMode.Classic => false, + _ => IsDarkModeEnabled + }; #endif @@ -401,22 +425,30 @@ static void NotifySystemEventsOfColorChange() #if NET11_0_OR_GREATER /// - /// Sets the process-wide form appearance mode. + /// Sets the process-wide default used for top-level forms that do + /// not set explicitly. /// - /// The form appearance mode to use for newly created forms. + /// The default form reveal mode to use for newly created top-level forms. /// /// - /// Set the form appearance mode before creating UI to ensure newly created forms use the intended - /// startup presentation behavior. + /// Set the default form reveal mode before creating UI to ensure newly created forms use the + /// intended startup presentation behavior. Unlike the visual-styles default-mode setter (which is + /// write-once, since rendering-version selection is a static, one-time choice), this method can be + /// called more than once, and is a valid argument (it resets + /// the default back to its own ambient resolution via ). Free + /// reassignment is intentional: the effective default is derived in part from + /// and , which can themselves change for the lifetime of the process; + /// locking this value after first use would make forms created after a later dark-mode change use a + /// stale reveal behavior. /// /// - /// - /// is not a valid value. + /// + /// is not a valid value. /// - public static void SetFormAppearanceMode(FormAppearanceMode mode) + public static void SetDefaultFormRevealMode(FormRevealMode mode) { SourceGenerated.EnumValidator.Validate(mode, nameof(mode)); - s_formAppearanceMode = mode; + s_defaultFormRevealMode = mode; } #endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs index 853bdd55346..d3eee07dbd0 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; using Windows.Win32.Graphics.Dwm; namespace System.Windows.Forms; @@ -8,6 +9,65 @@ namespace System.Windows.Forms; public partial class Form { #if NET11_0_OR_GREATER + private static readonly int s_propFormRevealMode = PropertyStore.CreateKey(); + + /// + /// Gets or sets how this form is presented while its initial appearance is prepared. + /// + /// + /// A value. When not explicitly set, the effective value is + /// or , resolved from + /// . + /// + /// + /// + /// As an ambient property, a form that does not have this value set explicitly resolves it from + /// . Unlike a control-level ambient property that + /// chains through a parent hierarchy, this property does not chain through a parent hierarchy: + /// deferred reveal is a top-level-window-only concept (see remarks on + /// and the DWM cloaking mechanism it relies on), so only + /// itself, and the process-wide default, participate. + /// + /// + /// A splash screen or other form that should always appear instantly, even while the rest of the + /// application defers by default, can set this property on itself to + /// without affecting the process-wide default. + /// + /// + [SRCategory(nameof(SR.CatWindowStyle))] + [AmbientValue(FormRevealMode.Inherit)] + [SRDescription(nameof(SR.FormFormRevealModeDescr))] + public virtual FormRevealMode FormRevealMode + { + get + { + if (!Properties.TryGetValue(s_propFormRevealMode, out FormRevealMode value) + || value == FormRevealMode.Inherit) + { + value = Application.IsFormRevealDeferred ? FormRevealMode.Deferred : FormRevealMode.Classic; + } + + return value; + } + set + { + SourceGenerated.EnumValidator.Validate(value, nameof(value)); + + if (value == FormRevealMode.Inherit) + { + Properties.RemoveValue(s_propFormRevealMode); + } + else + { + Properties.AddValue(s_propFormRevealMode, value); + } + } + } + + private bool ShouldSerializeFormRevealMode() => Properties.ContainsKey(s_propFormRevealMode); + + private void ResetFormRevealMode() => Properties.RemoveValue(s_propFormRevealMode); + private bool DeferredAppearanceCloaked { get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); @@ -43,7 +103,7 @@ private void UncloakDeferredAppearanceIfNeeded() private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; private bool ShouldUseDeferredAppearanceCloak() - => Application.FormAppearanceMode == FormAppearanceMode.Deferred + => FormRevealMode == FormRevealMode.Deferred && TopLevel && !IsMdiChild && Visible diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs index f5e76f27151..076b339a558 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs @@ -5,12 +5,26 @@ namespace System.Windows.Forms; #if NET11_0_OR_GREATER /// -/// Specifies how WinForms presents a form while its initial appearance is prepared. +/// Specifies how WinForms presents a top-level while its initial appearance is +/// prepared. /// -public enum FormAppearanceMode +public enum FormRevealMode { /// - /// Uses the classic WinForms form presentation behavior. + /// The form inherits its effective reveal behavior from . + /// This is the ambient default and is never returned by after + /// resolution. + /// + /// + /// + /// This value is the ambient sentinel: assigning it to clears any + /// local override so the value is inherited from again. + /// + /// + Inherit = -1, + + /// + /// Uses the classic WinForms form presentation behavior. The form is never cloaked. /// Classic = 0, diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs index a4eeb2cf967..979b50df6ed 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ApplicationTests.cs @@ -182,26 +182,53 @@ public void Application_SetColorMode_PlausibilityTests() #if NET11_0_OR_GREATER [WinFormsFact] - public void Application_FormAppearanceMode_Default_ReturnsDeferred() + public void Application_DefaultFormRevealMode_Default_ReturnsInherit() { - Assert.Equal(FormAppearanceMode.Deferred, Application.FormAppearanceMode); + // Run in an isolated child process: DefaultFormRevealMode is process-wide state, and other tests + // in this shared-process test binary may have already called SetDefaultFormRevealMode. + RemoteExecutor.Invoke(() => + { + Assert.Equal(FormRevealMode.Inherit, Application.DefaultFormRevealMode); + }).Dispose(); } [WinFormsTheory] - [InlineData(FormAppearanceMode.Classic)] - [InlineData(FormAppearanceMode.Deferred)] - public void Application_SetFormAppearanceMode_GetReturnsExpected(FormAppearanceMode mode) + [InlineData(FormRevealMode.Classic)] + [InlineData(FormRevealMode.Deferred)] + [InlineData(FormRevealMode.Inherit)] + public void Application_SetDefaultFormRevealMode_GetReturnsExpected(FormRevealMode mode) { - Application.SetFormAppearanceMode(mode); - Assert.Equal(mode, Application.FormAppearanceMode); + Application.SetDefaultFormRevealMode(mode); + Assert.Equal(mode, Application.DefaultFormRevealMode); } [WinFormsFact] - public void Application_SetFormAppearanceMode_Invalid_ThrowsInvalidEnumArgumentException() + public void Application_SetDefaultFormRevealMode_Invalid_ThrowsInvalidEnumArgumentException() { Assert.Throws( "mode", - () => Application.SetFormAppearanceMode((FormAppearanceMode)int.MaxValue)); + () => Application.SetDefaultFormRevealMode((FormRevealMode)int.MaxValue)); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Classic_ReturnsFalse() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Classic); + Assert.False(Application.IsFormRevealDeferred); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Deferred_ReturnsTrue() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + Assert.True(Application.IsFormRevealDeferred); + } + + [WinFormsFact] + public void Application_IsFormRevealDeferred_Inherit_MatchesIsDarkModeEnabled() + { + Application.SetDefaultFormRevealMode(FormRevealMode.Inherit); + Assert.Equal(Application.IsDarkModeEnabled, Application.IsFormRevealDeferred); } #endif diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs new file mode 100644 index 00000000000..9b4aa4ef442 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/FormTests.RevealMode.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable disable + +using System.ComponentModel; + +namespace System.Windows.Forms.Tests; + +public partial class FormTests +{ +#if NET11_0_OR_GREATER + [WinFormsFact] + public void Form_FormRevealMode_Default_ResolvesFromApplication() + { + using SubForm form = new(); + + Application.SetDefaultFormRevealMode(FormRevealMode.Classic); + Assert.Equal(FormRevealMode.Classic, form.FormRevealMode); + + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + Assert.Equal(FormRevealMode.Deferred, form.FormRevealMode); + } + + [WinFormsTheory] + [InlineData(FormRevealMode.Classic)] + [InlineData(FormRevealMode.Deferred)] + public void Form_FormRevealMode_SetExplicit_OverridesApplicationDefault(FormRevealMode mode) + { + using SubForm form = new(); + FormRevealMode otherMode = mode == FormRevealMode.Classic ? FormRevealMode.Deferred : FormRevealMode.Classic; + + Application.SetDefaultFormRevealMode(otherMode); + form.FormRevealMode = mode; + + Assert.Equal(mode, form.FormRevealMode); + } + + [WinFormsFact] + public void Form_FormRevealMode_SetInherit_ClearsLocalOverride() + { + using SubForm form = new(); + + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + form.FormRevealMode = FormRevealMode.Classic; + Assert.Equal(FormRevealMode.Classic, form.FormRevealMode); + + form.FormRevealMode = FormRevealMode.Inherit; + + Assert.Equal(FormRevealMode.Deferred, form.FormRevealMode); + } + + [WinFormsFact] + public void Form_FormRevealMode_InvalidValue_ThrowsInvalidEnumArgumentException() + { + using SubForm form = new(); + + Assert.Throws( + "value", + () => form.FormRevealMode = (FormRevealMode)int.MaxValue); + } + + [WinFormsFact] + public void Form_FormRevealMode_ShouldSerialize_ResetRoundTrips() + { + using SubForm form = new(); + PropertyDescriptor property = TypeDescriptor.GetProperties(form)[nameof(Form.FormRevealMode)]; + + Assert.False(property.ShouldSerializeValue(form)); + + form.FormRevealMode = FormRevealMode.Classic; + Assert.True(property.ShouldSerializeValue(form)); + + property.ResetValue(form); + Assert.False(property.ShouldSerializeValue(form)); + } +#endif +} From cce946959e3b78a2c488608fecdfb8cb0c539a16 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 08/18] 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 f1b587071554fbc5e5f787e0e822d5f3fe032415 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 09/18] 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 aabf184383a..2d16cc2f71c 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,9 +1,10 @@ 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 @@ -16,12 +17,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 297142a0389908c5542c5bf305cf3119494d6e4b 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 10/18] 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 f4961ae73d6..23738c8285a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -4377,13 +4377,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); } @@ -5080,14 +5075,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) @@ -7421,6 +7415,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 d26d86f3f1fa3d2573b4d9e05a2be086b36a8163 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 11/18] 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 2d16cc2f71c..2eb117fce33 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ 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 @@ -8,22 +9,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 d4e99f5154c..cf553a591e9 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 2b625c173a0..fd44189ea4d 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 dda4e94fb6c..75551eccd43 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 2c0d87878de..153812cee57 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 05e2b3ef5bb..b1bace3ad03 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 a81d4d556b3..4ec5044c11f 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 80348613b82..26d309624a9 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 1bc9c1a2274..c0260dcdcc6 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 ab8fa7646e8..8c07152bb20 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 c3a6f26c36f..fa5a5b39c39 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 7a7d81aee22..b13343ddf1f 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 16eb4fe27c8..ce1c8d9102d 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 de3c271aae3..8c06531e9df 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 d1df8cf8af1..0c9401e204a 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 23738c8285a..349e7292dfd 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -228,6 +228,7 @@ public unsafe partial class Control : private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); #if NET11_0_OR_GREATER + private static readonly int s_recursiveInvalidateAfterSuspendPaintingProperty = PropertyStore.CreateKey(); private static readonly int s_suspendPaintingCountProperty = PropertyStore.CreateKey(); #endif private static readonly int s_updateCountProperty = PropertyStore.CreateKey(); @@ -5073,9 +5074,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, @@ -5084,6 +5087,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 4d6a3c53602..f894dd1485b 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 @@ -1569,10 +1569,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 99345a9d44f721680d7e8ab8cceb992fb72e3bce Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Tue, 21 Jul 2026 00:34:50 -0700 Subject: [PATCH 12/18] Fix FormRevealMode.Deferred cloak never engaging ShouldUseDeferredAppearanceCloak() gated the DWM cloak on Visible == true, but it is only evaluated from OnHandleCreated, where a top-level form's window is still hidden (WS_VISIBLE is cleared from the create params and the show is deferred). The Visible state bit is not set until WM_SHOWWINDOW is processed, so the guard was always false, the cloak never engaged, and the window was shown uncloaked - still producing the default-background flash the mode is meant to prevent. Remove the Visible term so the already-hidden window is cloaked at handle creation. This covers Show, ShowDialog (which creates the handle via CreateControl before showing) and handle recreation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 78934db49c7c1fac3da0c366c2a27a9e323ba2cd) --- .../System/Windows/Forms/Form.RevealMode.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 d3eee07dbd0..7742ad2a41b 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -103,10 +103,16 @@ private void UncloakDeferredAppearanceIfNeeded() private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; private bool ShouldUseDeferredAppearanceCloak() + // This is evaluated from OnHandleCreated, at which point a top-level form's window is + // always still hidden: Form clears WS_VISIBLE from the create params and defers the show + // (see CreateParams / s_formStateShowWindowOnCreate). The window's Visible state is only + // set later, while WM_SHOWWINDOW is processed. Checking Visible here would therefore never + // be true and the cloak would never engage, so the window is shown uncloaked and the + // default-background flash the mode is meant to prevent still occurs. Cloaking the + // already-hidden window now is exactly the intended behavior for both Show and ShowDialog. => FormRevealMode == FormRevealMode.Deferred && TopLevel && !IsMdiChild - && Visible && IsHandleCreated; private unsafe bool SetDwmCloak(bool cloaked) From f08f3cf06c9619aa7444e137259f89d9c7be51f6 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 22 Jul 2026 12:22:00 -0700 Subject: [PATCH 13/18] Apply API Review tweaks to flicker-free mutation and FormRevealMode APIs Flicker-free UI mutation API: - Rename LayoutSuspendTraversal members for clarity and redefine semantics: None -> TargetOnly (0), TopLevelOnly -> TargetAndChildren (1, new: target plus immediate children), Traverse -> TargetAndDescendants (2). The painting-only (no layout suspension) case is now served solely by the parameterless SuspendPainting overload. - Make SuspendPaintingScope internal; the three ControlMutationExtensions .SuspendPainting overloads now return IDisposable. The await-safe / idempotent guidance moves to the public extension-method docs so it stays discoverable. - Document that Control implements ISupportSuspendPainting explicitly (callers use the IDisposable scope; overriders use Begin/EndSuspendPaintingCore). FormRevealMode: - Add the FormRevealModeChanged event and protected virtual OnFormRevealModeChanged, raised from the setter when the effective value changes. Updates PublicAPI.Unshipped.txt, SR.resx, and ControlTests.Methods.cs (including a new TargetAndChildren test). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64d20bbb-13df-4e59-8bcc-c307909b03f8 --- .../PublicAPI.Unshipped.txt | 17 +++--- src/System.Windows.Forms/Resources/SR.resx | 3 + .../Forms/ControlMutationExtensions.cs | 27 ++++++--- .../System/Windows/Forms/Form.RevealMode.cs | 26 ++++++++ .../Windows/Forms/ISupportSuspendPainting.cs | 12 ++++ .../Windows/Forms/LayoutSuspendTraversal.cs | 17 +++--- .../Windows/Forms/SuspendPaintingScope.cs | 35 +++++++++-- .../Windows/Forms/ControlTests.Methods.cs | 59 +++++++++++++------ .../Windows/Forms/FormTests.RevealMode.cs | 33 +++++++++++ 9 files changed, 181 insertions(+), 48 deletions(-) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 2eb117fce33..0619f1dbb65 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -1,7 +1,8 @@ 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.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! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.IDisposable! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target, System.Func! suspendLayoutContainerFilter) -> System.IDisposable! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target, System.Windows.Forms.LayoutSuspendTraversal layoutSuspendTraversal) -> System.IDisposable! +System.Windows.Forms.Form.FormRevealModeChanged -> System.EventHandler? System.Windows.Forms.FormRevealMode System.Windows.Forms.FormRevealMode.Classic = 0 -> System.Windows.Forms.FormRevealMode System.Windows.Forms.FormRevealMode.Deferred = 1 -> System.Windows.Forms.FormRevealMode @@ -10,12 +11,9 @@ System.Windows.Forms.ISupportSuspendPainting System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> 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.LayoutSuspendTraversal.TargetAndChildren = 1 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.TargetAndDescendants = 2 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.TargetOnly = 0 -> System.Windows.Forms.LayoutSuspendTraversal 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 @@ -23,6 +21,7 @@ virtual System.Windows.Forms.Control.BeginSuspendPaintingCore() -> void virtual System.Windows.Forms.Control.EndSuspendPaintingCore() -> void virtual System.Windows.Forms.Form.FormRevealMode.get -> System.Windows.Forms.FormRevealMode virtual System.Windows.Forms.Form.FormRevealMode.set -> void +virtual System.Windows.Forms.Form.OnFormRevealModeChanged(System.EventArgs! e) -> void override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void override System.Windows.Forms.ComboBox.EndSuspendPaintingCore() -> void override System.Windows.Forms.ListBox.BeginSuspendPaintingCore() -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index cf553a591e9..8d8835e8d0e 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -3292,6 +3292,9 @@ 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. + + Occurs when the effective value of the FormRevealMode property changes. + A color which will appear transparent when painted on the form. diff --git a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs index 963199a4782..a67c5a87590 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs @@ -9,15 +9,24 @@ namespace System.Windows.Forms; /// /// Provides extension methods for batching synchronous WinForms UI mutations. /// +/// +/// +/// Each SuspendPainting overload returns an scope that resumes +/// painting (and any suspended layout) when disposed. The scope is a reference type rather than a +/// ref struct, so it can span an in an asynchronous UI event handler +/// (for example, suspending painting for the duration of an async data reload). Disposal is +/// idempotent: disposing a scope more than once resumes painting only once. +/// +/// 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); + /// An scope that resumes painting when disposed. + public static IDisposable SuspendPainting(this ISupportSuspendPainting target) + => new SuspendPaintingScope(target); /// /// Suspends painting and the selected layout work until the returned scope is disposed. @@ -26,16 +35,16 @@ public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting /// /// A value that specifies which controls in the target's control tree should suspend layout. /// - /// A scope that resumes layout and painting when disposed. + /// An scope that resumes layout and painting when disposed. /// is . /// /// is not a valid value. /// /// is not a . - public static SuspendPaintingScope SuspendPainting( + public static IDisposable SuspendPainting( this ISupportSuspendPainting target, LayoutSuspendTraversal layoutSuspendTraversal) - => new(target, layoutSuspendTraversal); + => new SuspendPaintingScope(target, layoutSuspendTraversal); /// /// Suspends painting and layout for selected controls until the returned scope is disposed. @@ -44,14 +53,14 @@ public static SuspendPaintingScope SuspendPainting( /// /// A predicate that returns for each control whose layout should be suspended. /// - /// A scope that resumes layout and painting when disposed. + /// An scope that resumes layout and painting when disposed. /// /// or is . /// /// is not a . - public static SuspendPaintingScope SuspendPainting( + public static IDisposable SuspendPainting( this ISupportSuspendPainting target, Func suspendLayoutContainerFilter) - => new(target, suspendLayoutContainerFilter); + => new SuspendPaintingScope(target, suspendLayoutContainerFilter); } #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 7742ad2a41b..bdaf0f724b7 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -10,6 +10,7 @@ public partial class Form { #if NET11_0_OR_GREATER private static readonly int s_propFormRevealMode = PropertyStore.CreateKey(); + private static readonly object s_formRevealModeChangedEvent = new(); /// /// Gets or sets how this form is presented while its initial appearance is prepared. @@ -53,6 +54,8 @@ public virtual FormRevealMode FormRevealMode { SourceGenerated.EnumValidator.Validate(value, nameof(value)); + FormRevealMode previous = FormRevealMode; + if (value == FormRevealMode.Inherit) { Properties.RemoveValue(s_propFormRevealMode); @@ -61,9 +64,32 @@ public virtual FormRevealMode FormRevealMode { Properties.AddValue(s_propFormRevealMode, value); } + + if (FormRevealMode != previous) + { + OnFormRevealModeChanged(EventArgs.Empty); + } } } + /// + /// Occurs when the effective value of the property changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.FormOnFormRevealModeChangedDescr))] + public event EventHandler? FormRevealModeChanged + { + add => Events.AddHandler(s_formRevealModeChangedEvent, value); + remove => Events.RemoveHandler(s_formRevealModeChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + protected virtual void OnFormRevealModeChanged(EventArgs e) + => (Events[s_formRevealModeChangedEvent] as EventHandler)?.Invoke(this, e); + private bool ShouldSerializeFormRevealMode() => Properties.ContainsKey(s_propFormRevealMode); private void ResetFormRevealMode() => Properties.RemoveValue(s_propFormRevealMode); diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs index 77c3ea39ee5..96839458abc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs @@ -7,6 +7,18 @@ namespace System.Windows.Forms; /// /// Provides methods for temporarily suspending and resuming painting. /// +/// +/// +/// implements this interface explicitly, so a control instance does not surface +/// and directly. Callers instead +/// obtain a disposable suspension scope through +/// , which pairs the +/// begin/end calls with deterministic cleanup. Types deriving from +/// customize the behavior by overriding +/// and +/// rather than the explicit interface members. +/// +/// public interface ISupportSuspendPainting { /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs index 119c35d6cb4..e01b642a4da 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs @@ -5,23 +5,26 @@ namespace System.Windows.Forms; #if NET11_0_OR_GREATER /// -/// Specifies how layout suspension traverses a control tree while painting is suspended. +/// Specifies which controls in a target's control tree suspend layout while painting is suspended. /// public enum LayoutSuspendTraversal { /// - /// Does not suspend layout. + /// Suspends layout for the target control only. The target does not re-lay-out its own children + /// while the scope is active; a nested container child can still perform its own layout. /// - None = 0, + TargetOnly = 0, /// - /// Suspends layout only for the target control. + /// Suspends layout for the target control and each of its immediate child controls. Because a + /// container suspends the layout of its own children, this additionally holds the layout of the + /// target's grandchildren, but not of any deeper descendants. /// - TopLevelOnly = 1, + TargetAndChildren = 1, /// - /// Suspends layout for the target control and all its descendants. + /// Suspends layout for the target control and every control in its subtree. /// - Traverse = 2, + TargetAndDescendants = 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 e65deea5d20..ff1fc82d174 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -8,7 +8,7 @@ namespace System.Windows.Forms; #if NET11_0_OR_GREATER /// -/// Suspends painting for a target until the scope is disposed. +/// Suspends painting (and optionally layout) for a target until the scope is disposed. /// /// /// @@ -17,8 +17,13 @@ namespace System.Windows.Forms; /// the duration of an async data reload). is idempotent: disposing the scope more /// than once only resumes painting once. /// +/// +/// The type is internal; callers obtain it as an from +/// , which is why the consumer-facing guidance above is also +/// documented on those extension methods. +/// /// -public sealed class SuspendPaintingScope : IDisposable +internal sealed class SuspendPaintingScope : IDisposable { private ISupportSuspendPainting? _target; private Control[]? _layoutControls; @@ -27,7 +32,7 @@ public sealed class SuspendPaintingScope : IDisposable /// Initializes a new instance of the class. /// /// The target whose painting should be suspended. - public SuspendPaintingScope(ISupportSuspendPainting? target) + internal SuspendPaintingScope(ISupportSuspendPainting? target) { _target = target; @@ -128,9 +133,9 @@ private static Control[] GetLayoutControls( return layoutSuspendTraversal switch { - LayoutSuspendTraversal.None => [], - LayoutSuspendTraversal.TopLevelOnly => [control], - LayoutSuspendTraversal.Traverse => GetLayoutControls(control, static _ => true), + LayoutSuspendTraversal.TargetOnly => [control], + LayoutSuspendTraversal.TargetAndChildren => GetTargetAndImmediateChildren(control), + LayoutSuspendTraversal.TargetAndDescendants => GetLayoutControls(control, static _ => true), _ => throw new InvalidEnumArgumentException( nameof(layoutSuspendTraversal), (int)layoutSuspendTraversal, @@ -138,6 +143,24 @@ private static Control[] GetLayoutControls( }; } + private static Control[] GetTargetAndImmediateChildren(Control target) + { + if (target.ChildControls is not { Count: > 0 } children) + { + return [target]; + } + + Control[] layoutControls = new Control[children.Count + 1]; + layoutControls[0] = target; + + for (int i = 0; i < children.Count; i++) + { + layoutControls[i + 1] = children[i]; + } + + return layoutControls; + } + private static Control[] GetLayoutControls( ISupportSuspendPainting target, Func suspendLayoutContainerFilter) 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 5d47db2cf35..63f9a59acbe 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 @@ -34,7 +34,7 @@ public void Control_SuspendPainting_ScopeDisposes_Success() { using SubControl control = new(); - using SuspendPaintingScope scope = control.SuspendPainting(); + using IDisposable scope = control.SuspendPainting(); Assert.False(control.IsHandleCreated); } @@ -44,7 +44,7 @@ public void Control_SuspendPainting_ScopeDispose_IsIdempotent() { using SubControl control = new(); - SuspendPaintingScope scope = control.SuspendPainting(); + IDisposable scope = control.SuspendPainting(); scope.Dispose(); scope.Dispose(); @@ -56,7 +56,7 @@ public async Task Control_SuspendPainting_ScopeSpansAwait_Success() { using SubControl control = new(); - using SuspendPaintingScope scope = control.SuspendPainting(); + using IDisposable scope = control.SuspendPainting(); await Task.Yield(); Assert.False(control.IsHandleCreated); @@ -67,7 +67,7 @@ public void Control_SuspendPainting_HandleCreatedWithinScope_RemainsSuspended() { using RedrawTrackingControl control = new(); - SuspendPaintingScope scope = control.SuspendPainting(); + IDisposable scope = control.SuspendPainting(); Assert.True(control.IsUpdating()); Assert.NotEqual(IntPtr.Zero, control.Handle); Assert.Equal([false], control.RedrawStates); @@ -84,7 +84,7 @@ public void Control_SuspendPainting_HandleRecreatedWithinScope_RemainsSuspended( using RedrawTrackingControl control = new(); Assert.NotEqual(IntPtr.Zero, control.Handle); - SuspendPaintingScope scope = control.SuspendPainting(); + IDisposable scope = control.SuspendPainting(); control.RedrawStates.Clear(); control.RecreateHandle(); @@ -98,24 +98,24 @@ public void Control_SuspendPainting_HandleRecreatedWithinScope_RemainsSuspended( } [WinFormsFact] - public void Control_SuspendPainting_WithNone_DoesNotSuspendLayout() + public void Control_SuspendPainting_Parameterless_DoesNotSuspendLayout() { using SubControl control = new(); - using SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.None); + using IDisposable scope = control.SuspendPainting(); Assert.Equal((byte)0, control.LayoutSuspendCount); Assert.False(control.IsHandleCreated); } [WinFormsFact] - public void Control_SuspendPainting_WithTopLevelOnly_SuspendsOnlyTargetLayout() + public void Control_SuspendPainting_WithTargetOnly_SuspendsOnlyTargetLayout() { using SubControl control = new(); using Control child = new(); control.Controls.Add(child); - SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetOnly); Assert.Equal((byte)1, control.LayoutSuspendCount); Assert.Equal((byte)0, child.LayoutSuspendCount); @@ -129,7 +129,7 @@ public void Control_SuspendPainting_WithTopLevelOnly_SuspendsOnlyTargetLayout() } [WinFormsFact] - public void Control_SuspendPainting_WithTraverse_SuspendsEntireTreeLayout() + public void Control_SuspendPainting_WithTargetAndDescendants_SuspendsEntireTreeLayout() { using SubControl control = new(); using Control child = new(); @@ -137,7 +137,7 @@ public void Control_SuspendPainting_WithTraverse_SuspendsEntireTreeLayout() control.Controls.Add(child); child.Controls.Add(grandchild); - SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.Traverse); + IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetAndDescendants); Assert.Equal((byte)1, control.LayoutSuspendCount); Assert.Equal((byte)1, child.LayoutSuspendCount); @@ -153,6 +153,31 @@ public void Control_SuspendPainting_WithTraverse_SuspendsEntireTreeLayout() Assert.Equal((byte)0, grandchild.LayoutSuspendCount); } + [WinFormsFact] + public void Control_SuspendPainting_WithTargetAndChildren_SuspendsTargetAndImmediateChildrenLayout() + { + using SubControl control = new(); + using Control child = new(); + using Control grandchild = new(); + control.Controls.Add(child); + child.Controls.Add(grandchild); + + IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetAndChildren); + + Assert.Equal((byte)1, control.LayoutSuspendCount); + Assert.Equal((byte)1, child.LayoutSuspendCount); + Assert.Equal((byte)0, 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() { @@ -163,7 +188,7 @@ public void Control_SuspendPainting_WithFilter_SkipsControlAndContinuesTraversal child.Controls.Add(grandchild); List visitedControls = []; - SuspendPaintingScope scope = control.SuspendPainting(candidate => + IDisposable scope = control.SuspendPainting(candidate => { visitedControls.Add(candidate); return candidate != child; @@ -189,8 +214,8 @@ public void Control_SuspendPainting_WithLayoutSuspension_DisposeIsIdempotentAndB int invalidatedCallCount = 0; control.Invalidated += (sender, e) => invalidatedCallCount++; - SuspendPaintingScope outerScope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); - SuspendPaintingScope innerScope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + IDisposable outerScope = control.SuspendPainting(LayoutSuspendTraversal.TargetOnly); + IDisposable innerScope = control.SuspendPainting(LayoutSuspendTraversal.TargetOnly); Assert.Equal((byte)2, control.LayoutSuspendCount); Assert.Equal([false], control.RedrawStates); @@ -221,7 +246,7 @@ public void Control_SuspendPainting_WithLayoutSuspension_ResumesChildrenBeforePa child.Layout += (sender, e) => events.Add("child-layout"); control.Invalidated += (sender, e) => events.Add("parent-invalidated"); - SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.Traverse); + IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetAndDescendants); control.PerformLayout(); child.PerformLayout(); @@ -326,7 +351,7 @@ public void Control_SuspendPainting_WhenNestedBeginThrowsBeforeAcquisition_Prese ThrowOnBegin = false }; Assert.NotEqual(IntPtr.Zero, control.Handle); - SuspendPaintingScope outerScope = control.SuspendPainting(); + IDisposable outerScope = control.SuspendPainting(); control.ThrowOnBegin = true; control.CallBaseBeforeThrow = false; @@ -345,7 +370,7 @@ public void Control_SuspendPainting_WhenLayoutResumeThrows_BalancesLayoutAndPain { using ThrowingLayoutResumingControl control = new(); Assert.NotEqual(IntPtr.Zero, control.Handle); - SuspendPaintingScope scope = control.SuspendPainting(LayoutSuspendTraversal.TopLevelOnly); + IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetOnly); control.ThrowOnLayoutResuming = true; Assert.Throws(() => scope.Dispose()); 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 index 9b4aa4ef442..bdc65a7f1fa 100644 --- 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 @@ -74,5 +74,38 @@ public void Form_FormRevealMode_ShouldSerialize_ResetRoundTrips() property.ResetValue(form); Assert.False(property.ShouldSerializeValue(form)); } + + [WinFormsFact] + public void Form_FormRevealModeChanged_RaisedOnlyWhenEffectiveValueChanges() + { + using SubForm form = new(); + form.FormRevealMode = FormRevealMode.Classic; + + int callCount = 0; + object eventSender = null; + EventArgs eventArgs = null; + EventHandler handler = (sender, e) => + { + callCount++; + eventSender = sender; + eventArgs = e; + }; + form.FormRevealModeChanged += handler; + + // Changing the effective value raises the event. + form.FormRevealMode = FormRevealMode.Deferred; + Assert.Equal(1, callCount); + Assert.Same(form, eventSender); + Assert.Same(EventArgs.Empty, eventArgs); + + // Setting the same effective value again does not raise the event. + form.FormRevealMode = FormRevealMode.Deferred; + Assert.Equal(1, callCount); + + // After removing the handler no further notifications are raised. + form.FormRevealModeChanged -= handler; + form.FormRevealMode = FormRevealMode.Classic; + Assert.Equal(1, callCount); + } #endif } From 157eae581104e930e4e1b06b1193d9c8f53d4136 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 22 Jul 2026 12:35:12 -0700 Subject: [PATCH 14/18] Sync XLF localization stubs for FormOnFormRevealModeChangedDescr Generated by the build after adding the FormRevealModeChanged event description to SR.resx. Keeps the localized .xlf files in sync with the neutral resources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64d20bbb-13df-4e59-8bcc-c307909b03f8 --- src/System.Windows.Forms/Resources/xlf/SR.cs.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.de.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.es.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.fr.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.it.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.ja.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.ko.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.pl.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.ru.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.tr.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf | 5 +++++ src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf | 5 +++++ 13 files changed, 65 insertions(+) diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index fd44189ea4d..3aed550bd05 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -5550,6 +5550,11 @@ Chcete ho nahradit? Vyvolá se vždy, když uživatel zavírá formulář, a to před jeho zavřením, a určí důvod zavření. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Vyvolá se vždy, když se změní jazyk použitý pro výstup tohoto formuláře. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index 75551eccd43..bb955e25bb9 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -5550,6 +5550,11 @@ Möchten Sie den Pfad ersetzen? Tritt ein, wenn der Benutzer das Formular schließt, bevor das Formular geschlossen wurde, und den Grund für das Schließen angibt. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Tritt ein, wenn sich die Eingabesprache für dieses Formular ändert. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 153812cee57..930a8faa623 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? Tiene lugar cuando el usuario cierra el formulario, antes de cerrarlo, y especifica el motivo del cierre. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Tiene lugar cuando el lenguaje de entrada utilizado para este formulario cambia. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index b1bace3ad03..d967dbb00da 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -5550,6 +5550,11 @@ Voulez-vous le remplacer ? Se produit lorsque l'utilisateur ferme le formulaire, avant la fermeture du formulaire et donne la raison de cette fermeture. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Se produit lorsque le langage utilisé pour les entrées de ce formulaire change. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index 4ec5044c11f..b11b08cab4d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -5550,6 +5550,11 @@ Sostituirlo? Generato prima della chiusura del form da parte dell'utente. Specifica il motivo della chiusura. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Generato quando la lingua per l'immissione di dati nel form viene modificata. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 26d309624a9..f9d023e4041 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? ユーザーがフォームを閉じるたびに、フォームが閉じられる前、および閉じる理由を指定する前に発生します。 + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. このフォームの入力に使用された言語が変更されたときに発生します。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index c0260dcdcc6..702abc478dd 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? 사용자가 폼을 닫을 때마다 또는 폼이 닫히기 전에 발생하며 닫는 이유를 지정합니다. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. 이 폼에 입력하는 데 사용하는 언어가 변경될 때마다 발생합니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index 8c07152bb20..4cd1fd9809b 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -5550,6 +5550,11 @@ Czy chcesz zastąpić? Występuje przy każdym zamknięciu formularza przez użytkownika przed zamknięciem formularza i określa przyczynę zamknięcia. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Występuje za każdym razem, gdy zostanie zmieniony język, w którym wypełniany jest formularz. 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 fa5a5b39c39..71a41d163df 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -5550,6 +5550,11 @@ Deseja substituí-lo? Ocorre sempre que o usuário fecha o formulário, antes do fechamento do formulário e especifica o motivo do fechamento. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Ocorre sempre que o idioma usado para entrada neste formulário é alterado. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index b13343ddf1f..1bdbd7770c5 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? Возникает при каждом завершении работы с формой до того, как форма была закрыта, и определяет причины этого закрытия. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Происходит при каждом изменении языка, используемого для заполнения этой формы. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index ce1c8d9102d..66858f226ea 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -5550,6 +5550,11 @@ Değiştirmek istiyor musunuz? Kullanıcı formu her kapattığında, form kapatılmadan önce gerçekleşir ve kapatılma nedenini belirtir. + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. Bu formda giriş için kullanılan dil her değiştiğinde gerçekleşir. 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 8c06531e9df..240948e75b5 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? 每当用户关闭窗体时,在窗体已关闭并指定关闭原因前发生。 + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. 每当更改此窗体的输入语言时发生。 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 0c9401e204a..9459ce03827 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -5550,6 +5550,11 @@ Do you want to replace it? 當使用者關閉表單,並於表單關閉前指定關閉原因時發生。 + + Occurs when the effective value of the FormRevealMode property changes. + Occurs when the effective value of the FormRevealMode property changes. + + Occurs whenever the language used for input for this form changes. 當用來輸入此表單的語言變更時發生。 From 4e5663192c7ad048eceba6b91df66907938dfd8e Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 22 Jul 2026 13:00:42 -0700 Subject: [PATCH 15/18] Refine LayoutSuspendTraversal: restore None, drop TargetAndChildren Per follow-up review: the parameterless SuspendPainting overload already covers the painting-only case, but keeping an explicit None = 0 restores the intuitive default (no layout suspension) and avoids default(enum) silently suspending. TargetAndChildren is removed because the distinction from TargetOnly was subtle and confusing (a container's SuspendLayout already holds its children's layout). Final enum: None = 0, TargetOnly = 1, TargetAndDescendants = 2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64d20bbb-13df-4e59-8bcc-c307909b03f8 --- .../PublicAPI.Unshipped.txt | 4 ++-- .../Windows/Forms/LayoutSuspendTraversal.cs | 13 ++++++------ .../Windows/Forms/SuspendPaintingScope.cs | 20 +------------------ .../Windows/Forms/ControlTests.Methods.cs | 17 +++------------- 4 files changed, 12 insertions(+), 42 deletions(-) diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 0619f1dbb65..5fb9a336a3c 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -11,9 +11,9 @@ System.Windows.Forms.ISupportSuspendPainting System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> void System.Windows.Forms.LayoutSuspendTraversal -System.Windows.Forms.LayoutSuspendTraversal.TargetAndChildren = 1 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.None = 0 -> System.Windows.Forms.LayoutSuspendTraversal System.Windows.Forms.LayoutSuspendTraversal.TargetAndDescendants = 2 -> System.Windows.Forms.LayoutSuspendTraversal -System.Windows.Forms.LayoutSuspendTraversal.TargetOnly = 0 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.TargetOnly = 1 -> System.Windows.Forms.LayoutSuspendTraversal 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 diff --git a/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs index e01b642a4da..b852d916955 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs @@ -10,17 +10,16 @@ namespace System.Windows.Forms; public enum LayoutSuspendTraversal { /// - /// Suspends layout for the target control only. The target does not re-lay-out its own children - /// while the scope is active; a nested container child can still perform its own layout. + /// Does not suspend layout. Painting is still suspended; use this value (or the parameterless + /// overload) when + /// no layout suspension is wanted. /// - TargetOnly = 0, + None = 0, /// - /// Suspends layout for the target control and each of its immediate child controls. Because a - /// container suspends the layout of its own children, this additionally holds the layout of the - /// target's grandchildren, but not of any deeper descendants. + /// Suspends layout for the target control only. /// - TargetAndChildren = 1, + TargetOnly = 1, /// /// Suspends layout for the target control and every control in its subtree. diff --git a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs index ff1fc82d174..e97284e55f2 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -133,8 +133,8 @@ private static Control[] GetLayoutControls( return layoutSuspendTraversal switch { + LayoutSuspendTraversal.None => [], LayoutSuspendTraversal.TargetOnly => [control], - LayoutSuspendTraversal.TargetAndChildren => GetTargetAndImmediateChildren(control), LayoutSuspendTraversal.TargetAndDescendants => GetLayoutControls(control, static _ => true), _ => throw new InvalidEnumArgumentException( nameof(layoutSuspendTraversal), @@ -143,24 +143,6 @@ private static Control[] GetLayoutControls( }; } - private static Control[] GetTargetAndImmediateChildren(Control target) - { - if (target.ChildControls is not { Count: > 0 } children) - { - return [target]; - } - - Control[] layoutControls = new Control[children.Count + 1]; - layoutControls[0] = target; - - for (int i = 0; i < children.Count; i++) - { - layoutControls[i + 1] = children[i]; - } - - return layoutControls; - } - private static Control[] GetLayoutControls( ISupportSuspendPainting target, Func suspendLayoutContainerFilter) 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 63f9a59acbe..428fd19325b 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 @@ -154,28 +154,17 @@ public void Control_SuspendPainting_WithTargetAndDescendants_SuspendsEntireTreeL } [WinFormsFact] - public void Control_SuspendPainting_WithTargetAndChildren_SuspendsTargetAndImmediateChildrenLayout() + public void Control_SuspendPainting_WithNone_DoesNotSuspendLayout() { using SubControl control = new(); using Control child = new(); - using Control grandchild = new(); control.Controls.Add(child); - child.Controls.Add(grandchild); - IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.TargetAndChildren); - - Assert.Equal((byte)1, control.LayoutSuspendCount); - Assert.Equal((byte)1, child.LayoutSuspendCount); - Assert.Equal((byte)0, grandchild.LayoutSuspendCount); - Assert.False(control.IsHandleCreated); - Assert.False(child.IsHandleCreated); - Assert.False(grandchild.IsHandleCreated); - - scope.Dispose(); + using IDisposable scope = control.SuspendPainting(LayoutSuspendTraversal.None); Assert.Equal((byte)0, control.LayoutSuspendCount); Assert.Equal((byte)0, child.LayoutSuspendCount); - Assert.Equal((byte)0, grandchild.LayoutSuspendCount); + Assert.False(control.IsHandleCreated); } [WinFormsFact] From 44a12792489dfb0c4debd2a821594d3b9d139dd5 Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Wed, 22 Jul 2026 14:20:12 -0700 Subject: [PATCH 16/18] Fix deferred FormRevealMode residual flash by revealing after full first paint Deferred reveal cloaked the form at OnHandleCreated and uncloaked on the form's own first WM_PAINT. That paint fires before the child controls - separate child windows - paint their first frame, so the window was revealed mid-paint and the tail of the startup flash (controls popping in) was still visible. Reveal only after the whole control tree has painted its first frame: - Add Form.RevealDeferredAppearance(): force a synchronous full-tree paint of the still-cloaked window (RedrawWindow RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN| RDW_UPDATENOW), then uncloak, so the finished frame appears at once. It is a no-op once revealed / never cloaked, and guarded against re-entrancy since RDW_UPDATENOW dispatches WM_PAINT synchronously. - Trigger it from OnShown (CallShownEvent) as the primary, guaranteed one-shot point that runs before the first natural WM_PAINT, and keep the WM_PAINT hook as an idempotent fallback so the window can never be revealed mid-paint or stay stuck cloaked. Adds a safety-invariant test: a shown Deferred form must not remain cloaked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64d20bbb-13df-4e59-8bcc-c307909b03f8 --- .../System/Windows/Forms/Form.RevealMode.cs | 56 ++++++++++++++++++- .../System/Windows/Forms/Form.cs | 11 +++- .../Windows/Forms/FormTests.RevealMode.cs | 29 ++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) 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 bdaf0f724b7..73b8d280592 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -12,6 +12,10 @@ public partial class Form private static readonly int s_propFormRevealMode = PropertyStore.CreateKey(); private static readonly object s_formRevealModeChangedEvent = new(); + // Guards RevealDeferredAppearance against re-entrancy: its forced RDW_UPDATENOW repaint can + // dispatch WM_PAINT synchronously, and the form's WM_PAINT handler also calls the reveal. + private bool _isRevealingDeferredAppearance; + /// /// Gets or sets how this form is presented while its initial appearance is prepared. /// @@ -94,7 +98,7 @@ protected virtual void OnFormRevealModeChanged(EventArgs e) private void ResetFormRevealMode() => Properties.RemoveValue(s_propFormRevealMode); - private bool DeferredAppearanceCloaked + internal bool DeferredAppearanceCloaked { get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); set => Properties.AddOrRemoveValue(s_propFormAppearanceCloaked, value, defaultValue: false); @@ -126,6 +130,56 @@ private void UncloakDeferredAppearanceIfNeeded() } } + /// + /// Reveals a form that was cloaked for deferred appearance, once its entire control tree has + /// painted its first frame. + /// + /// + /// + /// The window is cloaked from while still hidden and shown while + /// cloaked, so nothing reaches the screen yet. Simply uncloaking on the form's own first + /// WM_PAINT would reveal the window before its child controls — each a separate window — + /// have painted, leaving a residual flash of controls popping in. This forces a synchronous + /// paint of the whole tree ( plus + /// ) into the still-cloaked redirection surface, + /// then uncloaks, so the finished first frame appears at once. It is a no-op once the form has + /// already been revealed (or was never cloaked), so it is safe to call from more than one hook. + /// + /// + /// dispatches WM_PAINT synchronously, and + /// one of this method's callers is the form's own WM_PAINT handler, so a re-entrancy guard + /// prevents the forced repaint from recursively re-entering the reveal before the cloak clears. + /// + /// + private void RevealDeferredAppearance() + { + if (!DeferredAppearanceCloaked || _isRevealingDeferredAppearance) + { + return; + } + + _isRevealingDeferredAppearance = true; + try + { + // Flush the first-frame paint for the form and its entire child tree into the still-cloaked + // redirection surface before revealing, so the finished frame appears in one step. + PInvoke.RedrawWindow( + this, + lprcUpdate: null, + HRGN.Null, + REDRAW_WINDOW_FLAGS.RDW_INVALIDATE + | REDRAW_WINDOW_FLAGS.RDW_ERASE + | REDRAW_WINDOW_FLAGS.RDW_ALLCHILDREN + | REDRAW_WINDOW_FLAGS.RDW_UPDATENOW); + + UncloakDeferredAppearanceIfNeeded(); + } + finally + { + _isRevealingDeferredAppearance = false; + } + } + private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; private bool ShouldUseDeferredAppearanceCloak() diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index 62055889de5..2e61d7cdb68 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -3803,6 +3803,12 @@ public static SizeF GetAutoScaleSize(Font font) private void CallShownEvent() { OnShown(EventArgs.Empty); +#if NET11_0_OR_GREATER + // Primary deferred-reveal trigger: OnShown is a guaranteed one-shot for a shown top-level + // form and runs before the first natural WM_PAINT, so revealing here shows the fully painted + // window tree at once rather than mid-paint. + RevealDeferredAppearance(); +#endif } /// @@ -7188,7 +7194,10 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_PAINT: base.WndProc(ref m); #if NET11_0_OR_GREATER - UncloakDeferredAppearanceIfNeeded(); + // Fallback deferred-reveal trigger. RevealDeferredAppearance is idempotent and forces + // a full-tree paint before uncloaking, so even if OnShown was delayed the window is + // never revealed mid-paint and never stays stuck cloaked. + RevealDeferredAppearance(); #endif break; 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 index bdc65a7f1fa..2222ad1b194 100644 --- 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 @@ -107,5 +107,34 @@ public void Form_FormRevealModeChanged_RaisedOnlyWhenEffectiveValueChanges() form.FormRevealMode = FormRevealMode.Classic; Assert.Equal(1, callCount); } + + [WinFormsFact] + public void Form_FormRevealMode_Deferred_ShowDoesNotRemainCloaked() + { + FormRevealMode originalDefault = Application.DefaultFormRevealMode; + + try + { + Application.SetDefaultFormRevealMode(FormRevealMode.Deferred); + + using SubForm form = new(); + Assert.Equal(FormRevealMode.Deferred, form.FormRevealMode); + + form.Show(); + + // A deferred form is cloaked while it paints its first frame and is revealed from the + // posted OnShown callback; pump the message queue so that reveal runs. Regardless of + // whether the DWM cloak actually engaged in this environment, the form must never be + // left cloaked once shown. + Application.DoEvents(); + + Assert.True(form.Visible); + Assert.False(form.DeferredAppearanceCloaked); + } + finally + { + Application.SetDefaultFormRevealMode(originalDefault); + } + } #endif } From 1400897893e6f4168985b5b483b9e7bdf99bcf9d Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Thu, 23 Jul 2026 00:35:04 -0700 Subject: [PATCH 17/18] Fix FormRevealMode doc cref namespace qualification Several XML doc comments referenced , an inconsistent partial-namespace qualification for the FormRevealMode enum. Use the unqualified enum name FormRevealMode (as elsewhere in the file, e.g. FormRevealMode.Inherit) so the cref resolves cleanly and matches the surrounding "a FormRevealMode value" wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 19883d86-6d5f-4d51-94c2-81d0bf3fc3ff --- .../System/Windows/Forms/Application.cs | 6 +++--- .../System/Windows/Forms/Form.RevealMode.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index b40d50ad9fc..673b91856e9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -254,7 +254,7 @@ internal static bool CustomThreadExceptionHandlerAttached #if NET11_0_OR_GREATER /// - /// Gets the configured default used as the reveal behavior for + /// Gets the configured default used as the reveal behavior for /// top-level forms that do not set explicitly. /// /// @@ -425,7 +425,7 @@ static void NotifySystemEventsOfColorChange() #if NET11_0_OR_GREATER /// - /// Sets the process-wide default used for top-level forms that do + /// Sets the process-wide default used for top-level forms that do /// not set explicitly. /// /// The default form reveal mode to use for newly created top-level forms. @@ -443,7 +443,7 @@ static void NotifySystemEventsOfColorChange() /// /// /// - /// is not a valid value. + /// is not a valid value. /// public static void SetDefaultFormRevealMode(FormRevealMode mode) { 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 73b8d280592..c2baf6166d0 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -20,7 +20,7 @@ public partial class Form /// Gets or sets how this form is presented while its initial appearance is prepared. /// /// - /// A value. When not explicitly set, the effective value is + /// A value. When not explicitly set, the effective value is /// or , resolved from /// . /// From 0d15efd074c8c5d6561d68223fa80708fccb3b4a Mon Sep 17 00:00:00 2001 From: Klaus Loeffelmann Date: Thu, 23 Jul 2026 04:43:29 -0700 Subject: [PATCH 18/18] Remove trailing whitespace from high-risk review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 86cc3454-1c71-4d7f-a766-231b55099101 --- docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md index 0150a26b9a5..7395038dc54 100644 --- a/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md +++ b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md @@ -4,7 +4,7 @@ This document records a review finding that needs native lifecycle investigation ## Deferred reveal is not activated during initial display -Reviewed merge base: `8b618e7f5` +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.