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/Application/net11-VisualStylesMode/WorkOrder_ComboBox_ModernChromeChokepoint.md b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/WorkOrder_ComboBox_ModernChromeChokepoint.md new file mode 100644 index 00000000000..a5534a649df --- /dev/null +++ b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/WorkOrder_ComboBox_ModernChromeChokepoint.md @@ -0,0 +1,147 @@ +# Work-Order: Centralize ComboBox Modern Chrome State into an Idempotent Chokepoint + +**Area:** WinForms Runtime — `VisualStylesMode` / .NET 11 visual modernization +**Component:** `System.Windows.Forms.ComboBox` (modern `FlatStyle` rendering) +**Branch baseline:** `KlausLoeffelmann/winforms` @ `d6ca49640903fa4f15be20b427f253bb940855d4` (`Net11/VisualStylesMode-Fixes`, rebased on `Net11/Integration-3`, upstream draft PR dotnet/winforms#14768) +**Type:** Rearchitecture (bug-class elimination), supersedes point-fix follow-ups to the "1-pixel internal padding" work-order. + +--- + +## 1. Problem Statement + +The modern ComboBox chrome (`UsesModernComboAdapter == true`) mutates **three independent pieces of native state**: + +1. `CB_SETITEMHEIGHT(-1)` — the native selection-field height, +2. `EM_SETMARGINS` on the child edit HWND, +3. `SetWindowPos` on the child edit (and, for `ComboBoxStyle.Simple`, the child list) HWND. + +Each mutation has its own capture/restore bookkeeping (`_modernEditBaseBounds`, `_modernEditBaseClientSize`, `_modernSimpleListBaseBounds`, `_modernFieldHeightApplied`, `_modernHandleInitialized`), and the mutations are applied from **many triggers** (`OnVisualStylesModeChanged`, `OnHandleCreated`, `OnFontChanged`, `OnPaddingChanged`, `OnSystemVisualSettingsChanged`, `RescaleConstantsForDpi`, `WM_SETFONT` on the child, `WM_WINDOWPOSCHANGED` for Simple, `UpdateItemHeight`, the `FlatStyle` setter) in trigger-specific orderings that depend on what `base.*` does in between. + +This produces an entire **class** of state-desynchronization bugs. Four confirmed instances against the pinned baseline: + +### Defect A — Vertical/horizontal inset asymmetry (edit overshoots the rounded border) +`UpdateModernEditMargins` applies `Fixed3DBorderPadding (2) + InternalChromeInset (2) + ComboBoxStyleInset (1)` = 5 logical px **horizontally** via `EM_SETMARGINS`, but `UpdateModernEditBounds` insets the edit **vertically** by only `ComboBoxStyleInset (1) + Padding.Top/Bottom`. The rounded border ring consumes ~3 px vertically (Fixed3D zone + AA stroke), so with `Padding.Empty` the edit client overlaps the border curve. `Padding = 2` masks it, which confirms the diagnosis. + +### Defect B — `EM_SETMARGINS` never reset on modern → classic +In `ComboBox.OnVisualStylesModeChanged`, `RestoreAndResetModernEditBounds()` empties `_modernEditBaseBounds` **before** the trailing `UpdateModernEditMargins()` call; that method's guard `(!UsesModernComboAdapter && _modernEditBaseBounds.IsEmpty)` then early-returns, so the classic edit child keeps the 5 px modern margins. + +### Defect C — Restore targets `COMBOBOXINFO.rcItem`, not the edit's native bounds +The DropDown path seeds `_modernEditBaseBounds` from `rcItem` (the combo's selection-field rect) but restores the **edit HWND** to that rect. The edit natively sits *inside* `rcItem` with a theme inset. The Simple path already does this correctly via `GetChildBounds(comboBoxInfo.hwndItem)`; the DropDown path does not. + +### Defect D — Delta-based `CB_SETITEMHEIGHT` is order-fragile and non-idempotent +`ApplyPreferredFieldHeight` computes `heightDelta = preferredHeight - Height` and applies it **relative** to the current item height. Before this runs, `Control.OnVisualStylesModeChanged` (`Metrics` impact) has already executed `RequestLayout`, `UpdateStyles`, and `RefreshVisualStylesModeNonClientArea` (SetWindowPos w/ `SWP_FRAMECHANGED`). Whenever any of these reconciles `Height` first — anchored controls, `TableLayoutPanel` cells, designer behavior service, or **deserialization**, where the mode transition occurs before handle creation and `Height` is already set to the target — the delta collapses to `0` and the native selection-field height silently keeps the previous mode's value. Because the math is relative, each round-trip can accumulate error. This is the primary suspect for the classic missing drop-down button and the misplaced button/frame remnants after classic → modern round-trips, and it fully explains why every deserialization pass re-triggers the corruption. + +--- + +## 2. Target Architecture + +Replace the trigger-specific mutation choreography with **one idempotent chokepoint**, mirroring the `EffectiveVisualStylesMode` chokepoint pattern already established for the .NET 11 work: many inputs, one authoritative computation, one applier. + +### 2.1 Core principle + +> The chokepoint computes the complete **target native state absolutely** from current inputs and applies it. It never reads back its own previous output to decide the next one, and calling it N times in any order after any trigger yields the same native state as calling it once. + +There is no separate "restore" path: applying the chokepoint while `UsesModernComboAdapter == false` *is* the restore, because the target state for classic mode is the captured native baseline. + +### 2.2 New shape (in `ComboBox.Modern.cs`) + +```csharp +/// +/// Captures the native, unmodified child layout of the ComboBox once per handle +/// lifetime, before any modern chrome mutation is applied. +/// +private readonly struct NativeComboBaseline +{ + public int SelectionFieldItemHeight { get; init; } + public Rectangle EditBounds { get; init; } + public Rectangle SimpleListBounds { get; init; } + public Size ClientSize { get; init; } + + public bool IsCaptured + => !EditBounds.IsEmpty || SelectionFieldItemHeight > 0; +} + +/// +/// The complete target state for the ComboBox's native children under the +/// current effective visual styles mode. +/// +private readonly struct ModernComboTargetState +{ + public int SelectionFieldItemHeight { get; init; } + public Padding EditMargins { get; init; } + public Rectangle EditBounds { get; init; } + public Rectangle SimpleListBounds { get; init; } +} + +/// +/// Single chokepoint. Computes the absolute target state for item height, +/// edit margins, and child bounds from the current inputs and applies it. +/// Idempotent; safe to call from any trigger in any order. +/// +private void ApplyModernComboLayout(); +``` + +### 2.3 Rules + +1. **Baseline capture happens exactly once per handle lifetime**, in `OnHandleCreated`, *before* the first mutation: native default `CB_GETITEMHEIGHT(-1)`, `GetChildBounds(hwndItem)` (fixes Defect C — use child bounds, never `rcItem`), `GetChildBounds(hwndList)` for Simple, and the `ClientSize` at capture time. Cleared in `OnHandleDestroyed`. +2. **Item height is computed absolutely** (fixes Defect D): + - Modern: derive the target selection-field height from `ModernPreferredHeight` minus the native frame, measured against the **HWND** (`GetWindowRect`), never against `Control.Height` bookkeeping. + - Classic: target = `NativeComboBaseline.SelectionFieldItemHeight`. + - Apply only when the current native value differs (read-compare-write, so repaint/relayout storms are avoided while idempotency is preserved). +3. **Edit margins are computed absolutely**: modern = the existing `Fixed3DBorderPadding + InternalChromeInset + ComboBoxStyleInset` formula plus `Padding.Left/Right`; classic = `0` (fixes Defect B — restore is just "apply classic target"). +4. **Vertical edit insets use the same chrome formula as the horizontal margins** (fixes Defect A): `topInset`/`bottomInset` = scaled `Fixed3DBorderPadding + ComboBoxStyleInset` (+ `Padding.Top/Bottom`), sourced from a single `GetModernChromeInsets()` helper so the border geometry (`CreateFieldPath`, `ClearNativeFrame`) and the child layout can never diverge again. If the "≥ 1 px internal padding" requirement from the previous work-order needs adjusting, it is adjusted **here, in one place**. +5. **All triggers call only the chokepoint.** `OnVisualStylesModeChanged`, `OnHandleCreated`, `OnFontChanged`, `OnPaddingChanged`, `OnSystemVisualSettingsChanged`, `RescaleConstantsForDpi`, child `WM_SETFONT`, `WM_WINDOWPOSCHANGED` (Simple), `UpdateItemHeight`, and the `FlatStyle` setter each reduce to: invalidate caches as needed → `ApplyModernComboLayout()`. Ordering relative to `base.*` calls no longer matters, because the chokepoint reads only current inputs. +6. **Reentrancy guard lives inside the chokepoint** (`_adjustingModernChildBounds` moves in; callers never manage it). +7. **Delete** `RestoreModernEditBounds`, `RestoreAndResetModernEditBounds`, `ResetModernEditBounds`, `ResizeModernSimpleBaseBounds`, `UpdateModernEditMargins`, `UpdateModernEditBounds`, `UpdateModernSimpleEditBounds`, `ApplyPreferredFieldHeight`'s delta logic, and the fields `_modernEditAppliedBounds`, `_modernEditBaseBounds`, `_modernEditBaseClientSize`, `_modernFieldHeightApplied`, `_modernSimpleListBaseBounds`. Replaced by `NativeComboBaseline` + the chokepoint. +8. `WinFormsApplicationBuilder`/host concerns are out of scope; this is runtime-internal. Public API surface is unchanged (`FlatStyle`, `Padding`, `PreferredHeight` semantics stay as shipped in Integration-3). + +### 2.4 Explicit non-goals + +- No changes to `ModernComboAdapter` painting itself except consuming `GetModernChromeInsets()` for its geometry constants. +- No changes to the `EffectiveVisualStylesMode` propagation in `Control` — the chokepoint must work with the existing `Metrics`/`Repaint` impact model as-is. +- No behavioral change for `FlatStyle.System` (remains fully native) or for classic `Flat`/`Popup` (existing `FlatComboAdapter`). + +--- + +## 3. Tasks + +| # | Task | Notes | +|---|------|-------| +| 1 | Introduce `NativeComboBaseline` capture in `OnHandleCreated`; clear in `OnHandleDestroyed`. | Capture **before** dark-mode theming and before any `CB_SETITEMHEIGHT`. | +| 2 | Introduce `GetModernChromeInsets()` shared by adapter geometry and child layout. | Single source of truth for Defect A. | +| 3 | Implement `ComputeModernComboTargetState()` (pure, absolute). | Inputs: `EffectiveVisualStylesMode`, `FlatStyle`, `DropDownStyle`, `Font`/`FontHeight`, `Padding`, `DeviceDpiInternal`, `SystemVisualSettings`, `RightToLeft`, baseline. | +| 4 | Implement `ApplyModernComboLayout()` applier with read-compare-write per state item and internal reentrancy guard. | Item height via HWND-measured frame, not `Control.Height`. | +| 5 | Reroute all triggers listed in Rule 5 through the chokepoint; delete legacy methods/fields per Rule 7. | `OnVisualStylesModeChanged` body shrinks to cache invalidation + `base` + chokepoint + `RefreshModernDropDownCornerPreference`. | +| 6 | Verify `DrawMode.OwnerDrawFixed/Variable` interplay: `UpdateItemHeight`'s explicit `CB_SETITEMHEIGHT` calls must not fight the chokepoint. | Owner-draw item heights own indices ≥ 0; chokepoint owns index −1 in Normal mode; define precedence for owner-draw −1. | +| 7 | Add a debug assertion helper that dumps `CB_GETITEMHEIGHT(-1)`, `COMBOBOXINFO.rcItem/rcButton`, and edit HWND bounds — used for the acceptance runs below. | Also resolves the open question whether the missing classic arrow is a Defect D consequence (degenerate `rcButton`) or an overlap. | +| 8 | Consider a `WFCC` `ComponentChange` entry if any observable classic-mode behavior shifts (e.g., corrected edit inset after round-trip). | Only if acceptance runs surface an observable delta vs. .NET 10 classic. | + +--- + +## 4. Acceptance Criteria + +1. **Round-trip invariance:** `Classic → Net11 → Classic` repeated 10× (runtime *and* designer-hosted) yields native state byte-identical to a freshly created classic ComboBox: item height, edit HWND bounds, `EM_GETMARGINS`, `rcButton` non-degenerate, drop-down arrow visible. +2. **Order independence:** Deserialization sequences that set `FlatStyle`/`Padding`/`Font`/mode in any order, before or after handle creation, converge to the same state. Specifically: mode transition before handle creation must not zero out the item-height adjustment (Defect D repro). +3. **Layout-container safety:** Combo inside `TableLayoutPanel` and with `Anchor = Top|Bottom` survives mode round-trips without frame remnants or misplaced button (Screenshot 4/5 repro). +4. **No overshoot at `Padding.Empty`:** the edit client rect is fully contained within the rounded field path at 100 %, 150 %, 200 % DPI (Screenshot 1 repro). `Padding` values add on top and never subtract. +5. **Simple style:** resize + mode round-trips keep edit/list stacked correctly; no drift of `_modernSimpleListBaseBounds`-equivalent state. +6. **RTL:** all of the above with `RightToLeft.Yes`. +7. **Idempotency probe:** calling `ApplyModernComboLayout()` 5× consecutively produces zero additional native messages after the first call (read-compare-write verified via message trace). +8. Existing modern paint tests (adapter geometry, chevron, `DropDownList` text) pass unchanged. + +--- + +## 5. Risks / Open Questions + +- **Native height authority:** for non-Simple styles the native control owns window height derived from item height; the chokepoint must treat `CB_SETITEMHEIGHT(-1)` as the *only* height lever and let `Control.Height` bookkeeping follow via `WM_WINDOWSPOSCHANGED`, never the reverse. Any remaining `Height = preferredHeight` assignments should be audited for redundancy. +- **Owner-draw precedence** (Task 6) needs an explicit decision; proposal: in `DrawMode != Normal`, the chokepoint does not touch index −1 and modern metrics derive the field height from the owner-draw value instead. +- **Classic missing-arrow root cause** is expected to be Defect D but must be confirmed via the Task 7 dump before closing; if it turns out to be an overlap issue, Task 1's child-bounds baseline already covers it. + +--- + +## 6. References (pinned) + +- `ComboBox.cs`, `ComboBox.Modern.cs`, `ComboBox.ModernComboAdapter.cs`, `ComboBox.FlatComboAdapter.cs`, `Rendering/ModernControlVisualStyles.cs` — all at + `https://github.com/KlausLoeffelmann/winforms/blob/d6ca49640903fa4f15be20b427f253bb940855d4/src/System.Windows.Forms/System/Windows/Forms/...` +- `Control.OnVisualStylesModeChanged` / `VisualStylesModeChangeImpact.Metrics` handling — `Control.cs`, same SHA (~lines 7060–7110). +- Upstream draft PR: dotnet/winforms#14768 (`Net11/Integration-3`). 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/Feature-Prompts/Net11/WinForms-Net11-VisualStyles-Consolidated-WorkOrder.md b/.github/Feature-Prompts/Net11/WinForms-Net11-VisualStyles-Consolidated-WorkOrder.md new file mode 100644 index 00000000000..8cf6dc6559f --- /dev/null +++ b/.github/Feature-Prompts/Net11/WinForms-Net11-VisualStyles-Consolidated-WorkOrder.md @@ -0,0 +1,285 @@ +# Consolidated Work Order: WinForms .NET 11 — Remaining VisualStylesMode Items + +**Branch:** `Net11/Integration-3` (KlausLoeffelmann/winforms) + +**Already implemented on this branch — consume, do not build:** the VisualStylesMode change-impact +model (`VisualStylesModeChangeImpact`, `protected virtual GetVisualStylesModeChangeImpact(old, new)`, +base `OnVisualStylesModeChanged` dispatcher with effective-equality early-out and coalesced +`LayoutTransaction` cascade, `protected EffectiveVisualStylesMode`), the `SystemVisualSettings` +surface (snapshot, `SystemVisualSettingsCategories`, unified `Application` event, leak-free +`Control.OnSystemVisualSettingsChanged` cascade), and the `HighPrecisionTimer` rework. Read their +current source before starting — do not re-derive their shapes from this document. + +## Commit discipline + +One coherent commit (or small series) per phase, in phase order. Bug fixes and docs/chores get +their own commits. No cross-phase mixing, no squashing phases together. + +## Global constraints (every phase) + +- **No new settable style/theming properties.** If implementation pressure suggests one + (`CaptionFont`, `CornerRadius`, `HeaderColor`, …), stop and flag. All colors derive from mode + palette, dark mode, system accent, High Contrast. +- No shadows/blur/elevation (per-paint GDI+ cost). +- High Contrast needs no per-feature handling: the `EffectiveVisualStylesMode` clamp resolves HC to + `Classic` everywhere. +- Shared renderer constants (border color family, radii, stroke widths) live in **one** internal + location — duplication is a review blocker. +- Follow repo conventions, analyzers, and the branch's XML-doc voice. + +--- + +# Phase 1 — GroupBox Net11 FlatStyles + +`FlatStyle` becomes the mode-gated renderer selector (precedent: the Net11 Button treatment). + +| FlatStyle | Classic / HC / Disabled | Net11+ | +|---|---|---| +| `Standard` | classic etched frame | **Card**: caption above frame; rounded filled card, subtly elevated surface (Win11 settings-card archetype) | +| `Flat` | classic flat | **Outline**: rounded stroke, transparent body, caption inline on the border line | +| `Popup` | classic popup | **Header band**: filled accent-tinted title bar, body below, firm border (vendor "TitledPanel" archetype for POS migration) | +| `System` | native `BS_GROUPBOX` | native — classic regardless of mode; document explicitly | + +### 1.1 Derived caption font — keystone, no API +Caption rendered with a paint-time-derived font: ambient `Font` × ~1.15, `SemiBold` (named internal +constant; tune against the Win11 reference). `Font` is **never modified** — children inherit +nothing; the ambient-font problem is structurally avoided. Multiplicative derivation ⇒ system +text-scale changes move the caption proportionally; wire the `TextScale` category via +`OnSystemVisualSettingsChanged` ⇒ `Metrics` impact. Cache the derived font (recreate on +`Font`/text-scale/DPI change); no per-paint allocation. Migration doc note: manually-bolded +GroupBox fonts double up under Net11 — guidance, not code (see phase 4). + +### 1.2 `DisplayRectangle` — explicit compat decision +Card style's caption-above-frame moves the content origin down and shrinks the inner area vs +classic. Accepted: metric changes are what opt-in means, and correct AutoSize layouts re-measure +via the impact model. Compute `DisplayRectangle` per style/mode from actual renderer metrics — no +constants duplicated between paint and layout code. Record in the high-risk review doc: this is the +biggest compat lever in the change. + +### 1.3 Impact model integration +Override `GetVisualStylesModeChangeImpact`: `Classic`/`Disabled` ↔ `>= Net11` ⇒ `Metrics`; within +`>= Net11` decide from actual renderer differences and document in the override's remarks. Runtime +`FlatStyle` switches under Net11 with differing metrics must route layout equivalent to `Metrics` — +verify the `FlatStyle` setter path requests layout, not just repaint. + +### 1.4 Rendering + A11y +`RightToLeft` respected in all three renderers. Popup band: accent-derived fill (never settable), +luminance-based readable foreground, reacts to the `AccentColor` category (`Repaint`). Disabled +state desaturated per mode conventions. Set `UIA_HeadingLevelPropertyId` (fixed level, e.g. +`HeadingLevel2` — **no nesting-depth heuristic**) under Net11 styles; decide/document/test HC +behavior (recommendation: retain heading semantics; verify no double-announcement with the +grouping role). + +### 1.5 Tests +`DisplayRectangle` goldens per (`FlatStyle` × mode × DPI × text scale); live text-scale change ⇒ +caption grows, `Metrics` fires, hosting AutoSize `TableLayoutPanel` row re-measures without control +recreation; `Font` never mutated (assert pre/post paint and post text-scale); runtime FlatStyle +switch ⇒ layout, not just repaint; UIA heading assertions; dark/accent snapshots; `System` renders +identically pre/post mode switch. + +--- + +# Phase 2 — ComboBox Net11 FlatStyles + +**Architectural starting point:** `FlatStyle.Flat`/`Popup` have routed through the WinForms-internal +`FlatComboAdapter` since 1.x — field, border, drop-button fully WinForms-painted, native theming +bypassed. The Net11 renderer is a **new adapter next to `FlatComboAdapter`**; extend the existing +FlatStyle-based adapter selection to be mode-gated. + +| FlatStyle | Classic / HC / Disabled | Net11+ | +|---|---|---| +| `Standard` | native themed | **Rounded field** matching `TextBoxBase` Net11 (newly routes `Standard` through the adapter under Net11 — record in high-risk doc) | +| `Flat` | classic flat adapter | **Underline**: borderless field, bottom accent underline on focus | +| `Popup` | flat-until-hover | borderless at rest, rounded border on hover/focus | +| `System` | native | native — classic regardless of mode; document | + +### 2.1 Field metrics — TextBoxBase parity is a requirement +Share border constants with the `TextBoxBase` Net11 renderer (extract to a common internal location +if not already shared). **Height parity:** single-line Net11 `TextBox` and Net11 `ComboBox` (all +`DropDownStyle`s except `Simple`) land at the same height for the same `Font`/DPI — golden test. +If `ItemHeight`-driven sizing conflicts with parity padding, **resolve in favor of parity** and +document. Drop-button: scalable drawn chevron per DPI (no bitmap, no Marlett); hover/pressed states +from the mode palette. `RightToLeft`: mirrored chevron and text alignment. + +### 2.2 Dropdown list (popup HWND) +Rounded corners via `DWMWA_WINDOW_CORNER_PREFERENCE` (`DWMWCP_ROUNDSMALL`) on the list HWND +(`GetComboBoxInfo` → `hwndList`); apply on dropdown creation; gate on Win11 build 22000+, silent +no-op below. Interior colors: verify the existing dark-mode item-draw path keys off the Net11 +palette — no second mechanism. `Simple`: embedded child, corner preference N/A, adapter draws the +composite border. **Verify the full 9-cell `DropDownStyle` × modern-`FlatStyle` matrix** — +`Simple` is where combo adapters historically rot. + +### 2.3 EDIT child (`DropDownStyle.DropDown`) +Existing dark-mode EDIT-child handling composes with Net11 field padding so text baseline and left +inset match `DropDownList` (adapter-painted) and `TextBoxBase` — baseline-alignment golden across +the three. No NC treatment on the child; the adapter owns the frame. + +### 2.4 Impact model integration +`Classic`/`Disabled` ↔ `>= Net11` ⇒ `Metrics` if the parity work changes preferred size across the +boundary (expected yes — verify, document). Runtime FlatStyle switches with metric differences +route layout. `AccentColor` category ⇒ `Repaint` (focus underline, chevron accent states). + +### 2.5 Tests +Adapter-selection matrix (`FlatStyle` × `EffectiveVisualStylesMode`); `System` provably untouched +pre/post mode switch; height/baseline goldens per `DropDownStyle` × DPI × text scale; 9-cell +snapshots light + dark; corner preference Win11+/no-op-below; live mode switch in an AutoSize TLP +row re-measures without recreation; RTL snapshot for `Standard`. + +--- + +# Phase 3 — Bug fixes + +### 3.1 Issue #14754 — `FlatStyle.System` + `Appearance.ToggleSwitch` + Net11 throws +An existing commit on this branch fixes the exception — locate it, include it, reference the issue +in the commit message. Beyond the crash, **define the behavior**: `System` (native rendering) +cannot draw a toggle switch. Decision (default unless the existing commit already decided +otherwise): **`Appearance` wins** — `ToggleSwitch` requires owner rendering by definition, so the +control renders via the WinForms toggle renderer as if `Standard`; document in both properties' +XML remarks. (Rejected alternative: `System` wins and silently renders a classic checkbox — makes +a set property a silent no-op, worse than the exception for debuggability.) Add a matrix smoke +test: **no** `FlatStyle × Appearance × VisualStylesMode` combination may throw. + +### 3.2 `VisualStylesMode.Inherit` serialization +Bug: the CodeDom serializer reads the public getter, which resolves the ambient value — `Inherit` +is never observable there, so a designer reset momentarily shows `Inherit` and then serializes the +*resolved* value. Fix with the classic ambient pattern operating on the **raw stored** value, never +the resolved getter: + +- `private bool ShouldSerializeVisualStylesMode()` ⇒ raw value set and ≠ `Inherit`. +- `private void ResetVisualStylesMode()` ⇒ raw value := `Inherit` (single assignment; no transient + resolve-and-store anywhere in the reset path). +- Ensure no code path writes a resolved value back into raw storage during reset or designer + refresh. +- Tests: CodeDom serialization emits nothing for `Inherit`; emits the explicit value otherwise; + reset in a designer-host test returns the property to ambient with no serialized line; designer + round-trip preserves `Inherit`-ness. + +--- + +# Phase 4 — Documentation + +### 4.1 Placement architecture +- **XML remarks carry the contract; one conceptual doc carries the guidance.** Create + `docs/net11-visualstyles-layout-guidance.md` (name/location per repo docs conventions); every + affected `FlatStyle`/`VisualStylesMode`/`Appearance` remark links to it. Do **not** duplicate the + guidance essay across the affected controls' properties. +- **Mechanism for long docs:** **C# 13 partial properties** — defining declarations carrying the + extensive XML docs in dedicated `*.Docs.cs` partial files (e.g. + `Control.VisualStylesMode.Docs.cs`), implementations in the main files. Class-level docs likewise + on a dedicated partial declaration. Docs on the defining declaration only. + +### 4.2 Contract content for the XML remarks (per affected control, kept tight) +- Certain `VisualStylesMode` settings **will** change adornment real-estate requirements without + compromising the client area — **the control gets bigger** when the switch flips. Therefore + `TableLayoutPanel`/`FlowLayoutPanel` are the recommended containers for controls in these modes. + This is deliberate: A11Y-driven sizing (an 8×8 check glyph is not reasonable in the 4K/200% era), + and size reaction to system text-scale changes is unavoidable anyway — the same containers + handle both. +- **Exception — fixed-bounds controls:** `RichTextBox` and multiline `TextBox` have no intrinsic + `IntegralHeight`/AutoSize behavior. Switching to a mode needing more adornment real estate does + **not** resize them; bounds stay, which means the usable client area shrinks slightly at + constant bounds. They must be programmatically aligned based on context — **and that is + intended**. Controls with intrinsic AutoSize (`Button`, single-line `TextBox`, `Label`) or with + `AutoSize` set re-layout and trigger re-layout automatically. +- Where repo docs conventions allow forward references: note that Copilot/agent tooling assists + the migration, and reference the (planned) agent skills for HighDPI layouting, A11Y, + text-scale-adaptive layouts, and pixel-perfect→fluent refactoring. + +### 4.3 Conceptual doc content (the guidance essay) +- **Anchor vs. Dock in cascading layouts** — the teachable rule: **anchor when consuming/aligning + size** (stretch within a cell whose size others determine), **dock when pushing size** (the + child's content drives the parent). Worked examples inside `TableLayoutPanel` and in + constituent-layout situations. +- **The equal-size OK/Cancel pattern** as the flagship example — guaranteed today with the right + `TableLayoutPanel` approach: AutoSize/GrowAndShrink panel, two 50% columns, AutoSize buttons + anchored `Left | Right` — both buttons always get the width of the wider preferred size. Include + the sample below (**corrected from the draft**: `_btnCancel.DialogResult` was `DialogResult.OK`, + fixed to `Cancel`; `CellBorderStyle = Inset` removed as a cell-visualization leftover — neither + belongs in a canonical sample that agents will replicate at scale): + +```csharp +_btnOK = new Button(); +_btnCancel = new Button(); +_tlpDialogResultButtons = new TableLayoutPanel(); +_tlpDialogResultButtons.SuspendLayout(); +SuspendLayout(); +// +// _btnOK +// +_btnOK.Anchor = AnchorStyles.Left | AnchorStyles.Right; +_btnOK.AutoSize = true; +_btnOK.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_btnOK.DialogResult = DialogResult.OK; +_btnOK.Name = "_btnOK"; +_btnOK.Padding = new Padding(14, 0, 14, 0); +_btnOK.TabIndex = 0; +_btnOK.Text = "OK"; +_btnOK.UseVisualStyleBackColor = true; +// +// _btnCancel +// +_btnCancel.Anchor = AnchorStyles.Left | AnchorStyles.Right; +_btnCancel.AutoSize = true; +_btnCancel.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_btnCancel.DialogResult = DialogResult.Cancel; +_btnCancel.Name = "_btnCancel"; +_btnCancel.Padding = new Padding(14, 0, 14, 0); +_btnCancel.TabIndex = 1; +_btnCancel.Text = "Cancel"; +_btnCancel.UseVisualStyleBackColor = true; +// +// _tlpDialogResultButtons +// +_tlpDialogResultButtons.AutoSize = true; +_tlpDialogResultButtons.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_tlpDialogResultButtons.ColumnCount = 2; +_tlpDialogResultButtons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); +_tlpDialogResultButtons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); +_tlpDialogResultButtons.Controls.Add(_btnCancel, 1, 0); +_tlpDialogResultButtons.Controls.Add(_btnOK, 0, 0); +_tlpDialogResultButtons.Name = "_tlpDialogResultButtons"; +_tlpDialogResultButtons.RowCount = 1; +_tlpDialogResultButtons.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); +_tlpDialogResultButtons.TabIndex = 3; +``` + +- Treat the OK/Cancel sample as **exemplary, not exhaustive**: identify comparable + customer-information-deficit spots (mixed TextBox/ComboBox row alignment, GroupBox card + `DisplayRectangle` implications, text-scale-driven growth in TLP AutoSize rows) and add + equivalent worked samples where they plausibly prevent support traffic. + +--- + +# Phase 5 — GitHub chores + +Run each item only when the corresponding implementation stands. Ask the user for concrete issue +numbers/URLs — do not guess. Amendments are additions clearly marked as such; match each issue's +formatting. Expected-Questions blocks are decisions with rationale — include **as written**, do not +soften into open questions. + +### 5.1 GroupBox + ComboBox issue notes +Add sections (or a shared note, per existing issue structure) documenting the mode-gated FlatStyle +renderings as behavioral changes under opt-in mode (not new API): the taxonomy tables, the +`DisplayRectangle` decision (GroupBox), the `Standard`-through-adapter note (ComboBox), the +`System`-stays-native statements, and the #14754 behavior decision (`Appearance` wins over +`FlatStyle.System` for `ToggleSwitch`; never throw). + +EQ block: +- *Isn't this theming?* No new settable surface exists; all three looks are framework-defined + renderings of an existing enum under an opt-in mode; colors/metrics derive from system state + (accent, dark mode, HC, text scale). Theming remains the business of control vendor partners. +- *Why does `System` not modernize?* It means native rendering by contract; native + `BS_GROUPBOX`/native combo cannot be restyled. Documented, not overlooked. +- *Why does the GroupBox card change `DisplayRectangle`?* Metric changes are what `VisualStylesMode` + opt-in means; correct AutoSize layouts re-measure through the change-impact model; recorded as + the change's biggest compat lever in the high-risk doc. + +### 5.2 "What's new in WinForms .NET 11" issue +Create an issue whose **description is the markdown itself** — the single source later lifted into +blog post, docs, and readme. Sections: `VisualStylesMode` / `EffectiveVisualStylesMode` / +`SetDefaultVisualStylesMode` (opt-in story, HC guarantee); the change-impact model for control +authors; `SystemVisualSettings` (snapshot, unified event, leak-free control path, vendor-plumbing +pitch); modernized control renderings per FlatStyle (Button, TextBoxBase, GroupBox incl. UIA +heading levels, ComboBox incl. rounded dropdown); layout guidance pointer (phase 4 conceptual doc); +notable fixes (#14754, `Inherit` serialization). Tone: factual, links to issues/PRs per entry; no +marketing prose — the blog post adds that layer later. 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 diff --git a/.github/copilot/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md b/.github/copilot/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md new file mode 100644 index 00000000000..09894f9d23f --- /dev/null +++ b/.github/copilot/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/skills/building-code/SKILL.md b/.github/skills/building-code/SKILL.md index 040dd437081..67fd8c7ba30 100644 --- a/.github/skills/building-code/SKILL.md +++ b/.github/skills/building-code/SKILL.md @@ -11,6 +11,22 @@ metadata: # Building the WinForms Repository +> ## 🛑 TENET — Build the solution ONLY with `build.cmd` +> +> **Never** build, validate, or declare the WinForms solution "clean" with a plain +> `dotnet build` / `dotnet msbuild` of `Winforms.sln`. Only **`build.cmd`** (Arcade) applies the +> repository's CI configuration — the **PublicAPI analyzer (RS0016/RS0017)**, the code-style and +> documentation analyzers, and **`-warnAsError`**. A plain `dotnet build` silently downgrades or +> skips these, so **"0 warnings" there does NOT mean CI is green** — the very same change can fail +> the official build with errors. +> +> * **Full / release / package / "is it clean?" verification → always `build.cmd`** (see §2). +> * A single-project `dotnet build` (see §3) is an **inner-loop convenience only**. It is fine while +> iterating, but you **must re-verify with `build.cmd` before claiming a change builds cleanly**. +> * If `build.cmd` cannot run in your environment, the closest fallback is +> `dotnet build /p:ContinuousIntegrationBuild=true /p:TreatWarningsAsErrors=true` — and +> you must say so explicitly rather than implying a `build.cmd` result. + ## Prerequisites * Windows is required for WinForms runtime scenarios, test execution, and Visual @@ -45,6 +61,12 @@ You can pass any extra `Build.ps1` flags after `Restore.cmd`, e.g. ## 2 Full Solution Build (preferred) +> **Always use `build.cmd` (Arcade) for full, release, and package builds.** Do **not** use a plain +> `dotnet build` of the solution for these — only `build.cmd` guarantees the Arcade-supported build +> options and the download of the correct base SDK (`global.json`) needed to compile. Plain +> `dotnet build` is reserved for the fast single-project inner loop (see Section 3), and even then +> only after at least one successful `build.cmd` / `Restore.cmd`. + ``` .\build.cmd ``` @@ -57,6 +79,56 @@ Under the hood this runs: eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ``` +### 2.1 Full (clean) test build — required workflow + +For a full, clean build of the whole solution, **clean the artifacts first, then build**: + +```powershell +# 1. Clean the artifacts folder. +.\build -clean + +# 2. Build the full solution. +.\build +``` + +**Reporting requirement:** a full build is long-running, so while it runs **report progress back to +the user in the console to bridge the wait and give early orientation.** As assemblies complete, +report which assemblies have been **built successfully** and which **failed and with how many +errors**. Prefer running the build with a binary log (the default `-bl`) and/or stream the console +output so per-project results can be surfaced as they happen rather than only at the end. + +### 2.2 Release build + +```powershell +.\build -configuration release +``` + +### 2.3 Creating packages + +```powershell +# Debug packages +.\build -pack + +# Release packages +.\build -configuration release -pack +``` + +### 2.4 Full `Build.ps1` parameter list + +`build.cmd` forwards every extra argument to `eng\common\Build.ps1`. The full surface is: + +``` +Build.ps1 [-configuration ] [-platform ] [-projects ] + [-verbosity ] [-msbuildEngine ] [-warnAsError ] + [-warnNotAsError ] [-nodeReuse ] [-buildCheck] [-restore] + [-deployDeps] [-build] [-rebuild] [-deploy] [-test] [-integrationTest] + [-performanceTest] [-sign] [-pack] [-publish] [-clean] [-productBuild] + [-fromVMR] [-binaryLog] [-binaryLogName ] [-excludeCIBinarylog] + [-ci] [-prepareMachine] [-runtimeSourceFeed ] + [-runtimeSourceFeedKey ] [-excludePrereleaseVS] + [-nativeToolsOnMachine] [-help] [-properties ] [] +``` + ### Common flags | Flag | Short | Description | @@ -90,6 +162,12 @@ eng\common\Build.ps1 -NativeToolsOnMachine -restore -build -bl ## 3 Optimized Building a Single Project (fast inner-loop) +> **Inner-loop only.** Use plain `dotnet build` of a single project **only** for quick iteration on +> one project, and **only after** at least one successful `.\build.cmd` / `.\Restore.cmd`. It does +> **not** guarantee the Arcade-supported build options or the download of the correct base SDK, so it +> must **never** be used for a full solution build, a release build, packaging, or any build whose +> result you intend to report as authoritative. For those, always use `build.cmd` (Section 2). + Prefer rebuilding just the project(s) with recent changes by using the standard `dotnet build` command, **after** at least one initial successful full restore (via `.\Restore.cmd` or `.\build.cmd`). diff --git a/.github/skills/control-api-tests/SKILL.md b/.github/skills/control-api-tests/SKILL.md index 5badcc2547c..ff4e9304230 100644 --- a/.github/skills/control-api-tests/SKILL.md +++ b/.github/skills/control-api-tests/SKILL.md @@ -66,6 +66,28 @@ The project uses **xUnit** with **FluentAssertions**. Key attributes: These are custom xUnit attributes that ensure tests run on an STA thread, which WinForms requires for COM interop and UI operations. +### 1.4 Async tests: pass a CancellationToken, respect `#nullable` + +The repository runs **xUnit v3** and enforces the relevant analyzers as **errors** under the CI +build (`build.cmd`). Two pitfalls fail CI even though a plain `dotnet build` may not flag them: + +* **CA2016 / xUnit1051 — always pass a `CancellationToken` to async calls.** Methods such as + `Task.Delay` must receive a token so a cancelled test run stops promptly. In xUnit v3 use + `TestContext.Current.CancellationToken`: + + ```csharp + await Task.Delay(25, TestContext.Current.CancellationToken); + ``` + + When you receive a `CancellationToken ct` (e.g. in a callback), **forward it** rather than dropping it. + +* **CS8632 — nullable annotations need a `#nullable` context.** If a test file uses `?` reference + annotations (e.g. `object? sender`) but the project does not enable nullable, add `#nullable enable` + at the top of the file (or remove the annotation). Match the surrounding files' convention. + +> Verify with `build.cmd` (CI parity) — see the `building-code` skill's build tenet. A plain +> single-project `dotnet build` can report these as 0 warnings while CI fails them as errors. + --- ## 2. Test Method Naming diff --git a/.github/skills/new-control-api/SKILL.md b/.github/skills/new-control-api/SKILL.md index ce6c58b6d2e..3030cf92ef7 100644 --- a/.github/skills/new-control-api/SKILL.md +++ b/.github/skills/new-control-api/SKILL.md @@ -148,7 +148,34 @@ System.Windows.Forms.MyEnum.Value2 = 1 -> System.Windows.Forms.MyEnum **Nullable annotations:** `?` = nullable reference, `!` = non-nullable reference. Value types do not carry these markers unless `Nullable`. -### 2.4 Publicly accessible interfaces +### 2.4 New `override` members must be tracked too + +The PublicAPI analyzer (RS0016) treats a **newly introduced `override`** of a public or +protected member as new API surface — even though the base member is already public. Whenever +you **add an `override` that did not previously exist on that type**, add a line for it to +`PublicAPI.Unshipped.txt` with the `override` prefix. This is easy to miss for paint/lifecycle +overrides added to support a feature. Examples: + +```text +override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void +override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +``` + +> **CI catches this, a plain `dotnet build` may not.** RS0016 is enforced as an **error** under +> the CI/Arcade build (`build.cmd`); a single-project `dotnet build` can report it as 0 warnings. +> Always re-verify API tracking with `build.cmd` (see the `building-code` skill's build tenet). + +### 2.5 Related pitfalls when adding members to a control + +* **Hiding an inherited member (CS0114):** if your new member intentionally hides an inherited + one (e.g. a `private new bool ShouldSerializePadding()` shadowing `Control.ShouldSerializePadding()`), + you **must** use the `new` keyword, or the CI build fails. +* **`cref` to internal types in another assembly (CS1574):** XML-doc `` cannot + resolve a type that is `internal` in a *different* assembly (even via `InternalsVisibleTo`). Use + `TypeName` (plain code font) instead of a `cref` for such references. + +### 2.6 Publicly accessible interfaces If a new **public or protected interface** is introduced (or an existing one gains new members), every member that is publicly accessible must also appear @@ -468,51 +495,73 @@ protected virtual void OnMyPropertyChanged(EventArgs e) --- -## 7. .NET Version Guard — Mandatory +## 7. API Stability: Experimental vs. Stable — and Version Guards -All new public APIs **must** be guarded with a preprocessor directive for the -target .NET version. Currently, new APIs target at least **.NET 11**: +### 7.1 New APIs are STABLE by default — do NOT mark them `[Experimental]` -```csharp -#if NET11_0_OR_GREATER - /// - /// Gets or sets the corner radius for the control's border. - /// - public int CornerRadius - { - get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); - set - { - ArgumentOutOfRangeException.ThrowIfNegative(value); +New public APIs ship as **normal, stable APIs by default**. Do **not** add the +`[Experimental(...)]` attribute, a `WFO5xxx` diagnostic ID, or `[WFO5xxx]` +PublicAPI prefixes unless the work item **explicitly** asks for an experimental +API. - if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) - { - Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); - OnCornerRadiusChanged(EventArgs.Empty); - } - } - } -#endif -``` +> **Never make an API experimental implicitly.** Experimental status is a +> deliberate, requested decision (it changes the customer contract and requires a +> diagnostic ID + suppression to consume). If the context does not explicitly call +> for it, the API is stable. + +### 7.2 When an experimental API *is* explicitly requested + +Only when the task explicitly requests an experimental API: + +1. Add (or reuse) a diagnostic ID in the `WFO500x` group in + `src\System.Windows.Forms.Analyzers\src\System\Windows\Forms\Analyzers\Diagnostics\DiagnosticIDs.cs` + (e.g. `ExperimentalDarkMode = "WFO5001"`, `ExperimentalAsync = "WFO5002"`, + `ExperimentalAsyncDropTarget = "WFO5003"`). New IDs continue the sequence. +2. Decorate the API: + ```csharp + [Experimental(DiagnosticIDs.ExperimentalXxx, UrlFormat = DiagnosticIDs.UrlFormat)] + ``` +3. Prefix every PublicAPI entry for that API with the diagnostic ID, e.g. + `[WFO5001]System.Windows.Forms.SomeNewApi.get -> ...`. +4. Add a row to **both** `docs\analyzers\Experimental.Help.md` and + `docs\list-of-diagnostics.md`. +5. Suppress the diagnostic where the framework itself consumes the API + (`#pragma warning disable WFOxxxx` / `#Disable Warning WFOxxxx` in VB). -> **Why?** Version guards ensure new APIs are only available on the .NET version -> they were approved for, preventing accidental use on older runtimes. The guard -> applies to the entire API surface: property, event, `On` method, and any -> associated types. +When the API later **graduates to stable** (typically the next release), reverse +all five steps: remove the attribute, the `[WFOxxxx]` PublicAPI prefixes, the +suppressions, the docs rows, and the unused diagnostic ID. -The matching tests must use the **same** preprocessor guard: +### 7.3 Version guards + +This repository **single-targets the current in-development .NET** (see +`TargetFramework` / `NetCurrent`), so source is **not** wrapped in +`#if NETxx_0_OR_GREATER` guards — there are none in `System.Windows.Forms`. Do +**not** add `#if NET11_0_OR_GREATER` blocks around new APIs. Add the member +directly: ```csharp -#if NET11_0_OR_GREATER - [WinFormsFact] - public void MyControl_CornerRadius_Set_GetReturnsExpected() +/// +/// Gets or sets the corner radius for the control's border. +/// +public int CornerRadius +{ + get => Properties.GetValueOrDefault(s_cornerRadiusProperty, 0); + set { - using MyControl control = new() { CornerRadius = 5 }; - Assert.Equal(5, control.CornerRadius); + ArgumentOutOfRangeException.ThrowIfNegative(value); + + if (Properties.GetValueOrDefault(s_cornerRadiusProperty, 0) != value) + { + Properties.AddOrRemoveValue(s_cornerRadiusProperty, value, defaultValue: 0); + OnCornerRadiusChanged(EventArgs.Empty); + } } -#endif +} ``` +Tests do not need a version guard either. + --- ## 8. Checklist Before Submitting @@ -521,7 +570,8 @@ Before considering the implementation complete, verify: * [ ] API proposal issue exists (upstream or fork) with full proposal format * [ ] All new public/protected members are in `PublicAPI.Unshipped.txt` -* [ ] New APIs guarded with `#if NET11_0_OR_GREATER` (or appropriate version) +* [ ] API is **stable** (no `[Experimental]`/`WFO5xxx`) unless experimental was + explicitly requested; no `#if NETxx_0_OR_GREATER` guards * [ ] Property values stored via `PropertyStore` (not backing fields) * [ ] Every property has a CodeDOM serialization strategy * [ ] Every property has `On[Property]Changed` + `[Property]Changed` event @@ -533,7 +583,7 @@ Before considering the implementation complete, verify: * [ ] XML documentation on every new public/protected member * [ ] Naming follows precedent on the control and its base classes * [ ] Publicly accessible interface members are tracked in PublicAPI files -* [ ] Unit tests cover the new API surface (with matching version guard) +* [ ] Unit tests cover the new API surface ### 8.1 API issue checklist diff --git a/Winforms.sln b/Winforms.sln index fef423d18a4..bad29dd5f24 100644 --- a/Winforms.sln +++ b/Winforms.sln @@ -218,6 +218,13 @@ Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "Microsoft.Private.Windows.P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Private.Windows.Core", "src\System.Private.Windows.Core\src\Microsoft.Private.Windows.Core.csproj", "{36A02BBB-B60B-5F23-6AF0-F41561A9275C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{4D53E144-73EF-49BC-BF11-F416E187944A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "net11-VisualStylesMode", "net11-VisualStylesMode", "{59D2720E-DBA9-4282-869E-0717D6311BDF}" + ProjectSection(SolutionItems) = preProject + .github\copilot\Application\net11-VisualStylesMode\TextBoxBase-VisualStyles-WorkOrder.md = .github\copilot\Application\net11-VisualStylesMode\TextBoxBase-VisualStyles-WorkOrder.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1188,6 +1195,8 @@ Global {4A2BD741-482C-4BF7-8A2D-5535A770DB69} = {583F1292-AE8D-4511-B8D8-A81FE4642DDC} {799CC0C2-236B-4A76-8CE3-65C346182CC1} = {77FEDB47-F7F6-490D-AF7C-ABB4A9E0B9D7} {36A02BBB-B60B-5F23-6AF0-F41561A9275C} = {77FEDB47-F7F6-490D-AF7C-ABB4A9E0B9D7} + {4D53E144-73EF-49BC-BF11-F416E187944A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {59D2720E-DBA9-4282-869E-0717D6311BDF} = {4D53E144-73EF-49BC-BF11-F416E187944A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7B1B0433-F612-4E5A-BE7E-FCF5B9F6E136} diff --git a/docs/Net11Api_05_VisualStylesMode.HighRiskReview.md b/docs/Net11Api_05_VisualStylesMode.HighRiskReview.md new file mode 100644 index 00000000000..9556497cad9 --- /dev/null +++ b/docs/Net11Api_05_VisualStylesMode.HighRiskReview.md @@ -0,0 +1,69 @@ +# Net11Api_05 VisualStylesMode High-Risk Review + +This document records animation infrastructure findings that require architecture and lifecycle decisions before they can be patched safely. + +## VisualStylesMode transition impact dispatch + +`Control.OnVisualStylesModeChanged` now owns impact processing for effective renderer changes. Overrides that +call `base` inherit preferred-size cache clearing, style/non-client frame refresh, invalidation, and deferred +layout for metric-affecting changes. The transition carries immutable old/new effective modes through the +existing `EventArgs` virtual shape; nested property changes cannot overwrite an outer transition, and stale +child cascades are suppressed. + +Metric layout requests are collected while the complete affected subtree transitions, then coalesced to one +layout per container. This is required so a container measures only fully updated children and so +`AutoSize = false` text boxes still trigger parent remeasurement. `TextBoxBase` treats only crossings between +classic/disabled and Net11-or-later rendering as metric changes; Net11-to-Latest shares its preferred-height +and non-client padding metrics and repaints only. + +`SystemVisualSettings` High Contrast transitions enter the same dispatcher with immutable old/new snapshots. +Every affected control compares its own effective mode, so locally configured descendants are included, while +effective-equality controls bypass visual-styles events, invalidation, and layout. The shared transition state +preserves existing reentrant-change suppression and layout coalescing. + +## System visual settings renderer composition + +Animated renderers subscribe only to their owning control's instance settings cascade. Accent transitions clear +their cached accent color; the control cascade repaints the tree. Disabling client-area animation completes an +active transition at progress 1 and unregisters it from the per-thread manager. Re-enabling does not restart a +completed transition; a later state change starts a new one. + +`TextBoxBase` derives modern focus-border thickness and focus-band height from the system focus-border metrics, +text-scale factor, and device DPI. These metrics affect only modern chrome. The existing never-invert client +carve, small-control flat fallback, and classic-mode metrics remain unchanged. + +## GroupBox content geometry + +The Net11 `FlatStyle.Standard` GroupBox renders its caption above a borderless rectangular surface. Its +`DisplayRectangle` therefore starts lower and is shorter than the classic etched-frame rectangle. This is the +largest compatibility lever in the GroupBox change: the mode is opt-in, the paint and layout paths share the +same metrics, and the VisualStylesMode impact dispatcher remeasures AutoSize containers when the effective +renderer crosses the classic/modern boundary. The caption aligns to the control Padding, and the smaller bottom +content inset preserves separation when GroupBoxes are stacked. `FlatStyle.Flat` uses a rounded Windows-accent +outline; `FlatStyle.Popup` uses a Windows-accent header. `FlatStyle.System` remains native and does not use these +metrics. + +## ComboBox adapter routing + +Net11 routes `FlatStyle.Standard` ComboBox controls through the WinForms adapter path for the first time so the +field can share TextBoxBase's rounded chrome metrics and color scheme. `Flat` uses the same scheme with a +square-corner border; `Popup` keeps the rounded Standard geometry but uses the Windows accent color for its +border. All three styles add one logical pixel of framework inset on top of the now-designer-visible public +Padding. The adapter changes preferred height across the classic/modern boundary; `FlatStyle.System` remains +native and bypasses the adapter. + +## Animation timing and thread ownership + +The timer now uses an absolute `Stopwatch` schedule with a high-resolution waitable timer on supported Windows +versions and a coarse 30 Hz fallback. It no longer changes process-wide timer resolution, and residual spinning is +bounded to the sub-millisecond remainder. Registration/start/stop transitions are generation-owned, callback +dispatch is allocation-free in steady state, and stale generations cannot dispatch after replacement. + +`AnimationManager` is now per UI thread. Each manager captures that thread's synchronization context, derives +animation progress from the timer tick timeline, and is disposed when its message loop exits. A renderer fault +quarantines that renderer without stopping unrelated animations on the same thread. + +The remaining power risk is idle registration lifetime: after a UI thread starts its first animation, its manager +keeps one timer registration until `Application.ThreadExit`, even when no renderer is currently running. The pacer +therefore continues waking and dispatching an empty frame callback. A future optimization can make manager +registration lazy while preserving the generation and thread-ownership invariants established by the rework. diff --git a/docs/net11-visualstyles-layout-guidance.md b/docs/net11-visualstyles-layout-guidance.md new file mode 100644 index 00000000000..9088495a78f --- /dev/null +++ b/docs/net11-visualstyles-layout-guidance.md @@ -0,0 +1,257 @@ +# .NET 11 VisualStyles layout guidance + +`VisualStylesMode.Net11` modernizes framework-defined control chrome without adding a theming API. Colors and +metrics come from Windows state, including dark mode, accent color, High Contrast, DPI, focus metrics, and text +scale. High Contrast always selects classic, backward-compatible painting through +`EffectiveVisualStylesMode`. + +Modern chrome can require more adornment space than classic chrome. When a naturally sized control opts in, the +control grows so its client area remains usable. This is intentional: glyphs and focus indicators designed for +96 DPI are not sufficient at modern display densities, and controls must also react to system text-scale changes. + +## Choose an adaptive container + +Use `TableLayoutPanel` or `FlowLayoutPanel` for controls whose preferred size can change. These containers already +handle localization, font changes, DPI changes, and AutoSize content, so they also handle modern visual-style +metrics. + +Controls with intrinsic sizing, such as `Button`, single-line `TextBox`, `ComboBox`, and `Label`, update their +preferred size when their effective renderer changes. Controls with `AutoSize` enabled request layout +automatically. + +### Fixed-bounds exceptions + +`RichTextBox` and multiline `TextBox` do not have intrinsic single-line or `IntegralHeight` behavior. Switching +their visual style does not resize their bounds. If the modern non-client frame needs more room, their client area +shrinks slightly within the existing bounds. + +That behavior is deliberate. Size and align fixed-bounds editors programmatically for the surrounding workflow +instead of relying on a global pixel-perfect height. + +## Anchor when consuming size; Dock when pushing size + +Use `Anchor` when a child consumes or aligns to space determined by its container. Use `Dock` when the child's +content should push the size of its container. + +Inside an AutoSize `TableLayoutPanel`, anchor a control left and right when it should stretch to a column width +that the table computes: + +```csharp +TextBox customerName = new() +{ + Anchor = AnchorStyles.Left | AnchorStyles.Right, + AutoSize = true +}; + +tableLayoutPanel.Controls.Add(customerName, column: 1, row: 0); +``` + +Inside a constituent UserControl whose height should be driven by a single child, dock that child: + +```csharp +public sealed class SearchEditor : UserControl +{ + private readonly TextBox _queryTextBox = new() + { + Dock = DockStyle.Top, + AutoSize = true + }; + + public SearchEditor() + { + AutoSize = true; + AutoSizeMode = AutoSizeMode.GrowAndShrink; + Controls.Add(_queryTextBox); + } +} +``` + +Do not use `DockStyle.Fill` as a substitute for AutoSize. Fill consumes the bounds already assigned by the parent; +it does not tell an AutoSize parent what preferred height the child needs. + +## Equal-width OK and Cancel buttons + +An AutoSize table with two 50 percent columns guarantees equal button widths. Each button is AutoSize and anchored +left and right, so both columns take the width required by the wider preferred button. + +```csharp +_btnOK = new Button(); +_btnCancel = new Button(); +_tlpDialogResultButtons = new TableLayoutPanel(); +_tlpDialogResultButtons.SuspendLayout(); +SuspendLayout(); +// +// _btnOK +// +_btnOK.Anchor = AnchorStyles.Left | AnchorStyles.Right; +_btnOK.AutoSize = true; +_btnOK.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_btnOK.DialogResult = DialogResult.OK; +_btnOK.Name = "_btnOK"; +_btnOK.Padding = new Padding(14, 0, 14, 0); +_btnOK.TabIndex = 0; +_btnOK.Text = "OK"; +_btnOK.UseVisualStyleBackColor = true; +// +// _btnCancel +// +_btnCancel.Anchor = AnchorStyles.Left | AnchorStyles.Right; +_btnCancel.AutoSize = true; +_btnCancel.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_btnCancel.DialogResult = DialogResult.Cancel; +_btnCancel.Name = "_btnCancel"; +_btnCancel.Padding = new Padding(14, 0, 14, 0); +_btnCancel.TabIndex = 1; +_btnCancel.Text = "Cancel"; +_btnCancel.UseVisualStyleBackColor = true; +// +// _tlpDialogResultButtons +// +_tlpDialogResultButtons.AutoSize = true; +_tlpDialogResultButtons.AutoSizeMode = AutoSizeMode.GrowAndShrink; +_tlpDialogResultButtons.ColumnCount = 2; +_tlpDialogResultButtons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); +_tlpDialogResultButtons.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); +_tlpDialogResultButtons.Controls.Add(_btnCancel, 1, 0); +_tlpDialogResultButtons.Controls.Add(_btnOK, 0, 0); +_tlpDialogResultButtons.Name = "_tlpDialogResultButtons"; +_tlpDialogResultButtons.RowCount = 1; +_tlpDialogResultButtons.RowStyles.Add(new RowStyle(SizeType.Percent, 100F)); +_tlpDialogResultButtons.TabIndex = 3; +``` + +The pattern remains stable when localization, DPI, text scale, or a VisualStylesMode transition changes either +button's preferred width or height. + +## Align TextBox and ComboBox rows + +Modern non-`Simple` ComboBox fields share their height metrics with an AutoSize, single-line modern TextBox using +`BorderStyle.Fixed3D`. ComboBox adds one logical pixel of internal style inset on top of its public Padding; apply +the same Padding to the TextBox when exact field-height parity is required. Put both in an AutoSize row, anchor +them left and right, and avoid hard-coded heights: + +```csharp +TableLayoutPanel editorTable = new() +{ + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 2, + RowCount = 2 +}; +editorTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); +editorTable.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); +editorTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); +editorTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + +Label nameLabel = new() +{ + Anchor = AnchorStyles.Left, + AutoSize = true, + Text = "Name" +}; +TextBox nameEditor = new() +{ + Anchor = AnchorStyles.Left | AnchorStyles.Right, + AutoSize = true, + Padding = new Padding(1) +}; + +Label categoryLabel = new() +{ + Anchor = AnchorStyles.Left, + AutoSize = true, + Text = "Category" +}; +ComboBox categoryEditor = new() +{ + Anchor = AnchorStyles.Left | AnchorStyles.Right, + DropDownStyle = ComboBoxStyle.DropDownList +}; + +editorTable.Controls.Add(nameLabel, 0, 0); +editorTable.Controls.Add(nameEditor, 1, 0); +editorTable.Controls.Add(categoryLabel, 0, 1); +editorTable.Controls.Add(categoryEditor, 1, 1); +``` + +Do not derive a ComboBox height from `ItemHeight`. Item height controls list content; the modern selection field +uses TextBox-compatible padding and focus metrics. ComboBox Padding is designer-visible in .NET 11 and is +serialized only when it differs from `Padding.Empty`. + +## Account for GroupBox DisplayRectangle + +In modern `FlatStyle.Standard`, the GroupBox caption sits above a borderless rectangular surface. Its +`DisplayRectangle` starts lower than the classic etched-frame rectangle and reserves more content space above +than below. The caption aligns with the control Padding. Docked and anchored children are relaid out against the +new rectangle. + +Use the GroupBox as a real layout container: + +```csharp +GroupBox addressGroup = new() +{ + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Dock = DockStyle.Top, + FlatStyle = FlatStyle.Standard, + Text = "Address" +}; + +TableLayoutPanel addressTable = new() +{ + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Dock = DockStyle.Top +}; + +addressGroup.Controls.Add(addressTable); +``` + +Avoid positioning children with a fixed Y offset derived from `Font.Height`. The renderer owns caption metrics, +and `DisplayRectangle` is the supported content boundary. + +The modern caption is derived from the ambient font at paint time. Standard and Popup enlarge the caption; Flat +keeps the ambient size. If the ambient font is regular and a matching installed Semibold family exists, WinForms +uses that real face. Otherwise the ambient weight is preserved; already styled fonts are never promoted. + +## React to live text-scale changes + +Place controls in AutoSize rows and let the layout engine react to the system event: + +```csharp +TableLayoutPanel settingsTable = new() +{ + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 2 +}; +settingsTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); +settingsTable.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + +GroupBox optionsGroup = new() +{ + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Text = "Options" +}; +ComboBox optionSelector = new() +{ + Anchor = AnchorStyles.Left | AnchorStyles.Right, + DropDownStyle = ComboBoxStyle.DropDownList +}; + +settingsTable.Controls.Add(optionsGroup, 0, 0); +settingsTable.Controls.Add(optionSelector, 0, 1); +``` + +Do not recreate controls when text scale changes. WinForms clears preferred-size caches, updates native selection +field metrics where necessary, and requests layout while preserving handles and selection. + +## Migration tooling + +Automated migration tools can replace fixed pixel layouts with adaptive containers, identify fixed-bounds editor +exceptions, and review accessibility and text-scale behavior. Planned WinForms agent skills will cover High DPI +layout, accessibility, text-scale-adaptive layouts, and pixel-perfect-to-Fluent refactoring. Treat generated +changes as a starting point and verify each workflow at the application's supported DPI, language, and +accessibility settings. 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..1e561754432 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, + visualStylesMode As VisualStylesMode) Me.MinimumSplashScreenDisplayTime = minimumSplashScreenDisplayTime Me.HighDpiMode = highDpiMode Me.ColorMode = colorMode + Me.VisualStylesMode = visualStylesMode End Sub ''' @@ -32,6 +34,12 @@ Namespace Microsoft.VisualBasic.ApplicationServices ''' Public Property ColorMode As SystemColorMode + ''' + ''' Setting this property inside the event handler determines the + ''' for the application. + ''' + Public Property VisualStylesMode As VisualStylesMode + ''' ''' Setting this property inside the event handler causes a ''' new default for Forms and UserControls to be set. diff --git a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb index 6e14d471534..569b58e9e7d 100644 --- a/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb +++ b/src/Microsoft.VisualBasic.Forms/src/Microsoft/VisualBasic/ApplicationServices/WindowsFormsApplicationBase.vb @@ -3,7 +3,6 @@ Imports System.Collections.ObjectModel Imports System.ComponentModel -Imports System.Diagnostics.CodeAnalysis Imports System.IO.Pipes Imports System.Reflection Imports System.Runtime.CompilerServices @@ -11,7 +10,6 @@ Imports System.Runtime.InteropServices Imports System.Security Imports System.Threading Imports System.Windows.Forms -Imports System.Windows.Forms.Analyzers.Diagnostics Imports VbUtils = Microsoft.VisualBasic.CompilerServices.ExceptionUtils @@ -69,6 +67,9 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' Note: We aim to expose this to the App Designer in later runtime/VS versions. Private _colorMode As SystemColorMode = SystemColorMode.Classic + ' The VisualStylesMode (renderer version) the user assigned to the ApplyApplicationDefaults event. + Private _visualStylesMode As VisualStylesMode = VisualStylesMode.Classic + ' We only need to show the splash screen once. ' Protect the user from himself if they are overriding our app model. Private _didSplashScreen As Boolean @@ -200,6 +201,22 @@ Namespace Microsoft.VisualBasic.ApplicationServices End Set End Property + ''' + ''' Gets or sets the (renderer version) for the application. + ''' + ''' + ''' The that the application uses to render its controls. + ''' + + Protected Property VisualStylesMode As VisualStylesMode + Get + Return _visualStylesMode + End Get + Set(value As VisualStylesMode) + _visualStylesMode = value + End Set + End Property + ''' ''' Determines whether this application will use the XP Windows styles for windows, controls, etc. ''' @@ -746,10 +763,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices Dim applicationDefaultsEventArgs As New ApplyApplicationDefaultsEventArgs( MinimumSplashScreenDisplayTime, HighDpiMode, - ColorMode) With - { - .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime - } + ColorMode, + VisualStylesMode) RaiseEvent ApplyApplicationDefaults(Me, applicationDefaultsEventArgs) @@ -765,6 +780,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices _highDpiMode = applicationDefaultsEventArgs.HighDpiMode _colorMode = applicationDefaultsEventArgs.ColorMode + _visualStylesMode = applicationDefaultsEventArgs.VisualStylesMode ' Then, it's applying what we got back as HighDpiMode. Dim dpiSetResult As Boolean = Application.SetHighDpiMode(_highDpiMode) @@ -780,6 +796,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices Application.EnableVisualStyles() End If + Application.SetDefaultVisualStylesMode(_visualStylesMode) + Application.SetColorMode(_colorMode) ' We'll handle "/nosplash" for you. diff --git a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt index e69de29bb2d..31142494a9b 100644 --- a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt @@ -0,0 +1,4 @@ +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.VisualStylesMode() -> System.Windows.Forms.VisualStylesMode +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.VisualStylesMode(AutoPropertyValue As System.Windows.Forms.VisualStylesMode) -> Void +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.VisualStylesMode() -> System.Windows.Forms.VisualStylesMode +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.VisualStylesMode(value As System.Windows.Forms.VisualStylesMode) -> Void diff --git a/src/Microsoft.VisualBasic.Forms/tests/UnitTests/System/Windows/Forms/WindowsFormsApplicationBaseTests.vb b/src/Microsoft.VisualBasic.Forms/tests/UnitTests/System/Windows/Forms/WindowsFormsApplicationBaseTests.vb index a03679253d7..701b5d4ca82 100644 --- a/src/Microsoft.VisualBasic.Forms/tests/UnitTests/System/Windows/Forms/WindowsFormsApplicationBaseTests.vb +++ b/src/Microsoft.VisualBasic.Forms/tests/UnitTests/System/Windows/Forms/WindowsFormsApplicationBaseTests.vb @@ -21,6 +21,8 @@ Namespace Microsoft.VisualBasic.Forms.Tests ColorMode = SystemColorMode.Dark ColorMode.Should.Be(SystemColorMode.Dark) + VisualStylesMode.Should.Be(VisualStylesMode.Classic) + EnableVisualStyles.Should.Be(False) EnableVisualStyles = True EnableVisualStyles.Should.Be(True) @@ -113,6 +115,31 @@ Namespace Microsoft.VisualBasic.Forms.Tests End If End Sub + + Public Sub OnInitialize_ApplyApplicationDefaults_VisualStylesModeFlowsToApplication() + If RemoteExecutor.IsSupported Then + Dim test As Action = + Sub() + Dim appModel As New SubWindowsFormsApplicationBase() With + { + .EnableVisualStylesCore = True + } + + AddHandler appModel.ApplyApplicationDefaults, + Sub(sender, e) + e.VisualStylesMode = VisualStylesMode.Latest + End Sub + + appModel.CallOnInitialize(Array.Empty(Of String)()).Should.BeTrue() + System.Windows.Forms.Application.DefaultVisualStylesMode.Should.Be(VisualStylesMode.Latest) + End Sub + + Using handle As RemoteInvokeHandle = RemoteExecutor.Invoke(test) + handle.ExitCode.Should.Be(RemoteExecutor.SuccessExitCode) + End Using + End If + End Sub + Public Sub ShowHideSplashScreenSuccess() Dim testCode As Action @@ -158,5 +185,19 @@ Namespace Microsoft.VisualBasic.Forms.Tests End If End Sub + Private NotInheritable Class SubWindowsFormsApplicationBase + Inherits WindowsFormsApplicationBase + + Public Function CallOnInitialize(commandLineArgs As String()) As Boolean + Return MyBase.OnInitialize(Array.AsReadOnly(commandLineArgs)) + End Function + + Public WriteOnly Property EnableVisualStylesCore As Boolean + Set(value As Boolean) + EnableVisualStyles = value + End Set + End Property + End Class + End Class End Namespace diff --git a/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj b/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj index 028ddf0be71..f63dbaa5496 100644 --- a/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj +++ b/src/System.Private.Windows.Core/src/Microsoft.Private.Windows.Core.csproj @@ -58,6 +58,10 @@ + + + + diff --git a/src/System.Private.Windows.Core/src/NativeMethods.txt b/src/System.Private.Windows.Core/src/NativeMethods.txt index cc17c7c0a45..ddea7b6c144 100644 --- a/src/System.Private.Windows.Core/src/NativeMethods.txt +++ b/src/System.Private.Windows.Core/src/NativeMethods.txt @@ -119,6 +119,7 @@ GetSystemMetrics GetThreadLocale GetViewportExtEx GetViewportOrgEx +GetWindowDC GetWindowOrgEx GetWindowRect GetWindowText @@ -150,6 +151,7 @@ HINSTANCE HPEN HPROPSHEETPAGE HRGN +HSTRING HWND HWND_* IDataObject @@ -164,6 +166,7 @@ IDropTargetHelper IEnumFORMATETC IEnumUnknown IGlobalInterfaceTable +IInspectable ImageFormat* ImageLockMode INK_SERIALIZED_FORMAT @@ -188,6 +191,7 @@ MonitorFromWindow MONITORINFOEXW MONITORINFOF_* MultiByteToWideChar +NCCALCSIZE_PARAMS NONCLIENTMETRICSW NS_E_WMP_CANNOT_FIND_FILE NS_E_WMP_DSHOW_UNSUPPORTED_FORMAT @@ -230,6 +234,7 @@ ReleaseDC ReleaseStgMedium RestoreDC RevokeDragDrop +RoActivateInstance RPC_E_CHANGED_MODE RPC_E_DISCONNECTED RPC_E_SERVERFAULT @@ -277,5 +282,7 @@ WIN32_ERROR WINCODEC_ERR_* WINDOW_LONG_PTR_INDEX WindowFromDC +WindowsCreateString +WindowsDeleteString WM_* WPARAM \ No newline at end of file diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs new file mode 100644 index 00000000000..15028e00045 --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/IUISettings3.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.ViewManagement.IUISettings3. +/// +/// +/// +/// Manually defined as the type lives in WinRT metadata, not Win32 metadata, +/// and we do not want a CsWinRT projection dependency. Slots 3-5 are the +/// IInspectable methods. +/// +/// +internal unsafe struct IUISettings3 : IComIID +{ + private readonly void** _vtbl; + + // {03021BE4-5254-4781-8194-5168F7D06D7B} + public static Guid IID_Guid { get; } = new(0x03021be4, 0x5254, 0x4781, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b); + + static ref readonly Guid IComIID.Guid + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + ReadOnlySpan data = + [ + // 0x03021be4, 0x5254, 0x4781, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b + 0xe4, 0x1b, 0x02, 0x03, 0x54, 0x52, 0x81, 0x47, 0x81, 0x94, 0x51, 0x68, 0xf7, 0xd0, 0x6d, 0x7b + ]; + + return ref Unsafe.As(ref MemoryMarshal.GetReference(data)); + } + } + + public HRESULT QueryInterface(Guid* riid, void** ppvObject) + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[0])(pThis, riid, ppvObject); + } + + public uint AddRef() + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[1])(pThis); + } + + public uint Release() + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[2])(pThis); + } + + // Slots 3-5: IInspectable::GetIids, GetRuntimeClassName, GetTrustLevel (unused). + + public HRESULT GetColorValue(UIColorType desiredColor, UIColor* value) + { + fixed (IUISettings3* pThis = &this) + return ((delegate* unmanaged[Stdcall])_vtbl[6])(pThis, desiredColor, value); + } +} diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs new file mode 100644 index 00000000000..94c3036ca5b --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColor.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.Color, the value returned by +/// . +/// +/// +/// +/// Manually defined to mirror the WinRT ABI layout (four sequential bytes: alpha, red, green, blue) +/// without taking a CsWinRT projection dependency. +/// +/// +internal struct UIColor +{ + /// + /// The alpha channel of the color. + /// + public byte A; + + /// + /// The red channel of the color. + /// + public byte R; + + /// + /// The green channel of the color. + /// + public byte G; + + /// + /// The blue channel of the color. + /// + public byte B; +} diff --git a/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs new file mode 100644 index 00000000000..9725700b888 --- /dev/null +++ b/src/System.Private.Windows.Core/src/Windows/Win32/UI/ViewManagement/UIColorType.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Windows.Win32.UI.ViewManagement; + +/// +/// WinRT ABI for Windows.UI.ViewManagement.UIColorType, identifying which system color to +/// retrieve through . +/// +/// +/// +/// Manually defined to mirror the WinRT enumeration without taking a CsWinRT projection dependency. +/// +/// +internal enum UIColorType +{ + /// + /// The background color. + /// + Background = 0, + + /// + /// The foreground color. + /// + Foreground = 1, + + /// + /// The darkest of the three accent shades. + /// + AccentDark3 = 2, + + /// + /// The second darkest accent shade. + /// + AccentDark2 = 3, + + /// + /// The lightest of the three dark accent shades. + /// + AccentDark1 = 4, + + /// + /// The base accent color. + /// + Accent = 5, + + /// + /// The darkest of the three light accent shades. + /// + AccentLight1 = 6, + + /// + /// The second lightest accent shade. + /// + AccentLight2 = 7, + + /// + /// The lightest of the three accent shades. + /// + AccentLight3 = 8, + + /// + /// The complement of the accent color. + /// + Complement = 9, +} diff --git a/src/System.Windows.Forms.Design/tests/UnitTests/System/ComponentModel/Design/Serialization/CodeDomComponentSerializationServiceTests.cs b/src/System.Windows.Forms.Design/tests/UnitTests/System/ComponentModel/Design/Serialization/CodeDomComponentSerializationServiceTests.cs index 7bdd386e04d..ca15858de08 100644 --- a/src/System.Windows.Forms.Design/tests/UnitTests/System/ComponentModel/Design/Serialization/CodeDomComponentSerializationServiceTests.cs +++ b/src/System.Windows.Forms.Design/tests/UnitTests/System/ComponentModel/Design/Serialization/CodeDomComponentSerializationServiceTests.cs @@ -179,6 +179,136 @@ public void CreateStore_CloseSerialize_Success() Assert.Empty(Assert.IsType>(info.GetValue("Shim", typeof(List)))); } + [Theory] + [InlineData(VisualStylesMode.Inherit, false)] + [InlineData(VisualStylesMode.Classic, true)] + [InlineData(VisualStylesMode.Disabled, true)] + [InlineData(VisualStylesMode.Net11, true)] + [InlineData(VisualStylesMode.Latest, true)] + public void CreateStore_ControlVisualStylesMode_SerializesOnlyRawOverride( + VisualStylesMode value, + bool expectedAssignment) + { + CodeDomComponentSerializationService service = new(); + using Container container = new(); + using Control control = new() + { + VisualStylesMode = value + }; + container.Add(control, "control"); + using SerializationStore store = service.CreateStore(); + + service.Serialize(store, control); + store.Close(); + + Assert.Empty(store.Errors); + ISerializable serializable = + Assert.IsAssignableFrom(store); + SerializationInfo info = new( + store.GetType(), + new FormatterConverter()); + serializable.GetObjectData(info, default); + CodeDomComponentSerializationState state = + GetState(info)["control"]; + CodeStatementCollection statements = + Assert.IsType(state.Code); + CodeAssignStatement visualStylesAssignment = statements + .Cast() + .OfType() + .FirstOrDefault( + statement => statement.Left + is CodePropertyReferenceExpression + { + PropertyName: nameof(Control.VisualStylesMode) + }); + + Assert.Equal( + expectedAssignment, + visualStylesAssignment is not null); + + ICollection deserializedObjects = service.Deserialize(store); + Control roundTripped = Assert.IsType( + Assert.Single(deserializedObjects.Cast())); + try + { + PropertyDescriptor property = TypeDescriptor.GetProperties( + roundTripped)[nameof(Control.VisualStylesMode)]; + Assert.Equal( + expectedAssignment, + property.ShouldSerializeValue(roundTripped)); + if (expectedAssignment) + { + Assert.Equal(value, roundTripped.VisualStylesMode); + } + } + finally + { + roundTripped.Dispose(); + } + } + + [Theory] + [InlineData(0, false)] + [InlineData(3, true)] + public void CreateStore_ComboBoxPadding_SerializesOnlyNonDefaultValue( + int all, + bool expectedAssignment) + { + CodeDomComponentSerializationService service = new(); + using Container container = new(); + using ComboBox comboBox = new() + { + Padding = new Padding(all) + }; + container.Add(comboBox, "comboBox"); + using SerializationStore store = service.CreateStore(); + + service.Serialize(store, comboBox); + store.Close(); + + Assert.Empty(store.Errors); + ISerializable serializable = + Assert.IsAssignableFrom(store); + SerializationInfo info = new( + store.GetType(), + new FormatterConverter()); + serializable.GetObjectData(info, default); + CodeDomComponentSerializationState state = + GetState(info)["comboBox"]; + CodeStatementCollection statements = + Assert.IsType(state.Code); + CodeAssignStatement paddingAssignment = statements + .Cast() + .OfType() + .FirstOrDefault( + statement => statement.Left + is CodePropertyReferenceExpression + { + PropertyName: nameof(ComboBox.Padding) + }); + + Assert.Equal( + expectedAssignment, + paddingAssignment is not null); + + ICollection deserializedObjects = service.Deserialize(store); + ComboBox roundTripped = Assert.IsType( + Assert.Single(deserializedObjects.Cast())); + try + { + PropertyDescriptor property = TypeDescriptor.GetProperties( + roundTripped)[nameof(ComboBox.Padding)]; + Assert.Equal( + expectedAssignment, + property.ShouldSerializeValue(roundTripped)); + Assert.Equal(new Padding(all), roundTripped.Padding); + } + finally + { + roundTripped.Dispose(); + } + } + public static IEnumerable CreateStore_CloseSerializeWithInvalidProvider_TestData() { yield return new object[] { null }; diff --git a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt index a56634792b1..602b8fdb687 100644 --- a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt +++ b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt @@ -183,6 +183,7 @@ GetProcessWindowStation GETPROPERTYSTOREFLAGS GetRgnBox GetROP2 +GetScrollBarInfo GetScrollInfo GetShortPathName GetStartupInfo diff --git a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs new file mode 100644 index 00000000000..b0628b09154 --- /dev/null +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs @@ -0,0 +1,850 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.Tracing; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace System.Windows.Forms.Animation; + +/// +/// A static animation timer that delivers frames at up to 60 Hz on the synchronization +/// context captured for each registration. +/// +/// +/// +/// The pacer uses a single timeline and schedules every frame from +/// an absolute epoch. Stopping a generation cancels it without joining its worker; the active +/// generation and registration identity are verified before every dispatch, so late work is benign. +/// +/// +internal static partial class HighPrecisionTimer +{ + private const int MaximumFramesPerSecond = 60; + private const int FallbackFramesPerSecond = 30; + private const int ConsecutiveFaultLimit = 3; + private const long MicrosecondsPerSecond = 1_000_000; + private const double DriftThresholdRatio = 0.20; + private const uint CreateWaitableTimerHighResolution = 0x00000002; + private const uint TimerAllAccess = 0x001F0003; + private const uint WaitObject0 = 0; + private const uint Infinite = 0xFFFFFFFF; + + private static readonly Lock s_lock = new(); + private static readonly Dictionary s_registrations = []; + private static readonly SendOrPostCallback s_dispatchCallback = static state + => DispatchCallback((Registration)state!); + + private static Registration[] s_registrationSnapshot = []; + private static long s_nextId; + private static long s_nextGeneration; + private static int s_requestedFramesPerSecond = MaximumFramesPerSecond; + private static int s_effectiveFramesPerSecond = MaximumFramesPerSecond; + private static int s_highResolutionAvailable; + private static LoopState? s_loopState; + + /// + /// Gets the current target frame time in milliseconds. + /// + internal static double TargetFrameTimeMs + => 1000.0 / Volatile.Read(ref s_effectiveFramesPerSecond); + + /// + /// Gets or sets the requested pacer rate. The active rate is capped at 30 Hz when + /// high-resolution waitable timers are unavailable. + /// + internal static int TargetFramesPerSecond + { + get => Volatile.Read(ref s_requestedFramesPerSecond); + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, MaximumFramesPerSecond); + + lock (s_lock) + { + if (s_requestedFramesPerSecond == value) + { + return; + } + + s_requestedFramesPerSecond = value; + if (s_loopState is not null) + { + StopTimerLocked(); + StartTimerLocked(); + } + } + } + } + + /// + /// Gets whether high-resolution waitable timers are available for the current generation. + /// + internal static bool IsHighResolutionAvailable + => Volatile.Read(ref s_highResolutionAvailable) != 0; + + /// + /// Registers a callback to be invoked on each animation frame tick. The current + /// is captured for dispatch. + /// + /// The callback invoked for each delivered frame. + /// A that unregisters the callback when disposed. + /// + /// Thrown when no is available on the current thread. + /// + internal static TimerRegistration Register(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + + SynchronizationContext syncContext = SynchronizationContext.Current + ?? throw new InvalidOperationException( + "A SynchronizationContext must be available on the calling thread. " + + "Ensure registration is performed from a UI thread."); + + lock (s_lock) + { + long id = ++s_nextId; + Registration registration = new(id, callback, syncContext); + s_registrations.Add(id, registration); + UpdateRegistrationSnapshotLocked(); + + if (s_loopState is null) + { + StartTimerLocked(); + } + + return new TimerRegistration(id); + } + } + + /// + /// Unregisters a previously registered callback. + /// + internal static void Unregister(long registrationId) + => Unregister(registrationId, expectedRegistration: null); + + private static void Unregister(long registrationId, Registration? expectedRegistration) + { + if (registrationId == 0) + { + return; + } + + lock (s_lock) + { + if (!s_registrations.TryGetValue(registrationId, out Registration? registration) + || (expectedRegistration is not null + && !ReferenceEquals(registration, expectedRegistration))) + { + return; + } + + Volatile.Write(ref registration.IsActive, 0); + _ = s_registrations.Remove(registrationId); + UpdateRegistrationSnapshotLocked(); + + if (s_registrations.Count == 0) + { + StopTimerLocked(); + } + } + } + + private static void StartTimerLocked() + { + if (s_loopState is not null) + { + return; + } + + SafeWaitableTimerHandle? waitableTimer = TryCreateHighResolutionWaitableTimer(); + int framesPerSecond = waitableTimer is null + ? Math.Min(s_requestedFramesPerSecond, FallbackFramesPerSecond) + : s_requestedFramesPerSecond; + LoopState state = new(++s_nextGeneration, framesPerSecond, waitableTimer); + + s_loopState = state; + Volatile.Write(ref s_effectiveFramesPerSecond, framesPerSecond); + Volatile.Write(ref s_highResolutionAvailable, waitableTimer is null ? 0 : 1); + state.Start(); + } + + private static void StopTimerLocked() + { + LoopState? state = s_loopState; + s_loopState = null; + Volatile.Write(ref s_highResolutionAvailable, 0); + + // Cancellation is intentionally nonblocking. The old loop owns and disposes its resources + // after its outstanding native wait completes, and cannot dispatch once it is no longer current. + state?.Cancel(); + } + + private static void TimerLoop(LoopState state) + { + bool restart = false; + + try + { + long scheduledFrame = 1; + + while (!state.Token.IsCancellationRequested) + { + long dueTimestamp = GetScheduledTimestamp( + state.EpochTimestamp, + scheduledFrame, + state.FramesPerSecond); + + if (!WaitUntilDue(state, dueTimestamp)) + { + break; + } + + long timestamp = Stopwatch.GetTimestamp(); + if (state.Token.IsCancellationRequested) + { + break; + } + + double expectedMilliseconds = 1000.0 / state.FramesPerSecond; + long driftMicroseconds = StopwatchTicksToMicroseconds(timestamp - dueTimestamp); + if (Math.Abs(driftMicroseconds) > expectedMilliseconds * 1000 * DriftThresholdRatio + && HighPrecisionTimerEventSource.s_log.IsEnabled( + EventLevel.Warning, + EventKeywords.None)) + { + HighPrecisionTimerEventSource.s_log.Drift( + driftMicroseconds, + scheduledFrame); + } + + DispatchCallbacks(state, timestamp); + + scheduledFrame++; + scheduledFrame = Math.Max( + scheduledFrame, + GetFirstFutureFrameIndex( + state.EpochTimestamp, + Stopwatch.GetTimestamp(), + state.FramesPerSecond)); + } + + restart = !state.Token.IsCancellationRequested; + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + HighPrecisionTimerEventSource.s_log.LoopFault( + ex.GetType().FullName ?? ex.GetType().Name); + restart = !state.Token.IsCancellationRequested; + } + finally + { + OnTimerLoopStopped(state, restart); + state.Dispose(); + } + } + + private static void OnTimerLoopStopped(LoopState state, bool restart) + { + lock (s_lock) + { + if (!ReferenceEquals(s_loopState, state)) + { + return; + } + + s_loopState = null; + Volatile.Write(ref s_highResolutionAvailable, 0); + + if (restart && s_registrations.Count > 0) + { + StartTimerLocked(); + } + } + } + + private static bool WaitUntilDue(LoopState state, long dueTimestamp) + { + if (state.Token.IsCancellationRequested) + { + return false; + } + + long remainingStopwatchTicks = dueTimestamp - Stopwatch.GetTimestamp(); + if (remainingStopwatchTicks <= 0) + { + return true; + } + + if (remainingStopwatchTicks <= Stopwatch.Frequency / 1000) + { + SpinToDueTimestamp(state, dueTimestamp); + return !state.Token.IsCancellationRequested; + } + + if (state.WaitableTimer is not null + && WaitForHighResolutionTimer(state.WaitableTimer, remainingStopwatchTicks)) + { + remainingStopwatchTicks = dueTimestamp - Stopwatch.GetTimestamp(); + if (remainingStopwatchTicks <= 0) + { + return true; + } + + if (remainingStopwatchTicks <= Stopwatch.Frequency / 1000) + { + SpinToDueTimestamp(state, dueTimestamp); + return !state.Token.IsCancellationRequested; + } + } + + return WaitCoarselyUntilDue(state, dueTimestamp); + } + + private static bool WaitCoarselyUntilDue(LoopState state, long dueTimestamp) + { + long residualSpinThreshold = Stopwatch.Frequency / 1000; + + while (!state.Token.IsCancellationRequested) + { + long remainingStopwatchTicks = dueTimestamp - Stopwatch.GetTimestamp(); + if (remainingStopwatchTicks <= 0) + { + return true; + } + + if (remainingStopwatchTicks <= residualSpinThreshold) + { + SpinToDueTimestamp(state, dueTimestamp); + return !state.Token.IsCancellationRequested; + } + + long sleepMilliseconds = Math.Max( + 1, + StopwatchTicksToMilliseconds(remainingStopwatchTicks - residualSpinThreshold)); + Thread.Sleep((int)Math.Min(sleepMilliseconds, int.MaxValue)); + } + + return false; + } + + private static void SpinToDueTimestamp(LoopState state, long dueTimestamp) + { + long spinStart = Stopwatch.GetTimestamp(); + SpinWait spinner = default; + + while (!state.Token.IsCancellationRequested + && Stopwatch.GetTimestamp() < dueTimestamp) + { + // This path is entered only for the sub-millisecond remainder after a wait. + spinner.SpinOnce(sleep1Threshold: -1); + } + + long spinTicks = Stopwatch.GetTimestamp() - spinStart; + TraceResidualSpin(spinTicks); + } + + private static void TraceResidualSpin(long spinTicks) + { + if (spinTicks > 0 + && HighPrecisionTimerEventSource.s_log.IsEnabled( + EventLevel.Informational, + EventKeywords.None)) + { + HighPrecisionTimerEventSource.s_log.ResidualSpin( + StopwatchTicksToMicroseconds(spinTicks)); + } + } + + private static bool WaitForHighResolutionTimer( + SafeWaitableTimerHandle waitableTimer, + long remainingStopwatchTicks) + { + long relativeDueTime = -Math.Max( + 1, + StopwatchTicksToTimeSpanTicks(remainingStopwatchTicks)); + + return NativeMethods.SetWaitableTimer( + waitableTimer, + in relativeDueTime, + period: 0, + completionRoutine: IntPtr.Zero, + argumentToCompletionRoutine: IntPtr.Zero, + resume: false) + && NativeMethods.WaitForSingleObject(waitableTimer, Infinite) == WaitObject0; + } + + private static void DispatchCallbacks(LoopState state, long timestamp) + { + if (!ReferenceEquals(Volatile.Read(ref s_loopState), state)) + { + return; + } + + Registration[] registrations = Volatile.Read(ref s_registrationSnapshot); + for (int i = 0; i < registrations.Length; i++) + { + Registration registration = registrations[i]; + if (Volatile.Read(ref registration.IsActive) == 0) + { + continue; + } + + if (Interlocked.CompareExchange(ref registration.InFlight, 1, 0) != 0) + { + int droppedFrames = Interlocked.Increment(ref registration.DroppedFrames); + HighPrecisionTimerEventSource.s_log.DroppedFrames( + registration.Id, + droppedFrames); + continue; + } + + long previousTimestamp = Interlocked.Exchange( + ref registration.LastDeliveredTimestamp, + timestamp); + long elapsedTimestamp = previousTimestamp == 0 + ? state.FramePeriodStopwatchTicks + : timestamp - previousTimestamp; + long frameIndex = Interlocked.Increment(ref registration.FrameIndex) - 1; + int dropped = Interlocked.Exchange(ref registration.DroppedFrames, 0); + + registration.PendingTick = new HighPrecisionTimerTick + { + Timestamp = StopwatchTicksToTimeSpan(timestamp - state.EpochTimestamp), + Elapsed = StopwatchTicksToTimeSpan(elapsedTimestamp), + DroppedFrames = dropped, + FrameIndex = frameIndex + }; + registration.LoopState = state; + + try + { + registration.SyncContext.Post(s_dispatchCallback, registration); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + TraceCallbackFault(registration, ex); + CompleteCallback(registration, succeeded: false); + } + } + } + + private static void DispatchCallback(Registration registration) + { + LoopState? state = registration.LoopState; + if (state is null + || Volatile.Read(ref registration.IsActive) == 0 + || !ReferenceEquals(Volatile.Read(ref s_loopState), state)) + { + Interlocked.Exchange(ref registration.InFlight, 0); + return; + } + + try + { + ValueTask callbackTask = registration.Callback(registration.PendingTick, state.Token); + if (callbackTask.IsCompletedSuccessfully) + { + CompleteCallback(registration, succeeded: true); + return; + } + + _ = AwaitCallbackAsync(registration, state, callbackTask); + } + catch (OperationCanceledException) when (state.Token.IsCancellationRequested) + { + Interlocked.Exchange(ref registration.InFlight, 0); + } + catch (Exception ex) + { + TraceCallbackFault(registration, ex); + CompleteCallback(registration, succeeded: false); + } + } + + private static async Task AwaitCallbackAsync( + Registration registration, + LoopState state, + ValueTask callbackTask) + { + try + { + await callbackTask.ConfigureAwait(false); + CompleteCallback(registration, succeeded: true); + } + catch (OperationCanceledException) when (state.Token.IsCancellationRequested) + { + Interlocked.Exchange(ref registration.InFlight, 0); + } + catch (Exception ex) + { + TraceCallbackFault(registration, ex); + CompleteCallback(registration, succeeded: false); + } + } + + private static void CompleteCallback(Registration registration, bool succeeded) + { + if (succeeded) + { + Interlocked.Exchange(ref registration.ConsecutiveFaults, 0); + } + else if (Interlocked.Increment(ref registration.ConsecutiveFaults) >= ConsecutiveFaultLimit) + { + Unregister(registration.Id, registration); + } + + Interlocked.Exchange(ref registration.InFlight, 0); + } + + private static void TraceCallbackFault(Registration registration, Exception exception) + { + HighPrecisionTimerEventSource.s_log.CallbackFault( + registration.Id, + exception.GetType().FullName ?? exception.GetType().Name); + } + + [SupportedOSPlatform("windows10.0.17134.0")] + private static SafeWaitableTimerHandle? TryCreateHighResolutionWaitableTimer() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134)) + { + return null; + } + + try + { + SafeWaitableTimerHandle waitableTimer = NativeMethods.CreateWaitableTimerEx( + IntPtr.Zero, + timerName: null, + flags: CreateWaitableTimerHighResolution, + desiredAccess: TimerAllAccess); + if (!waitableTimer.IsInvalid) + { + return waitableTimer; + } + + waitableTimer.Dispose(); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + // The coarse path is intentionally used when this optional Windows capability is unavailable. + } + + return null; + } + + private static long GetScheduledTimestamp( + long epochTimestamp, + long frameIndex, + int framesPerSecond) + { + long wholeSeconds = frameIndex / framesPerSecond; + long partialSecondFrames = frameIndex % framesPerSecond; + return epochTimestamp + + (wholeSeconds * Stopwatch.Frequency) + + ((partialSecondFrames * Stopwatch.Frequency) / framesPerSecond); + } + + private static long GetFirstFutureFrameIndex( + long epochTimestamp, + long timestamp, + int framesPerSecond) + { + long elapsed = Math.Max(0, timestamp - epochTimestamp); + long wholeSeconds = elapsed / Stopwatch.Frequency; + long remainder = elapsed % Stopwatch.Frequency; + return (wholeSeconds * framesPerSecond) + + ((remainder * framesPerSecond) / Stopwatch.Frequency) + + 1; + } + + private static long StopwatchTicksToMilliseconds(long stopwatchTicks) + => (stopwatchTicks / Stopwatch.Frequency * 1000) + + ((stopwatchTicks % Stopwatch.Frequency * 1000) / Stopwatch.Frequency); + + private static long StopwatchTicksToMicroseconds(long stopwatchTicks) + => (stopwatchTicks / Stopwatch.Frequency * MicrosecondsPerSecond) + + ((stopwatchTicks % Stopwatch.Frequency * MicrosecondsPerSecond) + / Stopwatch.Frequency); + + private static long StopwatchTicksToTimeSpanTicks(long stopwatchTicks) + => (stopwatchTicks / Stopwatch.Frequency * TimeSpan.TicksPerSecond) + + ((stopwatchTicks % Stopwatch.Frequency * TimeSpan.TicksPerSecond) + / Stopwatch.Frequency); + + private static long TimeSpanTicksToStopwatchTicks(long timeSpanTicks) + => (timeSpanTicks / TimeSpan.TicksPerSecond * Stopwatch.Frequency) + + ((timeSpanTicks % TimeSpan.TicksPerSecond * Stopwatch.Frequency) + / TimeSpan.TicksPerSecond); + + private static TimeSpan StopwatchTicksToTimeSpan(long stopwatchTicks) + => TimeSpan.FromTicks(StopwatchTicksToTimeSpanTicks(stopwatchTicks)); + + /// + /// Gets an absolute schedule timestamp for deterministic timer tests. + /// + internal static long GetScheduledTimestampForTesting( + long epochTimestamp, + long frameIndex, + int framesPerSecond) + => GetScheduledTimestamp(epochTimestamp, frameIndex, framesPerSecond); + + /// + /// Dispatches a supplied timestamp through the current generation for allocation tests. + /// + internal static void DispatchCallbacksForTesting(TimeSpan timestamp) + { + LoopState? state = Volatile.Read(ref s_loopState); + if (state is not null) + { + DispatchCallbacks( + state, + state.EpochTimestamp + TimeSpanTicksToStopwatchTicks(timestamp.Ticks)); + } + } + + /// + /// Emits the residual-spin diagnostic for allocation tests. + /// + internal static void TraceResidualSpinForTesting(TimeSpan duration) + => TraceResidualSpin(TimeSpanTicksToStopwatchTicks(duration.Ticks)); + + /// + /// Gets the current generation for deterministic timer tests. + /// + internal static long CurrentGenerationForTesting + { + get + { + lock (s_lock) + { + return s_loopState?.Generation ?? 0; + } + } + } + + /// + /// Gets the number of active registrations for deterministic timer tests. + /// + internal static int RegistrationCountForTesting + { + get + { + lock (s_lock) + { + return s_registrations.Count; + } + } + } + + /// + /// Gets whether a current generation is running for deterministic timer tests. + /// + internal static bool IsTimerRunningForTesting + { + get + { + lock (s_lock) + { + return s_loopState is not null; + } + } + } + + /// + /// Resets internal state. This test hook is serialized with registration changes and never + /// reuses registration identifiers, so late callbacks and disposals remain harmless. + /// + internal static void Reset() + { + lock (s_lock) + { + StopTimerLocked(); + + foreach (Registration registration in s_registrations.Values) + { + Volatile.Write(ref registration.IsActive, 0); + } + + s_registrations.Clear(); + Volatile.Write(ref s_registrationSnapshot, []); + s_requestedFramesPerSecond = MaximumFramesPerSecond; + Volatile.Write(ref s_effectiveFramesPerSecond, MaximumFramesPerSecond); + s_nextGeneration++; + } + } + + private static void UpdateRegistrationSnapshotLocked() + { + Registration[] registrations = new Registration[s_registrations.Count]; + s_registrations.Values.CopyTo(registrations, 0); + Volatile.Write(ref s_registrationSnapshot, registrations); + } + + /// + /// Holds state owned by one nonblocking timer-loop generation. + /// + private sealed class LoopState : IDisposable + { + public LoopState( + long generation, + int framesPerSecond, + SafeWaitableTimerHandle? waitableTimer) + { + CancellationSource = new CancellationTokenSource(); + Token = CancellationSource.Token; + EpochTimestamp = Stopwatch.GetTimestamp(); + FramePeriodStopwatchTicks = GetScheduledTimestamp( + epochTimestamp: 0, + frameIndex: 1, + framesPerSecond); + Generation = generation; + FramesPerSecond = framesPerSecond; + WaitableTimer = waitableTimer; + } + + public CancellationTokenSource CancellationSource { get; } + public CancellationToken Token { get; } + public long EpochTimestamp { get; } + public long FramePeriodStopwatchTicks { get; } + public long Generation { get; } + public int FramesPerSecond { get; } + public SafeWaitableTimerHandle? WaitableTimer { get; } + + public void Start() + { + _ = Task.Factory.StartNew( + static state => TimerLoop((LoopState)state!), + this, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + public void Cancel() => CancellationSource.Cancel(); + + public void Dispose() + { + WaitableTimer?.Dispose(); + CancellationSource.Dispose(); + } + } + + /// + /// Holds a callback registration and its reusable dispatch state. + /// + private sealed class Registration( + long id, + Func callback, + SynchronizationContext syncContext) + { + public long Id { get; } = id; + public Func Callback { get; } = callback; + public SynchronizationContext SyncContext { get; } = syncContext; + public HighPrecisionTimerTick PendingTick; + public LoopState? LoopState; + public long FrameIndex; + public long LastDeliveredTimestamp; + public int ConsecutiveFaults; + public int DroppedFrames; + public int InFlight; + public int IsActive = 1; + } + + /// + /// Releases waitable-timer handles through CloseHandle. + /// + private sealed class SafeWaitableTimerHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public SafeWaitableTimerHandle() : base(ownsHandle: true) + { + } + + protected override bool ReleaseHandle() => NativeMethods.CloseHandle(handle); + } + + /// + /// Emits diagnostic events for timer drift, coalescing, residual spin, and callback faults. + /// + [EventSource(Name = "System.Windows.Forms.HighPrecisionTimer")] + private sealed class HighPrecisionTimerEventSource : EventSource + { + public static readonly HighPrecisionTimerEventSource s_log = new(); + + [Event(1, Level = EventLevel.Warning)] + public void Drift(long microseconds, long frameIndex) + => WriteEvent(1, microseconds, frameIndex); + + [Event(2, Level = EventLevel.Informational)] + public void DroppedFrames(long registrationId, int droppedFrames) + => WriteEvent(2, registrationId, droppedFrames); + + [Event(3, Level = EventLevel.Informational)] + public void ResidualSpin(long microseconds) + => WriteEvent(3, microseconds); + + [Event(4, Level = EventLevel.Error)] + public void CallbackFault(long registrationId, string exceptionType) + => WriteEvent(4, registrationId, exceptionType); + + [Event(5, Level = EventLevel.Error)] + public void LoopFault(string exceptionType) + => WriteEvent(5, exceptionType); + } + + private static partial class NativeMethods + { + [LibraryImport( + "kernel32.dll", + EntryPoint = "CreateWaitableTimerExW", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeWaitableTimerHandle CreateWaitableTimerEx( + IntPtr timerAttributes, + string? timerName, + uint flags, + uint desiredAccess); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetWaitableTimer( + SafeWaitableTimerHandle timer, + in long dueTime, + int period, + IntPtr completionRoutine, + IntPtr argumentToCompletionRoutine, + [MarshalAs(UnmanagedType.Bool)] bool resume); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint WaitForSingleObject( + SafeWaitableTimerHandle handle, + uint milliseconds); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CloseHandle(IntPtr handle); + } + + /// + /// Represents a timer registration. Dispose to unregister. + /// + internal readonly struct TimerRegistration : IDisposable + { + private readonly long _id; + + internal TimerRegistration(long id) => _id = id; + + /// + /// Gets the registration identifier. + /// + public long Id => _id; + + /// + /// Unregisters this callback from the timer. + /// + public void Dispose() => Unregister(_id); + } +} diff --git a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs new file mode 100644 index 00000000000..3b6557e2643 --- /dev/null +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Animation; + +/// +/// Provides timing information for a single animation frame tick. +/// +internal readonly struct HighPrecisionTimerTick +{ + /// + /// The absolute timestamp of this tick from the timer's epoch. + /// + public TimeSpan Timestamp { get; init; } + + /// + /// The elapsed time since the last tick delivered to this registration. + /// + public TimeSpan Elapsed { get; init; } + + /// + /// The number of frames that were dropped (coalesced) since the last delivered tick. + /// + public int DroppedFrames { get; init; } + + /// + /// The zero-based frame index for this registration. + /// + public long FrameIndex { get; init; } +} diff --git a/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs b/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs new file mode 100644 index 00000000000..39c7c0396e9 --- /dev/null +++ b/src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs @@ -0,0 +1,525 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Windows.Forms.Animation; + +namespace System.Windows.Forms.Primitives.Tests.Animation; + +/// +/// A test synchronization context that executes posted callbacks immediately +/// on the thread pool, simulating a UI message pump for testing purposes. +/// +internal sealed class TestSynchronizationContext : SynchronizationContext +{ + public override void Post(SendOrPostCallback d, object? state) + => ThreadPool.QueueUserWorkItem( + _ => + { + SynchronizationContext? originalContext = Current; + SetSynchronizationContext(this); + + try + { + d(state); + } + finally + { + SetSynchronizationContext(originalContext); + } + }); + + public override void Send(SendOrPostCallback d, object? state) => d(state); +} + +// The timer is process-wide static; disable parallelization so timing-sensitive +// assertions are not perturbed by concurrently running tests. +[Collection(nameof(HighPrecisionTimerTests))] +[CollectionDefinition(nameof(HighPrecisionTimerTests), DisableParallelization = true)] +public sealed class HighPrecisionTimerTests : IDisposable +{ + private readonly SynchronizationContext? _originalContext; + + public HighPrecisionTimerTests() + { + _originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + } + + public void Dispose() + { + HighPrecisionTimer.Reset(); + SynchronizationContext.SetSynchronizationContext(_originalContext); + } + + [Fact] + public async Task SingleConsumer_ReceivesTicksAtApproximatelyExpectedRate() + { + ConcurrentBag intervals = []; + Stopwatch stopwatch = Stopwatch.StartNew(); + double lastTick = 0; + int tickCount = 0; + const int TargetTicks = 30; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + double now = stopwatch.Elapsed.TotalMilliseconds; + if (lastTick > 0) + { + intervals.Add(now - lastTick); + } + + lastTick = now; + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount >= TargetTicks); + + List sorted = [.. intervals.OrderBy(x => x)]; + double targetMs = HighPrecisionTimer.TargetFrameTimeMs; + + // Relaxed bounds to remain robust on loaded CI machines: the median must be in a + // sane band around the target frame time. + double median = Percentile(sorted, 0.50); + median.Should().BeLessThan(targetMs * 3.0, "the median frame interval should stay near the target"); + } + + [Fact] + public async Task MultipleConsumers_AllReceiveTicksIndependently() + { + const int ConsumerCount = 5; + const int TargetTicks = 15; + int[] tickCounts = new int[ConsumerCount]; + HighPrecisionTimer.TimerRegistration[] registrations = new HighPrecisionTimer.TimerRegistration[ConsumerCount]; + + for (int i = 0; i < ConsumerCount; i++) + { + int index = i; + registrations[i] = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref tickCounts[index]); + return ValueTask.CompletedTask; + }); + } + + await WaitForAsync(() => tickCounts.Min() >= TargetTicks); + + foreach (HighPrecisionTimer.TimerRegistration registration in registrations) + { + registration.Dispose(); + } + + tickCounts.Should().OnlyContain(count => count >= TargetTicks); + } + + [Fact] + public async Task SlowConsumer_DropsFramesInsteadOfQueuing() + { + ConcurrentBag ticks = []; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + async (tick, ct) => + { + ticks.Add(tick); + // Simulate slow rendering (well over one frame time). + await Task.Delay((int)(HighPrecisionTimer.TargetFrameTimeMs * 3), ct).ConfigureAwait(false); + }); + + await WaitForAsync(() => ticks.Sum(t => t.DroppedFrames) > 0, timeoutMs: 4000); + + ticks.Sum(t => t.DroppedFrames).Should().BeGreaterThan(0, "a slow consumer should report dropped frames"); + } + + [Fact] + public async Task Registration_Disposal_StopsCallbacks() + { + int tickCount = 0; + + HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount > 0); + registration.Dispose(); + int ticksAfterDispose = Volatile.Read(ref tickCount); + + await Task.Delay(200, TestContext.Current.CancellationToken); + + // At most a couple of in-flight callbacks may land right after disposal. + (Volatile.Read(ref tickCount) - ticksAfterDispose).Should().BeLessThanOrEqualTo(2); + } + + [Fact] + public void Registration_WithoutSyncContext_Throws() + { + SynchronizationContext? original = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + + try + { + Action act = () => HighPrecisionTimer.Register((tick, ct) => ValueTask.CompletedTask); + act.Should().Throw(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + } + + [Fact] + public async Task TimerTick_ProvidesElapsedAndIncreasingFrameIndex() + { + ConcurrentBag elapsedValues = []; + long lastFrameIndex = -1; + long firstElapsedTicks = -1; + long firstTimestampTicks = -1; + bool frameIndexMonotonic = true; + int tickCount = 0; + const int TargetTicks = 15; + + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + if (tick.FrameIndex <= lastFrameIndex) + { + frameIndexMonotonic = false; + } + + lastFrameIndex = tick.FrameIndex; + + if (tick.FrameIndex == 0) + { + Interlocked.CompareExchange(ref firstElapsedTicks, tick.Elapsed.Ticks, -1); + Interlocked.CompareExchange(ref firstTimestampTicks, tick.Timestamp.Ticks, -1); + } + else + { + elapsedValues.Add(tick.Elapsed.TotalMilliseconds); + } + + Interlocked.Increment(ref tickCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => tickCount >= TargetTicks); + + frameIndexMonotonic.Should().BeTrue("frame indices should increase monotonically"); + TimeSpan.FromTicks(Volatile.Read(ref firstElapsedTicks)).TotalMilliseconds.Should().BeInRange( + HighPrecisionTimer.TargetFrameTimeMs * 0.5, + HighPrecisionTimer.TargetFrameTimeMs * 2.0); + TimeSpan.FromTicks(Volatile.Read(ref firstTimestampTicks)).TotalMilliseconds.Should().BeInRange( + 0, + HighPrecisionTimer.TargetFrameTimeMs * 3.0); + elapsedValues.Should().NotBeEmpty(); + elapsedValues.Should().OnlyContain(value => value > 0, "elapsed time between ticks should be positive"); + } + + [Fact] + public void AbsoluteSchedule_UsesStopwatchTicksWithoutSlowDrift() + { + const long EpochTimestamp = 37; + const long FrameCount = 600; + const int FramesPerSecond = 60; + + long actual = HighPrecisionTimer.GetScheduledTimestampForTesting( + EpochTimestamp, + FrameCount, + FramesPerSecond); + long expected = EpochTimestamp + (Stopwatch.Frequency * 10); + + actual.Should().Be(expected, "600 frames at 60 Hz are exactly ten seconds on the absolute schedule"); + } + + [Fact] + public async Task RegisterAndUnregister_ConcurrentStress_DoesNotStrandTheTimer() + { + const int WorkerCount = 4; + const int RegistrationsPerWorker = 50; + + Task[] workers = Enumerable.Range(0, WorkerCount) + .Select( + _ => Task.Run( + () => + { + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new TestSynchronizationContext()); + + try + { + for (int i = 0; i < RegistrationsPerWorker; i++) + { + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => ValueTask.CompletedTask); + } + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + }, + TestContext.Current.CancellationToken)) + .ToArray(); + + await Task.WhenAll(workers); + await WaitForAsync( + () => HighPrecisionTimer.RegistrationCountForTesting == 0 + && !HighPrecisionTimer.IsTimerRunningForTesting); + } + + [Fact] + public async Task RegisterAfterLastUnregister_StartsANewGeneration() + { + int firstCallbackCount = 0; + HighPrecisionTimer.TimerRegistration firstRegistration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref firstCallbackCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => Volatile.Read(ref firstCallbackCount) > 0); + long firstGeneration = HighPrecisionTimer.CurrentGenerationForTesting; + firstRegistration.Dispose(); + + int secondCallbackCount = 0; + using HighPrecisionTimer.TimerRegistration secondRegistration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref secondCallbackCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => Volatile.Read(ref secondCallbackCount) > 0); + + HighPrecisionTimer.CurrentGenerationForTesting.Should().BeGreaterThan( + firstGeneration, + "the replacement loop must not share a canceled generation"); + } + + [Fact] + public async Task LatePostedCallback_AfterDisposal_IsBenign() + { + SynchronizationContext? originalContext = SynchronizationContext.Current; + QueuedSynchronizationContext queuedContext = new(); + SynchronizationContext.SetSynchronizationContext(queuedContext); + int callbackCount = 0; + + try + { + HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref callbackCount); + return ValueTask.CompletedTask; + }); + + SynchronizationContext.SetSynchronizationContext(null); + await WaitForAsync(() => queuedContext.Count > 0); + registration.Dispose(); + queuedContext.ExecuteAll(); + + Volatile.Read(ref callbackCount).Should().Be( + 0, + "callbacks posted before disposal must verify that their registration remains active"); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + [Fact] + public async Task Reset_IsSerializedWithTheActiveGeneration() + { + int callbackCount = 0; + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref callbackCount); + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => Volatile.Read(ref callbackCount) > 0); + HighPrecisionTimer.Reset(); + + HighPrecisionTimer.RegistrationCountForTesting.Should().Be(0); + HighPrecisionTimer.IsTimerRunningForTesting.Should().BeFalse(); + } + + [Fact] + public async Task CallbackFaults_ResetAfterSuccess() + { + int callbackCount = 0; + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => + { + int currentCallback = Interlocked.Increment(ref callbackCount); + if (currentCallback is 1 or 3 or 4) + { + throw new InvalidOperationException(); + } + + return ValueTask.CompletedTask; + }); + + await WaitForAsync(() => Volatile.Read(ref callbackCount) >= 5); + + HighPrecisionTimer.RegistrationCountForTesting.Should().Be( + 1, + "a successful callback resets the consecutive-fault counter"); + } + + [Fact] + public async Task ThreeConsecutiveCallbackFaults_AutoUnregisters() + { + int callbackCount = 0; + _ = HighPrecisionTimer.Register( + (tick, ct) => + { + Interlocked.Increment(ref callbackCount); + throw new InvalidOperationException(); + }); + + await WaitForAsync(() => HighPrecisionTimer.RegistrationCountForTesting == 0); + + Volatile.Read(ref callbackCount).Should().Be(3); + HighPrecisionTimer.IsTimerRunningForTesting.Should().BeFalse(); + } + + [Fact] + public void DispatchCallbacks_SteadyState_DoesNotAllocate() + { + SynchronizationContext? originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new ImmediateSynchronizationContext()); + + try + { + using HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + (tick, ct) => ValueTask.CompletedTask); + + HighPrecisionTimer.DispatchCallbacksForTesting(TimeSpan.FromMilliseconds(1)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) + { + HighPrecisionTimer.DispatchCallbacksForTesting( + TimeSpan.FromMilliseconds(i + 2)); + } + + long allocatedAfter = GC.GetAllocatedBytesForCurrentThread(); + allocatedAfter.Should().Be( + allocatedBefore, + "steady-state dispatch uses a cached callback and per-registration state"); + } + finally + { + SynchronizationContext.SetSynchronizationContext(originalContext); + } + } + + [Fact] + public void ResidualSpinTracing_Disabled_DoesNotAllocate() + { + HighPrecisionTimer.TraceResidualSpinForTesting(TimeSpan.FromMilliseconds(0.5)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) + { + HighPrecisionTimer.TraceResidualSpinForTesting(TimeSpan.FromMilliseconds(0.5)); + } + + long allocatedAfter = GC.GetAllocatedBytesForCurrentThread(); + allocatedAfter.Should().Be( + allocatedBefore, + "disabled residual-spin diagnostics must not allocate on the timer hot path"); + } + + [Fact] + public async Task AsyncCallback_CancellationDuringNonblockingShutdown_IsBenign() + { + TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + HighPrecisionTimer.TimerRegistration registration = HighPrecisionTimer.Register( + async (tick, cancellationToken) => + { + started.TrySetResult(); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + finally + { + completed.TrySetResult(); + } + }); + + await started.Task.WaitAsync(TestContext.Current.CancellationToken); + registration.Dispose(); + await completed.Task.WaitAsync(TestContext.Current.CancellationToken); + await WaitForAsync(() => !HighPrecisionTimer.IsTimerRunningForTesting); + + HighPrecisionTimer.RegistrationCountForTesting.Should().Be(0); + } + + private static double Percentile(List sortedValues, double percentile) + { + if (sortedValues.Count == 0) + { + return 0; + } + + int index = (int)Math.Ceiling(percentile * sortedValues.Count) - 1; + return sortedValues[Math.Max(0, index)]; + } + + private static async Task WaitForAsync(Func condition, int timeoutMs = 5000) + { + Stopwatch stopwatch = Stopwatch.StartNew(); + while (!condition()) + { + if (stopwatch.ElapsedMilliseconds > timeoutMs) + { + throw new TimeoutException("Timed out waiting for the expected timer ticks."); + } + + await Task.Delay(25, TestContext.Current.CancellationToken).ConfigureAwait(false); + } + } + + private sealed class ImmediateSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback d, object? state) => d(state); + } + + private sealed class QueuedSynchronizationContext : SynchronizationContext + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _callbacks = []; + + public int Count => _callbacks.Count; + + public override void Post(SendOrPostCallback d, object? state) + => _callbacks.Enqueue((d, state)); + + public void ExecuteAll() + { + while (_callbacks.TryDequeue(out var callback)) + { + callback.Callback(callback.State); + } + } + } +} diff --git a/src/System.Windows.Forms/GlobalSuppressions.cs b/src/System.Windows.Forms/GlobalSuppressions.cs index 74c366ba97c..b1ed827437e 100644 --- a/src/System.Windows.Forms/GlobalSuppressions.cs +++ b/src/System.Windows.Forms/GlobalSuppressions.cs @@ -269,3 +269,4 @@ [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")] [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.Windows.Forms.IWin32Window,System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")] [assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.IntPtr,System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")] +[assembly: SuppressMessage("DocumentationAnalyzers.StyleRules", "DOC107:Use 'see cref'", Justification = "Has been referenced already in the same paragraph.", Scope = "member", Target = "~P:System.Windows.Forms.TextBoxBase.Padding")] diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index e69de29bb2d..bb1cccdbf60 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,48 @@ +#nullable enable +System.Windows.Forms.Appearance.ToggleSwitch = 2 -> System.Windows.Forms.Appearance +System.Windows.Forms.Control.VisualStylesModeChanged -> System.EventHandler? +System.Windows.Forms.Control.EffectiveVisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.Control.VisualStylesModeChangeImpact.Metrics = 3 -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.Control.VisualStylesModeChangeImpact.None = 0 -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.Control.VisualStylesModeChangeImpact.NonClientUpdate = 2 -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.Control.VisualStylesModeChangeImpact.Recreate = 4 -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.Control.VisualStylesModeChangeImpact.Repaint = 1 -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Classic = 0 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Disabled = 1 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Inherit = -1 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Latest = 32767 -> System.Windows.Forms.VisualStylesMode +System.Windows.Forms.VisualStylesMode.Net11 = 2 -> System.Windows.Forms.VisualStylesMode +override System.Windows.Forms.ButtonBase.OnSystemColorsChanged(System.EventArgs! e) -> void +override System.Windows.Forms.ButtonBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +override System.Windows.Forms.CheckBox.Dispose(bool disposing) -> void +override System.Windows.Forms.CheckBox.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.CheckBox.OnSystemColorsChanged(System.EventArgs! e) -> void +override System.Windows.Forms.CheckBox.OnVisualStylesModeChanged(System.EventArgs! e) -> void +override System.Windows.Forms.Form.OnSystemColorsChanged(System.EventArgs! e) -> void +override System.Windows.Forms.ComboBox.GetVisualStylesModeChangeImpact(System.Windows.Forms.VisualStylesMode oldMode, System.Windows.Forms.VisualStylesMode newMode) -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +override System.Windows.Forms.ComboBox.OnLayout(System.Windows.Forms.LayoutEventArgs! levent) -> void +override System.Windows.Forms.ComboBox.OnPaddingChanged(System.EventArgs! e) -> void +override System.Windows.Forms.ComboBox.OnSystemVisualSettingsChanged(System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +override System.Windows.Forms.ComboBox.OnVisualStylesModeChanged(System.EventArgs! e) -> void +override System.Windows.Forms.ComboBox.RescaleConstantsForDpi(int deviceDpiOld, int deviceDpiNew) -> void +override System.Windows.Forms.GroupBox.Dispose(bool disposing) -> void +override System.Windows.Forms.GroupBox.GetVisualStylesModeChangeImpact(System.Windows.Forms.VisualStylesMode oldMode, System.Windows.Forms.VisualStylesMode newMode) -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +override System.Windows.Forms.GroupBox.OnSystemVisualSettingsChanged(System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +override System.Windows.Forms.GroupBox.RescaleConstantsForDpi(int deviceDpiOld, int deviceDpiNew) -> void +override System.Windows.Forms.PropertyGrid.VisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +override System.Windows.Forms.PropertyGrid.VisualStylesMode.set -> void +override System.Windows.Forms.RadioButton.Dispose(bool disposing) -> void +override System.Windows.Forms.RadioButton.OnPaint(System.Windows.Forms.PaintEventArgs! pevent) -> void +override System.Windows.Forms.RadioButton.OnSystemColorsChanged(System.EventArgs! e) -> void +override System.Windows.Forms.RadioButton.OnVisualStylesModeChanged(System.EventArgs! e) -> void +override System.Windows.Forms.UpDownBase.OnVisualStylesModeChanged(System.EventArgs! e) -> void +static System.Windows.Forms.Application.DefaultVisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +static System.Windows.Forms.Application.SetDefaultVisualStylesMode(System.Windows.Forms.VisualStylesMode styleSetting) -> void +virtual System.Windows.Forms.Control.DefaultVisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +virtual System.Windows.Forms.Control.GetVisualStylesModeChangeImpact(System.Windows.Forms.VisualStylesMode oldMode, System.Windows.Forms.VisualStylesMode newMode) -> System.Windows.Forms.Control.VisualStylesModeChangeImpact +virtual System.Windows.Forms.Control.OnParentVisualStylesModeChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.Control.OnVisualStylesModeChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.Control.VisualStylesMode.get -> System.Windows.Forms.VisualStylesMode +virtual System.Windows.Forms.Control.VisualStylesMode.set -> void diff --git a/src/System.Windows.Forms/Resources/SR.resx b/src/System.Windows.Forms/Resources/SR.resx index 53d5073b748..e965a2bf7f8 100644 --- a/src/System.Windows.Forms/Resources/SR.resx +++ b/src/System.Windows.Forms/Resources/SR.resx @@ -171,6 +171,12 @@ Application exception mode cannot be changed once any Controls are created in the application. + + The default visual styles mode can only be set once and cannot be changed afterwards. + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + &Apply @@ -4987,7 +4993,7 @@ Stack trace where the illegal operation occurred was: Controls whether the Network button is displayed. - Controls whether the RadioButton appears as normal or as a Windows PushButton. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. Causes the radio button to automatically change state when clicked. @@ -6931,6 +6937,12 @@ Stack trace where the illegal operation occurred was: Occurs when the value of the DataContext property changes. + + Determines how the control renders itself when visual styles are applied. + + + Occurs when the value of the VisualStylesMode property changes. + Gets or sets the parameter that is passed to the Command property's object on execution or on execution context request. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..887b110efdd 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf @@ -242,6 +242,16 @@ Režim výjimky vlákna nelze změnit po vytvoření ovládacích prvků ve vlákně. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Použít @@ -2172,6 +2182,16 @@ Určuje, zda je ovládací prvek viditelný nebo skrytý. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Šířka ovládacího prvku, v souřadnicích kontejneru. @@ -8675,8 +8695,8 @@ Trasování zásobníku, kde došlo k neplatné operaci: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Určuje, zda je ovládací prvek RadioButton zobrazen standardně nebo jako tlačítko systému Windows (PushButton). + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..0c8c105f852 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf @@ -242,6 +242,16 @@ Der Threadausnahmemodus kann nicht mehr geändert werden, sobald Steuerelemente in dem Thread erstellt wurden. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Anwenden @@ -2172,6 +2182,16 @@ Bestimmt, ob das Steuerelement sichtbar oder ausgeblendet ist. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Die Breite des Steuerelements (in Containerkoordinaten). @@ -8675,8 +8695,8 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Steuert, ob der RadioButton normal oder als Windows-PushButton angezeigt wird. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..23652e73b41 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf @@ -242,6 +242,16 @@ El modo de excepción del subproceso no se puede cambiar una vez que se creen Controles en el subproceso. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Aplicar @@ -2172,6 +2182,16 @@ Determina si el control está visible u oculto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Ancho del control, en las coordenadas del contenedor. @@ -8675,8 +8695,8 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Controla si el RadioButton es de tipo normal o como PushButton de Windows. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..37dd4004a9a 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf @@ -242,6 +242,16 @@ Impossible de modifier le mode d'exceptions du thread une fois que des Controls ont été créés sur le thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Appliquer @@ -2172,6 +2182,16 @@ Détermine si le contrôle est visible ou masqué. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. La largeur du contrôle, en coordonnées conteneur. @@ -8675,8 +8695,8 @@ Cette opération non conforme s'est produite sur la trace de la pile : - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Contrôle si le RadioButton s'affiche sous la forme normale ou sous la forme d'un PushButton Windows. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..80225c77959 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf @@ -242,6 +242,16 @@ Una volta creati controlli nel thread, non è possibile modificare la modalità eccezioni del thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Applica @@ -2172,6 +2182,16 @@ Determina se il controllo è visibile o nascosto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. La larghezza del controllo, nelle coordinate del contenitore. @@ -8675,8 +8695,8 @@ Analisi dello stack dove si è verificata l'operazione non valida: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Determina se il controllo RadioButton verrà visualizzato in modo standard o come un pulsante di comando di Windows. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..7b1b75e340f 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf @@ -242,6 +242,16 @@ スレッドでコントロールが作成された後、スレッド例外モードを変更することはできません。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply 適用(&A) @@ -2172,6 +2182,16 @@ コントロールの表示、非表示を示します。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. コンテナー座標で表したコントロールの幅です。 @@ -8675,8 +8695,8 @@ Stack trace where the illegal operation occurred was: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - RadioButton を通常どおり表示するか、Windows のプッシュボタンとして表示するかを制御します。 + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..95fb05a186b 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf @@ -242,6 +242,16 @@ 스레드에서 컨트롤이 만들어진 이후에는 스레드 예외 모드를 변경할 수 없습니다. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply 적용(&A) @@ -2172,6 +2182,16 @@ 컨트롤을 표시할지 여부를 결정합니다. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 컨테이너 좌표로 표시한 컨트롤의 너비입니다. @@ -8675,8 +8695,8 @@ Stack trace where the illegal operation occurred was: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - RadioButton을 기본 모양으로 표시할지 Windows 누름 단추로 표시할지 제어합니다. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..676fb9a7844 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf @@ -242,6 +242,16 @@ Trybu wyjątków wątku nie można zmienić po utworzeniu jakichkolwiek formantów w aplikacji. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Zastosuj @@ -2172,6 +2182,16 @@ Określa, czy formant jest widoczny, czy ukryty. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Szerokość formantu we współrzędnych kontenera. @@ -8675,8 +8695,8 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Określa, czy element RadioButton ma mieć wygląd normalny, czy wygląd przycisku polecenia systemu Windows. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. 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..4091b3213eb 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf @@ -242,6 +242,16 @@ Não é possível alterar o modo de exceção de thread após a criação de qualquer controle no thread. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Aplicar @@ -2172,6 +2182,16 @@ Determina se o controle está visível ou oculto. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. A largura do controle, nas coordenadas do recipiente. @@ -8675,8 +8695,8 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Controla se RadioButton é exibido como normal ou como um botão de ação do Windows. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..9ef2c5da6c6 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf @@ -242,6 +242,16 @@ Режим исключений потоков нельзя изменить, если в потоке были созданы элементы управления. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Применить @@ -2172,6 +2182,16 @@ Определяет, отображается или скрыт данный элемент управления. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Ширина элемента управления в координатах контейнера. @@ -8675,8 +8695,8 @@ Stack trace where the illegal operation occurred was: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - Указывает, будет ли RadioButton отображаться стандартно или как объект Windows PushButton. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..a5fda56a36d 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf @@ -242,6 +242,16 @@ İş parçacığında herhangi bir Denetim oluşturulduktan sonra iş parçacığı özel durum modu değiştirilemez. + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply &Uygula @@ -2172,6 +2182,16 @@ Denetimin görünür mü yoksa gizli mi olduğunu belirler. + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. Denetimin genişliği (kapsayıcı koordinatlarında). @@ -8675,8 +8695,8 @@ Geçersiz işlemin gerçekleştiği yığın izi: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - RadioButton'ın normal mi ya da bir Windows PushButton olarak mı görüneceğini denetler. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. 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..23d8b0a20f1 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf @@ -242,6 +242,16 @@ 只要在线程上创建了任何控件,则线程异常模式将不能再有任何更改。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply 应用(&A) @@ -2172,6 +2182,16 @@ 确定该控件是可见的还是隐藏的。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 控件的宽度(以容器坐标表示)。 @@ -8675,8 +8695,8 @@ Stack trace where the illegal operation occurred was: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - 控制 RadioButton 是按通常情况显示还是显示为 Windows PushButton。 + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. 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..9fe60e811c4 100644 --- a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf +++ b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf @@ -242,6 +242,16 @@ 一旦在執行緒上建立了控制項,就不能變更執行緒例外狀況模式。 + + The default visual styles mode can only be set once and cannot be changed afterwards. + The default visual styles mode can only be set once and cannot be changed afterwards. + + + + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + VisualStylesMode.Inherit cannot be used as the application-wide default visual styles mode because the application root has no parent to inherit from. + + &Apply 套用(&A) @@ -2172,6 +2182,16 @@ 決定控制項是可見或隱藏。 + + Occurs when the value of the VisualStylesMode property changes. + Occurs when the value of the VisualStylesMode property changes. + + + + Determines how the control renders itself when visual styles are applied. + Determines how the control renders itself when visual styles are applied. + + The width of the control, in container coordinates. 容器座標中控制項的寬度。 @@ -8675,8 +8695,8 @@ Stack trace where the illegal operation occurred was: - Controls whether the RadioButton appears as normal or as a Windows PushButton. - 控制 RadioButton 顯示為一般按鈕或 Windows PushButton。 + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. + Controls whether the RadioButton appears as normal, as a Windows PushButton, or as a toggle switch. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs new file mode 100644 index 00000000000..44f71a4c855 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.WindowsAccentColor.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Windows.Win32.System.WinRT; +using Windows.Win32.UI.ViewManagement; + +namespace System.Windows.Forms; + +public sealed partial class Application +{ + /// + /// Gets the user's current Windows accent color. + /// + /// + /// The accent color the user has selected in the Windows personalization settings. + /// + /// + /// + /// The value is read from the Windows UISettings runtime component and reflects the color the + /// user picked (or that Windows derived automatically from the desktop background). If the user has not + /// chosen an accent color, Windows returns a default accent color defined by the operating system, so + /// this method always yields a usable color rather than throwing or returning an empty value. + /// + /// + internal static unsafe Color GetWindowsAccentColor() + { + HSTRING className = default; + + fixed (char* pClassName = "Windows.UI.ViewManagement.UISettings") + { + PInvokeCore.WindowsCreateString((PCWSTR)pClassName, 36u, &className).ThrowOnFailure(); + } + + try + { + using ComScope inspectable = new(null); + PInvokeCore.RoActivateInstance(className, inspectable).ThrowOnFailure(); + + using ComScope settings = inspectable.TryQuery(out HRESULT hr); + hr.ThrowOnFailure(); + + UIColor color; + settings.Value->GetColorValue(UIColorType.Accent, &color).ThrowOnFailure(); + + return Color.FromArgb(color.A, color.R, color.G, color.B); + } + finally + { + PInvokeCore.WindowsDeleteString(className); + } + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index aab496c2744..97aaa3c945d 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -45,6 +45,8 @@ public sealed partial class Application private static SystemColorMode? s_colorMode; + private static VisualStylesMode? s_defaultVisualStylesMode; + private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; private const string DarkModeKey = "AppsUseLightTheme"; private const int SystemDarkModeDisabled = 1; @@ -430,8 +432,8 @@ private static int GetSystemColorModeInternal() return systemColorMode; } - private static bool IsSystemDarkModeAvailable => - !SystemInformation.HighContrast && OsVersion.IsWindows11_OrGreater(); + private static bool IsSystemDarkModeAvailable + => !SystemInformation.HighContrast && OsVersion.IsWindows11_OrGreater(); /// /// Gets a value indicating whether the application is running in a dark system color context. @@ -592,6 +594,86 @@ public static void RegisterMessageLoop(MessageLoopCallback? callback) public static bool RenderWithVisualStyles => ComCtlSupportsVisualStyles && VisualStyleRenderer.IsSupported; + /// + /// Gets the default used as the rendering style guideline for the + /// application's controls. + /// + /// + /// The used as the rendering style guideline for the application's + /// controls. This is when visual styles are enabled and + /// otherwise, unless it has been changed by a call to + /// . + /// + /// + /// + /// The default value is so that applications that simply + /// recompile against a newer framework keep their existing look. Opt in to a newer renderer by + /// calling . + /// + /// + /// While visual styles are disabled, the effective value remains , + /// even if a different default has already been requested through + /// . + /// + /// + public static VisualStylesMode DefaultVisualStylesMode + => s_defaultVisualStylesMode switch + { + { } visualStylesMode when UseVisualStyles => visualStylesMode, + { } => VisualStylesMode.Disabled, + _ => UseVisualStyles + ? VisualStylesMode.Classic + : VisualStylesMode.Disabled + }; + + /// + /// Sets the default used as the rendering style guideline for the + /// application's controls. + /// + /// The version of the visual styles renderer to use by default. + /// + /// The default visual styles mode has already been set to a different value. It can only be set once. + /// + /// + /// is , which is not valid as + /// the application-wide default because the application root has no parent to inherit from. + /// + /// + /// is not a defined value. + /// + /// + /// + /// Call this method before creating any window. If visual styles have not been enabled through + /// , the effective mode remains . + /// Passing has the same effect as not calling + /// . + /// + /// + public static void SetDefaultVisualStylesMode(VisualStylesMode styleSetting) + { + // Validate the value. Inherit is the ambient sentinel and is invalid as the application default, + // since the application root has no parent to inherit from. The non-contiguous members (Inherit, + // Latest) prevent using the source generated enum validator. + _ = styleSetting switch + { + VisualStylesMode.Classic => styleSetting, + VisualStylesMode.Disabled => styleSetting, + VisualStylesMode.Net11 => styleSetting, + VisualStylesMode.Latest => styleSetting, + VisualStylesMode.Inherit => throw new ArgumentException( + SR.Application_VisualStylesModeInheritInvalidAsDefault, + nameof(styleSetting)), + _ => throw new InvalidEnumArgumentException(nameof(styleSetting), (int)styleSetting, typeof(VisualStylesMode)) + }; + + if (s_defaultVisualStylesMode is { } current && current != styleSetting) + { + throw new InvalidOperationException(SR.Application_VisualStylesModeCanOnlyBeSetOnce); + } + + s_defaultVisualStylesMode = styleSetting; + } + /// /// Gets or sets the format string to apply to top level window captions /// when they are displayed with a warning banner. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.VisualStylesMode.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.VisualStylesMode.Docs.cs new file mode 100644 index 00000000000..4354c55bc36 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.VisualStylesMode.Docs.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class Control +{ + /// + /// Gets or sets how the control renders itself when visual styles are applied. This is an ambient property. + /// + /// + /// The requested for the control. The default is + /// . + /// + /// + /// + /// When this property is , the effective renderer mode comes from + /// the parent control or from . Derived controls can override + /// to pin a renderer version for compatibility. + /// + /// + /// Modern modes can increase the space required for chrome without reducing the client area. Controls with + /// intrinsic sizing, or with enabled, request layout automatically. Multiline + /// and controls retain fixed bounds, so their client area can + /// shrink slightly. Prefer or for adaptive layouts. + /// + /// + /// High Contrast resolves the effective mode to classic rendering. See the + /// + /// .NET 11 VisualStyles layout guidance for migration patterns. + /// + /// + [SRCategory(nameof(SR.CatAppearance))] + [EditorBrowsable(EditorBrowsableState.Always)] + [AmbientValue(VisualStylesMode.Inherit)] + [SRDescription(nameof(SR.ControlVisualStylesModeDescr))] + public virtual partial VisualStylesMode VisualStylesMode { get; set; } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index 14efc5014ff..eff813067a2 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -146,6 +146,7 @@ public unsafe partial class Control : private protected static readonly object s_paddingChangedEvent = new(); private static readonly object s_previewKeyDownEvent = new(); private static readonly object s_dataContextEvent = new(); + private static readonly object s_visualStylesModeChangedEvent = new(); private static MessageId s_threadCallbackMessage; private static ContextCallback? s_invokeMarshaledCallbackHelperDelegate; @@ -224,6 +225,8 @@ public unsafe partial class Control : private static readonly int s_cacheTextFieldProperty = PropertyStore.CreateKey(); private static readonly int s_ambientPropertiesServiceProperty = PropertyStore.CreateKey(); private static readonly int s_dataContextProperty = PropertyStore.CreateKey(); + private static readonly int s_visualStylesModeProperty = PropertyStore.CreateKey(); + private static readonly int s_visualStylesModeChangeVersionProperty = PropertyStore.CreateKey(); private static readonly int s_deviceDpiInternal = PropertyStore.CreateKey(); private static readonly int s_originalDeviceDpiInternal = PropertyStore.CreateKey(); @@ -824,6 +827,18 @@ internal HBRUSH BackColorBrush [SRCategory(nameof(SR.CatData))] [Browsable(false)] [Bindable(true)] + // Note: unlike Font/Cursor (which use [AmbientValue(null)]) or RightToLeft (which uses + // [AmbientValue(RightToLeft.Inherit)]), DataContext intentionally has NO [AmbientValue]. + // [AmbientValue] only matters for the designer's CodeDOM serializer: when it is forced to emit an + // otherwise-ambient property (inherited/"difference" forms, member relationships, absolute + // serialization), it writes the AmbientValue as the "reset to ambient" sentinel. That only works + // when the setter treats that sentinel as "clear the local override." Font does (its setter uses + // AddOrRemoveValue, so Font = null re-inherits); RightToLeft does (the getter resolves Inherit to + // the parent). DataContext's setter instead stores an explicit null when the parent value differs + // (see below), so null would SUPPRESS inheritance rather than restore it - making [AmbientValue(null)] + // a leaky, incorrect sentinel. Combined with this property being [Browsable(false)], runtime-oriented, + // and typically holding a non-CodeDOM-serializable object, the forced-serialization "bake-in" is a + // non-issue, so we deliberately leave it off rather than introduce a setter behavior change. public virtual object? DataContext { get => Properties.TryGetValue(s_dataContextProperty, out object? value) @@ -856,6 +871,162 @@ private bool ShouldSerializeDataContext() private void ResetDataContext() => Properties.RemoveValue(s_dataContextProperty); + public virtual partial VisualStylesMode VisualStylesMode + { + get => Properties.GetValueOrDefault( + s_visualStylesModeProperty, + VisualStylesMode.Inherit); + set + { + // Can't use the source generated enum validator here, since it cannot deal with the + // non-contiguous Inherit (-1) and Latest (short.MaxValue) members. + _ = value switch + { + VisualStylesMode.Inherit => value, + VisualStylesMode.Classic => value, + VisualStylesMode.Disabled => value, + VisualStylesMode.Net11 => value, + VisualStylesMode.Latest => value, + _ => throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(VisualStylesMode)) + }; + + VisualStylesMode oldEffectiveValue = EffectiveVisualStylesMode; + + // Inherit was requested explicitly, or the requested value matches the ambient (parent) value: + // drop any local override so the value is inherited again. + if (value == VisualStylesMode.Inherit + || (ParentInternal is { } parent && parent.ResolvedVisualStylesMode == value)) + { + Properties.RemoveValue(s_visualStylesModeProperty); + } + else + { + Properties.AddValue(s_visualStylesModeProperty, value); + } + + if (oldEffectiveValue != EffectiveVisualStylesMode) + { + RaiseVisualStylesModeChanged(oldEffectiveValue, EffectiveVisualStylesMode); + } + } + } + + private bool ShouldSerializeVisualStylesMode() + => Properties.TryGetValue( + s_visualStylesModeProperty, + out VisualStylesMode value) + && value != VisualStylesMode.Inherit; + + private void ResetVisualStylesMode() + => VisualStylesMode = VisualStylesMode.Inherit; + + private VisualStylesMode ResolvedVisualStylesMode + { + get + { + VisualStylesMode value = VisualStylesMode; + + return value == VisualStylesMode.Inherit + ? ParentInternal?.ResolvedVisualStylesMode ?? DefaultVisualStylesMode + : value; + } + } + + /// + /// Gets the renderer-authoritative that controls must honor when deciding + /// or paint behavior, after applying the High Contrast and disabled clamps. + /// + /// + /// when visual styles are explicitly disabled; + /// when Windows High Contrast is active (so custom non-client + /// painting, which does not honor the High Contrast palette, is bypassed); otherwise the resolved ambient + /// . + /// + /// + /// + /// In-box controls MUST read this property, not the raw , whenever they + /// decide their or paint behavior. The raw stays + /// pure so that before/after comparisons across a High Contrast transition remain meaningful; the High + /// Contrast clamp lives here instead. Override , rather than this + /// property, to customize a control's renderer default. + /// + /// + /// Windows High Contrast can be toggled while the application is running, so this value is not stable + /// across such a transition and must not be cached. Affected controls rebuild their handles on the + /// transition (see ), which re-reads this value. + /// + /// + protected VisualStylesMode EffectiveVisualStylesMode + => GetEffectiveVisualStylesMode(IsHighContrast); + + private VisualStylesMode GetEffectiveVisualStylesMode(bool highContrast) + { + VisualStylesMode mode = ResolvedVisualStylesMode; + + return mode is VisualStylesMode.Disabled + ? VisualStylesMode.Disabled + : IsHighContrast + ? VisualStylesMode.Classic + : mode; + } + + /// + /// Gets a value indicating whether the High Contrast renderer clamp is active. + /// + /// + /// + /// This internal seam keeps visual-styles transition tests independent of the operating system setting. + /// + /// + internal virtual bool IsHighContrast => SystemInformation.HighContrast; + + /// + /// Describes the work a control requires when its effective changes. + /// + protected enum VisualStylesModeChangeImpact + { + /// + /// No rendering or layout work is required. + /// + None, + + /// + /// Only client-area repainting is required. + /// + Repaint, + + /// + /// The non-client frame must be refreshed, but layout metrics are unchanged. + /// + NonClientUpdate, + + /// + /// Preferred-size or layout metrics changed and layout must be refreshed. + /// + Metrics, + + /// + /// The change cannot be applied to the existing native handle and requires the handle to be + /// recreated (for example, when native-window state such as the client area or a per-handle + /// baseline was established for a specific renderer). Preferred-size and layout metrics are + /// also refreshed. + /// + Recreate, + } + + /// + /// Gets the default for the control, which is ambient to + /// . + /// + /// The default visual styles mode for the control. + /// + /// + /// Derived controls can override this property to pin themselves to a specific renderer version when their + /// rendering or layout depends on it, independent of the application-wide default. + /// + /// + protected virtual VisualStylesMode DefaultVisualStylesMode => Application.DefaultVisualStylesMode; + /// /// The background color of this control. This is an ambient property and /// will always return a non-null value. @@ -3739,6 +3910,19 @@ public event EventHandler? DataContextChanged remove => Events.RemoveHandler(s_dataContextEvent, value); } + /// + /// Occurs when the value of the property changes. + /// + [SRCategory(nameof(SR.CatAppearance))] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Advanced)] + [SRDescription(nameof(SR.ControlVisualStylesModeChangedDescr))] + public event EventHandler? VisualStylesModeChanged + { + add => Events.AddHandler(s_visualStylesModeChangedEvent, value); + remove => Events.RemoveHandler(s_visualStylesModeChangedEvent, value); + } + [SRCategory(nameof(SR.CatDragDrop))] [SRDescription(nameof(SR.ControlOnDragDropDescr))] public event DragEventHandler? DragDrop @@ -4246,6 +4430,7 @@ internal virtual void AssignParent(Control? value) RightToLeft oldRtl = RightToLeft; bool oldEnabled = Enabled; bool oldVisible = Visible; + VisualStylesMode oldEffectiveVisualStylesMode = EffectiveVisualStylesMode; // Update the parent _parent = value; @@ -4291,6 +4476,11 @@ internal virtual void AssignParent(Control? value) OnRightToLeftChanged(EventArgs.Empty); } + if (oldEffectiveVisualStylesMode != EffectiveVisualStylesMode) + { + RaiseVisualStylesModeChanged(oldEffectiveVisualStylesMode, EffectiveVisualStylesMode); + } + if (!Properties.ContainsKey(s_bindingManagerProperty) && Created) { // We do not want to call our parent's BindingContext property here. @@ -6829,6 +7019,148 @@ protected virtual void OnDataContextChanged(EventArgs e) } } + /// + /// Raises the event and applies the required rendering, + /// non-client, and layout updates. + /// + /// An that contains the event data. + /// + /// + /// Inheriting classes should call . + /// so that the control's + /// is honored and ambient child controls are notified. + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnVisualStylesModeChanged(EventArgs e) + { + if (GetAnyDisposingInHierarchy()) + { + return; + } + + VisualStylesModeChangeEventArgs? transition = e as VisualStylesModeChangeEventArgs; + VisualStylesModeChangeImpact impact = transition is null + ? VisualStylesModeChangeImpact.Repaint + : GetVisualStylesModeChangeImpact( + transition.OldEffectiveVisualStylesMode, + transition.NewEffectiveVisualStylesMode); + + switch (impact) + { + case VisualStylesModeChangeImpact.None: + break; + case VisualStylesModeChangeImpact.Repaint: + Invalidate(); + break; + case VisualStylesModeChangeImpact.NonClientUpdate: + UpdateStyles(); + RefreshVisualStylesModeNonClientArea(); + Invalidate(); + break; + case VisualStylesModeChangeImpact.Metrics: + CommonProperties.xClearPreferredSizeCache(this); + transition!.RequestLayout(this, this); + transition.RequestAncestorLayouts(ParentInternal, this); + UpdateStyles(); + RefreshVisualStylesModeNonClientArea(); + Invalidate(); + break; + case VisualStylesModeChangeImpact.Recreate: + CommonProperties.xClearPreferredSizeCache(this); + + // The renderer transition established native-window state that cannot be unwound in + // place (for example, a modified client area or per-handle baseline), so rebuild the + // handle. RecreateHandle re-runs handle creation and repaints; when no handle exists + // yet the pending layout below still applies the new metrics. + if (IsHandleCreated) + { + RecreateHandle(); + } + + transition!.RequestLayout(this, this); + transition.RequestAncestorLayouts(ParentInternal, this); + break; + default: + Debug.Fail($"Unexpected {nameof(VisualStylesModeChangeImpact)}: {impact}"); + break; + } + + if (Events[s_visualStylesModeChangedEvent] is EventHandler eventHandler) + { + eventHandler(this, transition is null ? e : EventArgs.Empty); + } + + if (ChildControls is { } children + && (transition is null || transition.IsCurrent)) + { + for (int i = 0; i < children.Count && (transition is null || transition.IsCurrent); i++) + { + children[i].OnParentVisualStylesModeChanged(e); + } + } + } + + /// + /// Determines the work this control requires when its effective changes. + /// + /// The renderer-authoritative mode before the change. + /// The renderer-authoritative mode after the change. + /// + /// A value that describes the required change processing. + /// + /// + /// + /// Implementations can consider stable control state such as Multiline or BorderStyle. The + /// result must remain stable for the duration of a single change notification. + /// + /// + protected virtual VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + => VisualStylesModeChangeImpact.Repaint; + + private void RaiseVisualStylesModeChanged( + VisualStylesMode oldEffectiveVisualStylesMode, + VisualStylesMode newEffectiveVisualStylesMode) + { + int changeVersion = Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0) + 1; + Properties.AddValue(s_visualStylesModeChangeVersionProperty, changeVersion); + + VisualStylesModeChangeEventArgs e = new( + this, + changeVersion, + oldEffectiveVisualStylesMode, + newEffectiveVisualStylesMode); + + try + { + OnVisualStylesModeChanged(e); + } + finally + { + e.PerformLayouts(); + } + } + + private void RefreshVisualStylesModeNonClientArea() + { + if (!IsHandleCreated) + { + return; + } + + PInvoke.SetWindowPos( + this, + HWND.HWND_TOP, + 0, 0, 0, 0, + SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED + | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOMOVE + | SET_WINDOW_POS_FLAGS.SWP_NOSIZE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + } + [EditorBrowsable(EditorBrowsableState.Advanced)] protected virtual void OnDockChanged(EventArgs e) { @@ -7042,6 +7374,158 @@ protected virtual void OnParentDataContextChanged(EventArgs e) OnDataContextChanged(e); } + /// + /// Occurs when the property of the parent of this control changes. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnParentVisualStylesModeChanged(EventArgs e) + { + if (Properties.ContainsKey(s_visualStylesModeProperty)) + { + if (Properties.GetValueOrDefault(s_visualStylesModeProperty) + == ParentInternal?.ResolvedVisualStylesMode) + { + // Same as the parent value, make it ambient again by removing it. + Properties.RemoveValue(s_visualStylesModeProperty); + } + + // A local value isolates this subtree from parent changes. If the local value matched the + // parent's new value, removing it preserves the effective value while making it ambient again. + return; + } + + if (e is VisualStylesModeChangeEventArgs transition + && (!transition.IsCurrent + || ParentInternal?.EffectiveVisualStylesMode != transition.NewEffectiveVisualStylesMode)) + { + return; + } + + // In every other case we're going to raise the event. + OnVisualStylesModeChanged(e); + } + + /// + /// Carries an effective visual-styles transition through the existing event-argument virtual shape. + /// + /// + /// + /// The instance is private and immutable for mode data, so a nested change cannot overwrite the transition + /// observed by its outer notification. Layout requests are accumulated until the outer transition completes. + /// + /// + private sealed class VisualStylesModeChangeEventArgs : EventArgs + { + private List? _layoutRequests; + private bool _layoutsPerformed; + + public VisualStylesModeChangeEventArgs( + Control source, + int sourceChangeVersion, + VisualStylesMode oldEffectiveVisualStylesMode, + VisualStylesMode newEffectiveVisualStylesMode) + { + Source = source; + SourceChangeVersion = sourceChangeVersion; + OldEffectiveVisualStylesMode = oldEffectiveVisualStylesMode; + NewEffectiveVisualStylesMode = newEffectiveVisualStylesMode; + } + + public VisualStylesMode NewEffectiveVisualStylesMode { get; } + + public VisualStylesMode OldEffectiveVisualStylesMode { get; } + + private Control Source { get; } + + private int SourceChangeVersion { get; } + + public bool IsCurrent + => Source.Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0) == SourceChangeVersion; + + public void PerformLayouts() + { + if (_layoutsPerformed || _layoutRequests is null) + { + return; + } + + _layoutsPerformed = true; + + for (int i = _layoutRequests.Count - 1; i >= 0; i--) + { + _layoutRequests[i].Transaction.Dispose(); + } + + _layoutRequests.Sort( + static (left, right) => GetLayoutDepth(right.Target).CompareTo(GetLayoutDepth(left.Target))); + + foreach (VisualStylesModeLayoutRequest request in _layoutRequests) + { + LayoutTransaction.DoLayout(request.Target, request.Cause, PropertyNames.VisualStylesMode); + } + } + + public void RequestLayout(Control? target, Control cause) + { + if (target is null) + { + return; + } + + _layoutRequests ??= []; + + foreach (VisualStylesModeLayoutRequest request in _layoutRequests) + { + if (ReferenceEquals(request.Target, target)) + { + return; + } + } + + _layoutRequests.Add(new VisualStylesModeLayoutRequest(target, cause)); + } + + public void RequestAncestorLayouts(Control? target, Control cause) + { + for (Control? current = target; current is not null; current = current.ParentInternal) + { + RequestLayout(current, cause); + } + } + + private static int GetLayoutDepth(Control control) + { + int depth = 0; + + for (Control? parent = control.ParentInternal; parent is not null; parent = parent.ParentInternal) + { + depth++; + } + + return depth; + } + } + + /// + /// Defers one container layout request until all controls in a visual-styles transition have updated. + /// + private sealed class VisualStylesModeLayoutRequest + { + public VisualStylesModeLayoutRequest(Control target, Control cause) + { + Target = target; + Cause = cause; + Transaction = new LayoutTransaction(target, cause, PropertyNames.VisualStylesMode, resumeLayout: false); + } + + public Control Cause { get; } + + public Control Target { get; } + + public LayoutTransaction Transaction { get; } + } + [EditorBrowsable(EditorBrowsableState.Advanced)] protected virtual void OnParentEnabledChanged(EventArgs e) { @@ -12651,6 +13135,7 @@ protected virtual void WndProc(ref Message m) break; + case PInvokeCore.WM_DWMCOLORIZATIONCOLORCHANGED: case PInvokeCore.WM_SYSCOLORCHANGE: if (GetExtendedState(ExtendedStates.InterestedInUserPreferenceChanged) && GetTopLevel()) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs index fb144cac774..ae195492933 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Appearance.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. namespace System.Windows.Forms; @@ -17,4 +17,19 @@ public enum Appearance /// The appearance of a Windows button. /// Button = 1, + + /// + /// The appearance of a modern UI toggle switch. + /// + /// + /// + /// This value has no effect when is set to + /// or . + /// + /// + /// For later visual styles versions, each control determines whether and how it supports this value. + /// Setting it does not require every control or every control state to render as a toggle switch. + /// + /// + ToggleSwitch = 2 } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs index e76dd755f29..6f514d31bfa 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/Button.cs @@ -148,51 +148,23 @@ public virtual DialogResult DialogResult } } - /// - /// Defines, whether the control is owner-drawn. Based on this, - /// the UserPaint flags get set, which in turn makes it later - /// a Win32 controls, which we wrap (OwnerDraw == false) or not, and then - /// we draw ourselves. If the user wants to opt out of DarkMode, we can no - /// longer force (wrapping) System-Painting for FlatStyle.Standard, and we - /// need this then also here and now, before CreateParams is called. - /// - private protected override bool OwnerDraw - { - get - { - if (Application.IsDarkModeEnabled - - // The SystemRenderer cannot render images. So, we flip to our - // own DarkMode renderer, if we need to render images, except if... - && Image is null - // ...or a BackgroundImage, except if... - && BackgroundImage is null - // ...the user wants to opt out of implicit DarkMode rendering. - && DarkModeRequestState is true - - // And all of this only counts for FlatStyle.Standard. For the - // rest, we're using specific renderers anyway, which check - // themselves on demand, if they need to apply Light- or DarkMode. - && FlatStyle == FlatStyle.Standard) - { - return false; - } - - return base.OwnerDraw; - } - } - internal override bool SupportsUiaProviders => true; /// /// Raises the event. /// - protected override void OnMouseEnter(EventArgs e) => base.OnMouseEnter(e); + protected override void OnMouseEnter(EventArgs e) + { + base.OnMouseEnter(e); + } /// /// Raises the event. /// - protected override void OnMouseLeave(EventArgs e) => base.OnMouseLeave(e); + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + } /// [Browsable(false)] diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.FlatStyle.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.FlatStyle.Docs.cs new file mode 100644 index 00000000000..e3650ef178a --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.FlatStyle.Docs.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class ButtonBase +{ + /// + /// Gets or sets the flat-style appearance of the button control. + /// + /// + /// + /// In an effective .NET 11-or-later visual-styles mode, , + /// , and select framework-defined modern + /// renderers. Their colors and metrics derive from system state, including dark mode, accent color, + /// text scale, and DPI. + /// + /// + /// requests the native control and is not modernized. A two-state + /// with is the exception: its appearance + /// requires owner drawing and therefore takes precedence over System. Modern chrome can increase preferred + /// size, so adaptive layout containers are recommended. See the + /// + /// .NET 11 VisualStyles layout guidance. + /// + /// + [SRCategory(nameof(SR.CatAppearance))] + [DefaultValue(FlatStyle.Standard)] + [Localizable(true)] + [SRDescription(nameof(SR.ButtonFlatStyleDescr))] + public partial FlatStyle FlatStyle { get; set; } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs index 26c3292e175..e5381d0c57a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonBase.cs @@ -6,6 +6,7 @@ using System.Drawing.Design; using System.Windows.Forms.ButtonInternal; using System.Windows.Forms.Layout; +using System.Windows.Forms.Rendering.Button; using Windows.Win32.System.Variant; using Windows.Win32.UI.Accessibility; @@ -46,6 +47,8 @@ public abstract partial class ButtonBase : Control, ICommandBindingTargetProvide private ButtonBaseAdapter? _adapter; private FlatStyle _cachedAdapterType; + private ButtonBackColorAnimator? _backColorAnimator; + private AnimatedPopupButtonRenderer? _popupKeyCapRenderer; // Backing fields for the infrastructure to make ToolStripItem bindable and introduce (bindable) ICommand. private Input.ICommand? _command; @@ -337,52 +340,7 @@ protected internal bool IsDefault } } - /// - /// Gets or sets the flat style appearance of the button control. - /// - /// - /// - /// The property determines how the button is rendered. The following values are supported: - /// - /// - /// - /// - /// - /// The default style. The button is not wrapping the system button. It is rendered using the StandardButton adapter. - /// VisualStyleRenderer from the OS is used for certain parts, which may have issues in high-resolution scenarios. - /// Dark mode works to some extent, but improvements are needed. - /// - /// - /// - /// - /// - /// The button is fully owner-drawn. No rendering is delegated to the OS, not even VisualStyleRenderer. - /// This style works well in dark mode and is fully controlled by the application. - /// 3D effects are expected but may not be rendered; consider revisiting for meaningful styling. - /// - /// - /// - /// - /// - /// The button is fully owner-drawn, with no OS calls or VisualStyleRenderer usage. - /// This fits modern design language and works well in dark mode. - /// - /// - /// - /// - /// - /// The button wraps the system button and is not owner-drawn. - /// No OnPaint, OnPaintBackground, or adapter is involved. - /// In dark mode, this style is used as a fallback for Standard-style buttons. - /// - /// - /// - /// - [SRCategory(nameof(SR.CatAppearance))] - [DefaultValue(FlatStyle.Standard)] - [Localizable(true)] - [SRDescription(nameof(SR.ButtonFlatStyleDescr))] - public FlatStyle FlatStyle + public partial FlatStyle FlatStyle { get => _flatStyle; set @@ -838,6 +796,10 @@ protected override void Dispose(bool disposing) _imageList?.Disposed -= DetachImageList; _textToolTip?.Dispose(); _textToolTip = null; + _backColorAnimator?.Dispose(); + _backColorAnimator = null; + _popupKeyCapRenderer?.Dispose(); + _popupKeyCapRenderer = null; } base.Dispose(disposing); @@ -1067,6 +1029,29 @@ internal virtual ButtonBaseAdapter CreateStandardAdapter() return null; } + internal ButtonBackColorAnimator BackColorAnimator + => _backColorAnimator ??= new(this); + + private AnimatedPopupButtonRenderer PopupKeyCapRenderer + => _popupKeyCapRenderer ??= new(this); + + private bool IsPopupKeyCapAppearance + => FlatStyle == FlatStyle.Popup + && EffectiveVisualStylesMode >= VisualStylesMode.Net11 + && this is Button + or CheckBox { Appearance: Appearance.Button } + or RadioButton { Appearance: Appearance.Button }; + + private bool IsPopupKeyCapSelected + => this is CheckBox { Checked: true } + or RadioButton { Checked: true }; + + private protected void ResetAdapter() + { + _adapter = null; + _cachedAdapterType = (FlatStyle)(-1); + } + internal virtual StringFormat CreateStringFormat() { if (Adapter is null) @@ -1250,12 +1235,70 @@ protected override void OnPaint(PaintEventArgs pevent) Animate(); ImageAnimator.UpdateFrames(Image); - PaintControl(pevent); + if (IsPopupKeyCapAppearance) + { + PopupKeyCapRenderer.SetInteractionState( + hovered: MouseIsOver, + pressed: MouseIsDown, + selected: IsPopupKeyCapSelected); + + using GraphicsStateScope scope = new(pevent.Graphics); + PopupKeyCapRenderer.RenderControl(pevent.Graphics); + } + else + { + PaintControl(pevent); + } } base.OnPaint(pevent); } + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + using (LayoutTransaction.CreateTransactionIf( + AutoSize, + ParentInternal, + this, + PropertyNames.VisualStylesMode)) + { + base.OnVisualStylesModeChanged(e); + + // Renderer padding can change with VisualStylesMode, so recreate the adapter before layout + // recomputes the preferred size. + ResetAdapter(); + + _backColorAnimator?.Dispose(); + _backColorAnimator = null; + _popupKeyCapRenderer?.Dispose(); + _popupKeyCapRenderer = null; + + if (IsHandleCreated) + { + Invalidate(); + } + } + } + + /// + protected override void OnSystemColorsChanged(EventArgs e) + { + ResetAdapter(); + _backColorAnimator?.Dispose(); + _backColorAnimator = null; + _popupKeyCapRenderer?.Dispose(); + _popupKeyCapRenderer = null; + base.OnSystemColorsChanged(e); + } + + /// + /// Exposes the (otherwise private protected) + /// to the owner-drawn button adapters in the ButtonInternal namespace, so that renderer selection + /// honors the Windows High Contrast clamp just like the control's own paint and . + /// + internal VisualStylesMode EffectiveVisualStylesModeInternal => EffectiveVisualStylesMode; + protected override void OnParentChanged(EventArgs e) { base.OnParentChanged(e); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.LayoutOptions.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.LayoutOptions.cs index d8c7b6b6a8d..fc275d1919d 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.LayoutOptions.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.LayoutOptions.cs @@ -17,6 +17,12 @@ internal partial class LayoutOptions private bool _disableWordWrapping; + internal bool DisableWordWrapping + { + get => _disableWordWrapping; + set => _disableWordWrapping = value; + } + // If this is changed to a property callers will need to be updated // as they modify fields in the Rectangle. public Rectangle Client; @@ -42,6 +48,8 @@ internal partial class LayoutOptions public bool LayoutRTL { get; set; } public bool VerticalText { get; set; } public bool UseCompatibleTextRendering { get; set; } + public bool ClipImagesToClient { get; set; } + public bool EnsureImagePreferredSizeInset { get; set; } /// /// .NET Framework 1.0/1.1 compatibility @@ -217,6 +225,7 @@ internal Size GetPreferredSizeCore(Size proposedSize) // will happen but the layout would not be adjusted to allow text wrapping. If someone has a // carriage return in the text we'll honor that for preferred size, but we won't wrap based // on constraints. + bool disableWordWrapping = _disableWordWrapping; try { _disableWordWrapping = true; @@ -224,12 +233,18 @@ internal Size GetPreferredSizeCore(Size proposedSize) } finally { - _disableWordWrapping = false; + _disableWordWrapping = disableWordWrapping; } } // Combine pieces to get final preferred size. Size requiredSize = Compose(checkSize, ImageSize, textSize); + if (EnsureImagePreferredSizeInset && ImageSize != Size.Empty) + { + Size imageRequiredSize = Compose(checkSize, requiredImageSize, Size.Empty); + requiredSize = LayoutUtils.UnionSizes(requiredSize, imageRequiredSize); + } + requiredSize += bordersAndPadding; return requiredSize; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.cs index 39b5b0ab852..6ff3638a230 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonBaseAdapter.cs @@ -4,6 +4,7 @@ using System.Drawing; using System.Runtime.CompilerServices; using System.Windows.Forms.Layout; +using System.Windows.Forms.Rendering.Button; namespace System.Windows.Forms.ButtonInternal; @@ -59,8 +60,57 @@ internal virtual Size GetPreferredSizeCore(Size proposedSize) return options.GetPreferredSizeCore(proposedSize); } + protected static Size GetPopupPreferredSizeCore(LayoutOptions layout, Size proposedSize) + { + layout.GrowBorderBy1PxWhenDefault = false; + layout.MaxFocus = false; + layout.BorderSize = 0; + layout.PaddingSize = 1; + + return layout.GetPreferredSizeCore(proposedSize); + } + + protected static Size GetModernPopupPreferredSizeCore( + LayoutOptions layout, + Size proposedSize, + int deviceDpi, + int borderSize) + => GetModernPreferredSizeCore( + layout, + proposedSize, + PopupButtonKeyCapRenderer.GetPreferredSizeChrome(deviceDpi, borderSize)); + + protected static Size GetModernPreferredSizeCore( + LayoutOptions layout, + Size proposedSize, + Size chromeSize) + { + layout.GrowBorderBy1PxWhenDefault = false; + layout.MaxFocus = false; + layout.BorderSize = 0; + layout.PaddingSize = 0; + + Size contentConstraint = new( + proposedSize.Width > 0 ? Math.Max(1, proposedSize.Width - chromeSize.Width) : proposedSize.Width, + proposedSize.Height > 0 ? Math.Max(1, proposedSize.Height - chromeSize.Height) : proposedSize.Height); + + return layout.GetPreferredSizeCore(contentConstraint) + chromeSize; + } + protected abstract LayoutOptions Layout(PaintEventArgs e); + internal LayoutData GetLayoutData(Rectangle contentBounds) + { + LayoutOptions options = CommonLayout(); + options.Client = LayoutUtils.DeflateRect(contentBounds, Control.Padding); + options.GrowBorderBy1PxWhenDefault = false; + options.BorderSize = 0; + options.PaddingSize = 0; + options.MaxFocus = false; + + return options.Layout(); + } + internal abstract void PaintUp(PaintEventArgs e, CheckState state); internal abstract void PaintDown(PaintEventArgs e, CheckState state); @@ -379,18 +429,24 @@ internal virtual void DrawImageCore(Graphics graphics, Image image, Rectangle im if (!layout.Options.DotNetOneButtonCompat) { - Rectangle bounds = new( - ButtonBorderSize, - ButtonBorderSize, - Control.Width - (2 * ButtonBorderSize), - Control.Height - (2 * ButtonBorderSize)); + Rectangle bounds = layout.Options.ClipImagesToClient + ? layout.Client + : new Rectangle( + ButtonBorderSize, + ButtonBorderSize, + Control.Width - (2 * ButtonBorderSize), + Control.Height - (2 * ButtonBorderSize)); Region newClip = oldClip.Clone(); newClip.Intersect(bounds); - // If we don't do this, DrawImageUnscaled will happily draw the entire image, even though imageBounds - // is smaller than the image size. - newClip.Intersect(imageBounds); + // DrawImageUnscaled ignores a reduced destination size, so clip only when layout had to squeeze + // the image. Avoiding an unnecessary exact-edge clip preserves the outer image pixels. + if (imageBounds.Width < image.Width || imageBounds.Height < image.Height) + { + newClip.Intersect(imageBounds); + } + graphics.Clip = newClip; } else @@ -408,6 +464,10 @@ internal virtual void DrawImageCore(Graphics graphics, Image image, Rectangle im // Need to specify width and height ControlPaint.DrawImageDisabled(graphics, image, imageBounds, unscaledImage: true); } + else if (!layout.Options.DotNetOneButtonCompat) + { + graphics.DrawImageUnscaled(image, imageBounds.Location); + } else { graphics.DrawImage(image, imageBounds.X, imageBounds.Y, image.Width, image.Height); @@ -543,25 +603,40 @@ internal void PaintField( } /// - /// Draws the button's image. + /// Draws the button's background image. /// - internal void PaintImage(PaintEventArgs e, LayoutData layout) + internal void PaintBackgroundImage( + PaintEventArgs e, + Rectangle? clipRectangle = null) { - if (Application.IsDarkModeEnabled && Control.DarkModeRequestState is true && Control.BackgroundImage is not null) + if (Control.BackgroundImage is null + || DisplayInformation.HighContrast) + { + return; + } + + Rectangle imageClip = clipRectangle ?? Control.ClientRectangle; + if (clipRectangle is null) { - Rectangle bounds = Control.ClientRectangle; - bounds.Inflate(-ButtonBorderSize, -ButtonBorderSize); - ControlPaint.DrawBackgroundImage( - e.GraphicsInternal, - Control.BackgroundImage, - Color.Transparent, - Control.BackgroundImageLayout, - Control.ClientRectangle, - bounds, - Control.DisplayRectangle.Location, - Control.RightToLeft); + imageClip.Inflate(-ButtonBorderSize, -ButtonBorderSize); } + ControlPaint.DrawBackgroundImage( + e.GraphicsInternal, + Control.BackgroundImage, + Color.Transparent, + Control.BackgroundImageLayout, + Control.ClientRectangle, + imageClip, + Control.DisplayRectangle.Location, + Control.RightToLeft); + } + + /// + /// Draws the button's foreground image. + /// + internal void PaintImage(PaintEventArgs e, LayoutData layout) + { if (Control.Image is not null) { // Setup new clip region & draw diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonPopupAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonPopupAdapter.cs index 31fe458ea77..ac7aa638a75 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonPopupAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/ButtonPopupAdapter.cs @@ -145,6 +145,9 @@ protected override LayoutOptions Layout(PaintEventArgs e) return layout; } + internal override Size GetPreferredSizeCore(Size proposedSize) + => GetPopupPreferredSizeCore(PaintPopupLayout(up: false, 0), proposedSize); + internal static LayoutOptions PaintPopupLayout( bool up, int paintedBorder, diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxFlatAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxFlatAdapter.cs index c14017ce380..441bca05a33 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxFlatAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxFlatAdapter.cs @@ -84,9 +84,7 @@ private void PaintFlatWorker(PaintEventArgs e, Color checkColor, Color checkBack PaintField(e, layout, colors, checkColor, drawFocus: true); } - private new ButtonFlatAdapter ButtonAdapter => (ButtonFlatAdapter)base.ButtonAdapter; - - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonFlatAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateFlatAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxModernAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxModernAdapter.cs new file mode 100644 index 00000000000..11e6d11d5fd --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxModernAdapter.cs @@ -0,0 +1,127 @@ +// 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.ButtonInternal; + +/// +/// Paints a normal-appearance with the modern animated glyph renderer. +/// +internal sealed class CheckBoxModernAdapter : CheckBoxBaseAdapter +{ + private readonly FlatStyle _flatStyle; + + internal CheckBoxModernAdapter(CheckBox control, FlatStyle flatStyle) : base(control) + { + _flatStyle = flatStyle; + } + + internal override void PaintUp(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintUp(e, Control.CheckState); + return; + } + + PaintCore(e); + } + + internal override void PaintDown(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintDown(e, Control.CheckState); + return; + } + + PaintCore(e); + } + + internal override void PaintOver(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintOver(e, Control.CheckState); + return; + } + + PaintCore(e); + } + + protected override ButtonBaseAdapter CreateButtonAdapter() + => _flatStyle switch + { + FlatStyle.Flat => DarkModeAdapterFactory.CreateFlatAdapter(Control), + FlatStyle.Popup => DarkModeAdapterFactory.CreatePopupAdapter(Control), + _ => DarkModeAdapterFactory.CreateStandardAdapter(Control) + }; + + protected override LayoutOptions Layout(PaintEventArgs e) + { + LayoutOptions layout = CommonLayout(); + layout.CheckPaddingSize = Control.LogicalToDeviceUnits(2); + layout.CheckSize = Math.Max( + Control.LogicalToDeviceUnits(13), + (int)(Control.Font.Height * 0.9f)); + + return layout; + } + + internal override LayoutOptions CommonLayout() + { + LayoutOptions layout = base.CommonLayout(); + layout.ShadowedText = false; + + return layout; + } + + private void PaintCore(PaintEventArgs e) + { + Graphics graphics = e.GraphicsInternal; + ParentBackgroundRenderer.Paint( + Control, + graphics, + Control.ClientRectangle, + Control.BackColor); + + LayoutData layout = Layout(e).Layout(); + AdjustFocusRectangle(layout); + PaintBackgroundImage(e); + + Color? customOnColor = Control.ShouldSerializeBackColor() + ? Control.BackColor + : null; + + Color? customBorderColor = Control.FlatAppearance.BorderColor.IsEmpty + ? null + : Control.FlatAppearance.BorderColor; + + Control.CheckGlyphRenderer.NotifyCheckStateChanged(Control.CheckState); + Control.CheckGlyphRenderer.DrawGlyph( + graphics, + layout.CheckBounds, + _flatStyle, + Control.Enabled, + Control.MouseIsOver, + Control.Focused && Control.ShowFocusCues, + customOnColor, + customBorderColor); + + PaintImage(e, layout); + + Color preferredTextColor = Control.ShouldSerializeForeColor() + ? Control.ForeColor + : Application.IsDarkModeEnabled + ? Color.FromArgb(0xF0, 0xF0, 0xF0) + : SystemColors.WindowText; + Color textColor = Control.Enabled + ? preferredTextColor + : ModernControlColorMath.GetDisabledTextColor( + preferredTextColor, + Control.Parent?.BackColor ?? Control.BackColor); + + PaintField(e, layout, PaintRender(e).Calculate(), textColor, drawFocus: true); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxPopupAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxPopupAdapter.cs index c45c832d02c..0513250b873 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxPopupAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxPopupAdapter.cs @@ -16,7 +16,7 @@ internal override void PaintUp(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintUp(e, Control.CheckState); } else @@ -51,7 +51,7 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintOver(e, Control.CheckState); } else @@ -94,7 +94,7 @@ internal override void PaintDown(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintDown(e, Control.CheckState); } else @@ -115,7 +115,7 @@ internal override void PaintDown(PaintEventArgs e, CheckState state) } } - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonPopupAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreatePopupAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs index 3a7f7d3b0c8..9768de8f69a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/CheckBoxStandardAdapter.cs @@ -105,9 +105,7 @@ internal override Size GetPreferredSizeCore(Size proposedSize) } } - private new ButtonStandardAdapter ButtonAdapter => (ButtonStandardAdapter)base.ButtonAdapter; - - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonStandardAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateStandardAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonBackColorAnimator.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonBackColorAnimator.cs new file mode 100644 index 00000000000..0a325246403 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonBackColorAnimator.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Windows.Forms.Rendering.Animation; + +namespace System.Windows.Forms.ButtonInternal; + +/// +/// Animates a button's background color between interaction states. +/// +internal sealed class ButtonBackColorAnimator : AnimatedControlRenderer +{ + private const int AnimationDuration = 350; + + private Color _fromColor; + private Color _toColor; + private bool _hasColor; + + public ButtonBackColorAnimator(Control control) : base(control) + { + } + + public Color CurrentColor { get; private set; } + + public void AnimateTo(Color targetColor) + { + if (!_hasColor) + { + _hasColor = true; + CurrentColor = targetColor; + _toColor = targetColor; + return; + } + + if (targetColor == _toColor) + { + return; + } + + _fromColor = CurrentColor; + _toColor = targetColor; + RestartAnimation(); + } + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + CurrentColor = Lerp(_fromColor, _toColor, animationProgress); + Invalidate(); + } + + public override void RenderControl(Graphics graphics) + { + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + AnimationProgress = 0; + return (AnimationDuration, AnimationCycle.Once); + } + + protected override void OnAnimationStopped() + { + } + + protected override void OnAnimationEnded() + { + StopAnimation(); + CurrentColor = _toColor; + AnimationProgress = 1; + Invalidate(); + } + + private static Color Lerp(Color from, Color to, float progress) + { + progress = Math.Clamp(progress, 0f, 1f); + + return Color.FromArgb( + LerpChannel(from.A, to.A, progress), + LerpChannel(from.R, to.R, progress), + LerpChannel(from.G, to.G, progress), + LerpChannel(from.B, to.B, progress)); + + static int LerpChannel(int from, int to, float progress) + => from + (int)((to - from) * progress); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs index a85dd3ed4cc..d02787f52cd 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeAdapter.cs @@ -2,36 +2,68 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Drawing; +using System.Windows.Forms.Rendering.Button; using System.Windows.Forms.VisualStyles; namespace System.Windows.Forms.ButtonInternal; internal class ButtonDarkModeAdapter : ButtonBaseAdapter { + private readonly bool _animateBackgroundColors; private readonly ButtonDarkModeRendererBase _buttonDarkModeRenderer; + private readonly bool _modern; internal ButtonDarkModeAdapter(ButtonBase control) : base(control) { + _modern = control.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11; + _animateBackgroundColors = _modern && !SystemInformation.HighContrast; + _buttonDarkModeRenderer = control.FlatStyle switch { - FlatStyle.Standard => new FlatButtonDarkModeRenderer(), - FlatStyle.Flat => new FlatButtonDarkModeRenderer(), - FlatStyle.Popup => new PopupButtonDarkModeRenderer(), + FlatStyle.Standard => _modern ? new ModernButtonDarkModeRenderer() : new FlatButtonDarkModeRenderer(), + FlatStyle.Flat => _modern ? new ModernFlatButtonRenderer() : new FlatButtonDarkModeRenderer(), + FlatStyle.Popup => _modern ? new ModernButtonDarkModeRenderer() : new PopupButtonDarkModeRenderer(), FlatStyle.System => new SystemButtonDarkModeRenderer(), _ => throw new ArgumentOutOfRangeException(nameof(control)) }; + + _buttonDarkModeRenderer.DeviceDpi = control.DeviceDpi; + _buttonDarkModeRenderer.FlatAppearance = control.FlatAppearance; } - private ButtonDarkModeRendererBase ButtonDarkModeRenderer => - _buttonDarkModeRenderer; + private ButtonDarkModeRendererBase ButtonDarkModeRenderer + { + get + { + _buttonDarkModeRenderer.DeviceDpi = Control.DeviceDpi; + return _buttonDarkModeRenderer; + } + } - private Color GetButtonTextColor(IDeviceContext deviceContext, PushButtonState state) + private Color GetButtonTextColor( + IDeviceContext deviceContext, + PushButtonState state, + Color backColor) { Color textColor; - if (Control.ForeColor != Forms.Control.DefaultForeColor) + if (_modern && !Control.Enabled) { - textColor = new ColorOptions(deviceContext, Control.ForeColor, Control.BackColor) + return ModernControlColorMath.GetDisabledTextColor( + Control.ForeColor, + backColor); + } + + bool useEffectiveForeColor = _modern + ? Control.ShouldSerializeForeColor() + : Control.ForeColor != Forms.Control.DefaultForeColor; + + if (useEffectiveForeColor) + { + textColor = Control.Enabled + && Control.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + ? Control.ForeColor + : new ColorOptions(deviceContext, Control.ForeColor, Control.BackColor) { Enabled = Control.Enabled }.Calculate().WindowText; @@ -43,7 +75,7 @@ private Color GetButtonTextColor(IDeviceContext deviceContext, PushButtonState s } else { - textColor = ButtonDarkModeRenderer.GetTextColor(state, Control.IsDefault); + textColor = ButtonDarkModeRenderer.GetTextColor(state, Control.IsDefault, backColor); } return textColor; @@ -55,7 +87,10 @@ private Color GetButtonBackColor(PushButtonState state) if (Control.BackColor != Forms.Control.DefaultBackColor) { - backColor = Control.BackColor; + backColor = ButtonDarkModeRenderer.GetBackgroundColor( + state, + Control.IsDefault, + Control.BackColor); if (IsHighContrastHighlighted()) { @@ -64,132 +99,94 @@ private Color GetButtonBackColor(PushButtonState state) } else { - backColor = ButtonDarkModeRenderer.GetBackgroundColor(state, Control.IsDefault); + backColor = ButtonDarkModeRenderer.GetBackgroundColor( + state, + Control.IsDefault, + customBaseColor: Color.Empty); + } + + if (_animateBackgroundColors) + { + Control.BackColorAnimator.AnimateTo(backColor); + backColor = Control.BackColorAnimator.CurrentColor; } return backColor; } internal override void PaintUp(PaintEventArgs e, CheckState state) - { - try - { - // Use GraphicsInternal for better performance (GDI+ best practice) - var g = e.GraphicsInternal; - var smoothingMode = g.SmoothingMode; - g.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias; - - LayoutData layout = CommonLayout().Layout(); - - PushButtonState pushButtonState = ToPushButtonState(state, Control.Enabled); - ButtonDarkModeRenderer.RenderButton( - g, - Control.ClientRectangle, - Control.FlatStyle, - pushButtonState, - Control.IsDefault, - Control.Focused, - Control.ShowFocusCues, - Control.Parent?.BackColor ?? Control.BackColor, - GetButtonBackColor(pushButtonState), - _ => PaintImage(e, layout), - () => PaintField( - e, - layout, - PaintDarkModeRender(e).Calculate(), - GetButtonTextColor(e, pushButtonState), - drawFocus: false) - ); - - g.SmoothingMode = smoothingMode; - } - catch (Exception) - { - // Handle exceptions gracefully, possibly logging them or showing a message - Debug.Assert(false, "Exception in PaintUp: Unable to render button in dark mode."); - } - } + => PaintCore(e, ToPushButtonState(state, Control.Enabled)); internal override void PaintDown(PaintEventArgs e, CheckState state) - { - try - { - // Use GraphicsInternal for better performance (GDI+ best practice) - var g = e.GraphicsInternal; - var smoothingMode = g.SmoothingMode; - g.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias; - - LayoutData layout = CommonLayout().Layout(); - ButtonDarkModeRenderer.RenderButton( - g, - Control.ClientRectangle, - Control.FlatStyle, - PushButtonState.Pressed, - Control.IsDefault, - Control.Focused, - Control.ShowFocusCues, - Control.Parent?.BackColor ?? Control.BackColor, - GetButtonBackColor(PushButtonState.Pressed), - _ => PaintImage(e, layout), - () => PaintField( - e, - layout, - PaintDarkModeRender(e).Calculate(), - GetButtonTextColor(e, PushButtonState.Pressed), - drawFocus: false) - ); - - g.SmoothingMode = smoothingMode; - } - catch (Exception) - { - // Handle exceptions gracefully, possibly logging them or showing a message - Debug.Assert(false, "Exception in PaintDown: Unable to render button in dark mode."); - } - } + => PaintCore(e, PushButtonState.Pressed); internal override void PaintOver(PaintEventArgs e, CheckState state) + => PaintCore(e, PushButtonState.Hot); + + private void PaintCore(PaintEventArgs e, PushButtonState state) { + var graphics = e.GraphicsInternal; + Drawing.Drawing2D.SmoothingMode smoothingMode = graphics.SmoothingMode; + try { - // Use GraphicsInternal for better performance (GDI+ best practice) - var g = e.GraphicsInternal; - var smoothingMode = g.SmoothingMode; - g.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias; - - LayoutData layout = CommonLayout().Layout(); + graphics.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias; + Color backColor = GetButtonBackColor(state); + Color parentBackColor = Control.Parent?.BackColor ?? Control.BackColor; + Color textBackColor = PopupButtonColorMath.Composite(backColor, parentBackColor); ButtonDarkModeRenderer.RenderButton( - g, + graphics, + Control, Control.ClientRectangle, Control.FlatStyle, - PushButtonState.Hot, + state, Control.IsDefault, Control.Focused, Control.ShowFocusCues, - Control.Parent?.BackColor ?? Control.BackColor, - GetButtonBackColor(PushButtonState.Hot), - _ => PaintImage(e, layout), - () => PaintField( - e, - layout, - PaintDarkModeRender(e).Calculate(), - GetButtonTextColor(e, PushButtonState.Hot), - drawFocus: false) - ); - - g.SmoothingMode = smoothingMode; + parentBackColor, + backColor, + contentBounds => + { + LayoutData layout = GetLayoutData(contentBounds); + PaintBackgroundImage(e, contentBounds); + PaintImage(e, layout); + PaintField( + e, + layout, + PaintDarkModeRender(e).Calculate(), + GetButtonTextColor(e, state, textBackColor), + drawFocus: false); + }); } - catch (Exception ex) + finally { - Debug.Assert(false, $"Exception in PaintOver: {ex.Message}"); + graphics.SmoothingMode = smoothingMode; } } protected override LayoutOptions Layout(PaintEventArgs e) => CommonLayout(); - private new LayoutOptions CommonLayout() + internal override Size GetPreferredSizeCore(Size proposedSize) + => Control.FlatStyle == FlatStyle.Popup && _modern + ? GetModernPopupPreferredSizeCore( + CommonLayout(), + proposedSize, + Control.DeviceDpi, + Control.FlatAppearance.BorderSize) + : _modern + ? GetModernPreferredSizeCore( + CommonLayout(), + proposedSize, + ButtonDarkModeRenderer.GetPreferredSizePadding().Size) + : base.GetPreferredSizeCore(proposedSize); + + internal override LayoutOptions CommonLayout() { LayoutOptions layout = base.CommonLayout(); + layout.DisableWordWrapping = Control.AutoSize; + layout.DotNetOneButtonCompat = !_modern; + layout.ClipImagesToClient = _modern; + layout.EnsureImagePreferredSizeInset = _modern; layout.FocusOddEvenFixup = false; layout.ShadowedText = false; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs index 9d655a618b0..ae744bc04fb 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ButtonDarkModeRendererBase.cs @@ -14,6 +14,80 @@ internal abstract partial class ButtonDarkModeRendererBase : IButtonRenderer // Define padding values for each renderer type private protected abstract Padding PaddingCore { get; } + private protected virtual bool UseModernStateDefaults => false; + + /// + /// Gets the padding that insets the button body from the control bounds for the given focus state. + /// + /// + /// when the focus ring will be drawn for this paint; otherwise . + /// + /// + /// + /// By default this returns regardless of focus. Renderers that reserve room for + /// an outer focus ring can override this to return a smaller padding when the ring is not drawn, letting + /// the button body grow into the space the ring and its gap would otherwise occupy. + /// + /// + private protected virtual Padding GetContentPadding(bool focusRingVisible) => PaddingCore; + + internal virtual Padding GetPreferredSizePadding() => PaddingCore; + + /// + /// The device DPI of the control being rendered. Set by the adapter before each paint so renderers + /// can DPI-scale their logical (96-DPI) constants. Defaults to 96 (100%). + /// + internal int DeviceDpi { get; set; } = 96; + + /// + /// Gets or sets the appearance settings for the button being rendered. + /// + internal FlatButtonAppearance? FlatAppearance { get; set; } + + /// + /// Scales a logical (96-DPI) value to the current . + /// + private protected int Scale(int logicalValue) => (int)Math.Round(logicalValue * (DeviceDpi / 96.0)); + + private protected int ScaleBorderThickness(int scaledThickness) + { + int borderSize = FlatAppearance?.BorderSize ?? 1; + if (borderSize == 0) + { + return 0; + } + + float dpiScale = Math.Max(1f, DeviceDpi / 96f); + float factor = 1f + ((borderSize - 1) / dpiScale); + + return Math.Max(1, (int)Math.Round(scaledThickness * factor)); + } + + private protected Color ResolveBorderColor(Color designColor) + => FlatAppearance is { BorderColor.IsEmpty: false } appearance + ? appearance.BorderColor + : designColor; + + private protected static Color DeriveHoverColor(Color baseColor) + => baseColor.GetBrightness() < 0.5f + ? ControlPaint.Light(baseColor, 0.15f) + : ControlPaint.Dark(baseColor, 0.05f); + + private protected static Color DerivePressedColor(Color baseColor) + => baseColor.GetBrightness() < 0.5f + ? ControlPaint.Light(baseColor, 0.30f) + : ControlPaint.Dark(baseColor, 0.12f); + + private protected static Color DeriveDisabledColor(Color baseColor) + { + int average = (baseColor.R + baseColor.G + baseColor.B) / 3; + + return Color.FromArgb( + ((average * 6) + (baseColor.R * 4)) / 10, + ((average * 6) + (baseColor.G * 4)) / 10, + ((average * 6) + (baseColor.B * 4)) / 10); + } + /// /// Clears the background with the parent's background color or the control's background color if no parent is available. /// @@ -26,10 +100,11 @@ private static void ClearBackground(Graphics graphics, Color parentBackgroundCol } /// - /// Renders a button with the specified properties and delegates for painting image and field. + /// Renders a button with the specified properties and a delegate for painting its content. /// public void RenderButton( Graphics graphics, + Control control, Rectangle bounds, FlatStyle flatStyle, PushButtonState state, @@ -38,21 +113,17 @@ public void RenderButton( bool showFocusCues, Color parentBackgroundColor, Color backColor, - Action paintImage, - Action paintField) + Action paintContent) { ArgumentNullException.ThrowIfNull(graphics); - ArgumentNullException.ThrowIfNull(paintImage); - ArgumentNullException.ThrowIfNull(paintField); + ArgumentNullException.ThrowIfNull(paintContent); // Scope the graphics state so all changes are reverted after rendering using (new GraphicsStateScope(graphics)) { - // Clear the background over the whole button area. - ClearBackground(graphics, parentBackgroundColor); - - // Use padding from ButtonDarkModeRenderer - Padding padding = PaddingCore; + // Use padding from the renderer. When the focus ring is not drawn, renderers may return a smaller + // padding so the button body expands into the space the ring and its gap would otherwise occupy. + Padding padding = GetContentPadding(focused && showFocusCues); Rectangle paddedBounds = new( x: bounds.X + padding.Left, @@ -60,13 +131,20 @@ public void RenderButton( width: bounds.Width - padding.Horizontal, height: bounds.Height - padding.Vertical); - // Draw button background and get content bounds - Rectangle contentBounds = DrawButtonBackground(graphics, paddedBounds, state, isDefault, backColor); + if (PaintParentBackground && paddedBounds.Width > 0 && paddedBounds.Height > 0) + { + ParentBackgroundRenderer.Paint(control, graphics, bounds, parentBackgroundColor); + } + else + { + // Rectangular renderers still need a complete background before painting their body. + ClearBackground(graphics, parentBackgroundColor); + } - // Paint image and field using the provided delegates - paintImage(contentBounds); + // Draw button background and get content bounds + Rectangle contentBounds = DrawButtonBackground(graphics, paddedBounds, state, isDefault, focused, backColor); - paintField(); + paintContent(contentBounds); if (focused && showFocusCues) { @@ -76,11 +154,54 @@ public void RenderButton( } } - public abstract Rectangle DrawButtonBackground(Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault, Color backColor); + private protected virtual bool PaintParentBackground => false; + + public abstract Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor); public abstract void DrawFocusIndicator(Graphics graphics, Rectangle contentBounds, bool isDefault); - public abstract Color GetTextColor(PushButtonState state, bool isDefault); + public abstract Color GetTextColor(PushButtonState state, bool isDefault, Color backColor); + + public Color GetBackgroundColor(PushButtonState state, bool isDefault, Color customBaseColor) + { + if (state == PushButtonState.Hot + && FlatAppearance is { MouseOverBackColorCore.IsEmpty: false } hoverAppearance) + { + return hoverAppearance.MouseOverBackColorCore; + } + + if (state == PushButtonState.Pressed + && FlatAppearance is { MouseDownBackColorCore.IsEmpty: false } pressedAppearance) + { + return pressedAppearance.MouseDownBackColorCore; + } + + if (UseModernStateDefaults + && FlatAppearance is not null + && state is PushButtonState.Hot or PushButtonState.Pressed) + { + return ModernButtonColorMath.GetStateColor(this, state, isDefault, customBaseColor); + } + + if (!customBaseColor.IsEmpty) + { + return state switch + { + PushButtonState.Disabled => DeriveDisabledColor(customBaseColor), + PushButtonState.Hot => DeriveHoverColor(customBaseColor), + PushButtonState.Pressed => DerivePressedColor(customBaseColor), + _ => customBaseColor + }; + } + + return GetBackgroundColor(state, isDefault); + } public abstract Color GetBackgroundColor(PushButtonState state, bool isDefault); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs index 55552647bce..c32a3a00c79 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/DarkModeAdapterFactory.cs @@ -5,18 +5,25 @@ namespace System.Windows.Forms.ButtonInternal; internal static class DarkModeAdapterFactory { + // The owner-drawn dark/modern adapter is used when dark mode is enabled (conservative renderer) or when + // the control opts into the modern .NET 11 visual styles (modern renderer, in either dark or light scheme). + private static bool UseOwnerDrawnAdapter(ButtonBase control) + { + return Application.IsDarkModeEnabled || control.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11; + } + public static ButtonBaseAdapter CreateFlatAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonFlatAdapter(control); public static ButtonBaseAdapter CreateStandardAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonStandardAdapter(control); public static ButtonBaseAdapter CreatePopupAdapter(ButtonBase control) => - Application.IsDarkModeEnabled + UseOwnerDrawnAdapter(control) ? new ButtonDarkModeAdapter(control) : new ButtonPopupAdapter(control); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/FlatButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/FlatButtonDarkModeRenderer.cs index 2541cb1b6ad..5b27822ed35 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/FlatButtonDarkModeRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/FlatButtonDarkModeRenderer.cs @@ -23,7 +23,12 @@ internal sealed class FlatButtonDarkModeRenderer : ButtonDarkModeRendererBase private protected override Padding PaddingCore { get; } = new(0); public override Rectangle DrawButtonBackground( - Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault, Color backColor) + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor) { // fill background using var back = backColor.GetCachedSolidBrushScope(); @@ -51,7 +56,7 @@ public override void DrawFocusIndicator(Graphics g, Rectangle contentBounds, boo focusBackColor); } - public override Color GetTextColor(PushButtonState state, bool isDefault) => + public override Color GetTextColor(PushButtonState state, bool isDefault, Color backColor) => state == PushButtonState.Disabled ? DefaultColors.DisabledTextColor : isDefault @@ -77,32 +82,37 @@ public override Color GetBackgroundColor(PushButtonState state, bool isDefault) _ => DefaultColors.StandardBackColor }; - private static void DrawButtonBorder(Graphics g, Rectangle bounds, PushButtonState state, bool isDefault) + private void DrawButtonBorder(Graphics g, Rectangle bounds, PushButtonState state, bool isDefault) { g.SmoothingMode = SmoothingMode.AntiAlias; - // Win32 draws its stroke fully *inside* the control → inset by 1 px + int thickness = ScaleBorderThickness(1); + if (thickness == 0) + { + return; + } + + // Win32 draws its stroke fully inside the control. Rectangle outer = Rectangle.Inflate(bounds, -1, -1); - DrawSingleBorder(g, outer, GetBorderColor(state)); + DrawSingleBorder(g, outer, ResolveBorderColor(GetBorderColor(state)), thickness); - // Default button gets a second 1‑px border one pixel further inside + // Default button gets a second border one pixel further inside. if (isDefault) { Rectangle inner = Rectangle.Inflate(outer, -1, -1); - DrawSingleBorder(g, inner, DefaultColors.AcceptFocusIndicatorBackColor); + DrawSingleBorder(g, inner, DefaultColors.AcceptFocusIndicatorBackColor, thickness); } } - private static void DrawSingleBorder(Graphics g, Rectangle rect, Color color) + private static void DrawSingleBorder(Graphics g, Rectangle rect, Color color, int thickness) { g.SmoothingMode = SmoothingMode.AntiAlias; using var path = new GraphicsPath(); path.AddRoundedRectangle(rect, s_corner); - // a 1‑px stroke, aligned *inside*, is exactly what Win32 draws - using var pen = new Pen(color) { Alignment = PenAlignment.Inset }; + using var pen = new Pen(color, thickness) { Alignment = PenAlignment.Inset }; g.DrawPath(pen, path); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs index ab1f7844042..522f52d85b3 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/IButtonRenderer.cs @@ -30,6 +30,7 @@ static void DrawButtonBorder(Graphics graphics, GraphicsPath path, Color borderC /// Renders the button with the specified style, state, and content. /// /// The graphics context to draw on. + /// The button control whose parent surface is used for exposed regions. /// The bounds of the button. /// The flat style of the button. /// The visual state of the button (normal, hot, pressed, disabled, default). @@ -37,10 +38,10 @@ static void DrawButtonBorder(Graphics graphics, GraphicsPath path, Color borderC /// True if the button is focused; otherwise, false. /// True to show focus cues; otherwise, false. /// The background color of the parent control. - /// An action to paint the image within the specified rectangle. - /// An action to paint the text or field within the specified rectangle, color, and enabled state. + /// An action to lay out and paint the image and text within the content rectangle. void RenderButton( Graphics graphics, + Control control, Rectangle bounds, FlatStyle flatStyle, PushButtonState state, @@ -49,8 +50,7 @@ void RenderButton( bool showFocusCues, Color parentBackgroundColor, Color backColor, - Action paintImage, - Action paintField); + Action paintContent); /// /// Draws button background with appropriate styling. @@ -59,8 +59,15 @@ void RenderButton( /// Bounds of the button /// State of the button (normal, hot, pressed, disabled) /// True if button is the default button + /// True if the button is focused /// The content bounds (area inside the button for text/image) - Rectangle DrawButtonBackground(Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault, Color backColor); + Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor); /// /// Draws focus indicator appropriate for this style. @@ -73,5 +80,5 @@ void RenderButton( /// /// Gets the text color appropriate for the button state and type. /// - Color GetTextColor(PushButtonState state, bool isDefault); + Color GetTextColor(PushButtonState state, bool isDefault, Color backColor); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonColorMath.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonColorMath.cs new file mode 100644 index 00000000000..bb08116fd05 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonColorMath.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Windows.Forms.Rendering.Button; +using System.Windows.Forms.VisualStyles; + +namespace System.Windows.Forms; + +/// +/// Resolves the modern button palette and state colors shared by button renderers and appearance defaults. +/// +internal static class ModernButtonColorMath +{ + internal const float AccentBlendAmount = 0.2f; + internal const float MinimumTextContrastRatio = PopupButtonColorMath.MinimumReadableContrastRatio; + private const float DefaultHoverLightenAmount = 0.08f; + private const float DefaultPressedDarkenAmount = 0.12f; + + internal static Color GetMouseDownColor() + => Application.GetWindowsAccentColor(); + + internal static Color GetMouseOverColor(ButtonBase owner, FlatButtonAppearance appearance) + { + Color baseColor = GetRenderedBaseColor(owner, appearance); + + return BlendWithAccent(baseColor); + } + + internal static Color BlendWithAccent(Color baseColor) + => PopupButtonColorMath.Blend(baseColor, Application.GetWindowsAccentColor(), AccentBlendAmount); + + internal static Color GetDefaultButtonColor(PushButtonState state) + => GetDefaultButtonColor(Application.GetWindowsAccentColor(), state); + + internal static Color GetDefaultButtonColor(Color accentColor, PushButtonState state) + => state switch + { + PushButtonState.Hot => PopupButtonColorMath.Lighten(accentColor, DefaultHoverLightenAmount), + PushButtonState.Pressed => PopupButtonColorMath.Darken(accentColor, DefaultPressedDarkenAmount), + _ => accentColor + }; + + internal static Color GetReadableForeColor(Color backColor) + { + float blackContrast = PopupButtonColorMath.GetContrastRatio(Color.Black, backColor); + float whiteContrast = PopupButtonColorMath.GetContrastRatio(Color.White, backColor); + Color foreColor = PopupButtonColorMath.GetReadableForeColor(backColor); + + Debug.Assert( + Math.Max(blackContrast, whiteContrast) >= MinimumTextContrastRatio, + "Either black or white should meet WCAG AA contrast against an opaque background."); + + return foreColor; + } + + internal static Color GetStateColor( + ButtonDarkModeRendererBase renderer, + PushButtonState state, + bool isDefault, + Color customBaseColor) + { + if (customBaseColor.IsEmpty && isDefault) + { + return GetDefaultButtonColor(state); + } + + Color baseColor = customBaseColor.IsEmpty + ? renderer.GetBackgroundColor(PushButtonState.Normal, isDefault) + : customBaseColor; + + return state switch + { + PushButtonState.Pressed => GetMouseDownColor(), + PushButtonState.Hot => BlendWithAccent(baseColor), + _ => baseColor + }; + } + + internal static Color GetRenderedBaseColor(ButtonBase owner, FlatButtonAppearance appearance) + { + ButtonDarkModeRendererBase renderer = owner.FlatStyle switch + { + FlatStyle.Standard or FlatStyle.Popup => new ModernButtonDarkModeRenderer(), + FlatStyle.Flat => new ModernFlatButtonRenderer(), + FlatStyle.System => new SystemButtonDarkModeRenderer(), + _ => throw new ArgumentOutOfRangeException(nameof(owner)) + }; + + renderer.DeviceDpi = owner.DeviceDpi; + renderer.FlatAppearance = appearance; + + if (owner.BackColor != Control.DefaultBackColor) + { + return owner.BackColor; + } + + return renderer.GetBackgroundColor(PushButtonState.Normal, owner.IsDefault); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs new file mode 100644 index 00000000000..81ecfd0f5cc --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernButtonDarkModeRenderer.cs @@ -0,0 +1,261 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.VisualStyles; + +namespace System.Windows.Forms; + +/// +/// Modern, WinUI-inspired push button renderer used when is +/// or later. It supports both dark and light color schemes and draws a +/// rounded button area, an optional dark gap ring, and a rounded focus ring for the focused button. +/// +/// +/// +/// The visual is intentionally driven by a small set of DPI-scaled constants so the appearance can be +/// fine-tuned during exploratory testing. All widths are expressed in logical (96-DPI) pixels. +/// +/// +internal sealed class ModernButtonDarkModeRenderer : ButtonDarkModeRendererBase +{ + // Logical (96-DPI) layout constants. DPI-scaled via the base Scale() helper. + private const int FocusRingThicknessLogical = 2; + private const int FocusGapThicknessLogical = 1; + private const int FocusedCornerRadiusLogical = 6; + private const int UnfocusedCornerRadiusLogical = 8; + private const int BorderThicknessLogical = 1; + private const int ContentInsetLogical = 4; + + // Dark scheme - normal button area. + private static readonly Color s_darkNormal = Color.FromArgb(0x2D, 0x2D, 0x2D); + private static readonly Color s_darkNormalHover = Color.FromArgb(0x32, 0x32, 0x32); + private static readonly Color s_darkNormalPressed = Color.FromArgb(0x2A, 0x2A, 0x2A); + private static readonly Color s_darkDisabled = Color.FromArgb(0x25, 0x25, 0x25); + + private static readonly Color s_darkDisabledText = Color.FromArgb(0x88, 0x88, 0x88); + + private static readonly Color s_darkGap = Color.FromArgb(0x0A, 0x0A, 0x0A); + private static readonly Color s_darkFocusRing = Color.White; + + // Light (WinUI) scheme - normal button area. + private static readonly Color s_lightNormal = Color.FromArgb(0xFB, 0xFB, 0xFB); + private static readonly Color s_lightNormalHover = Color.FromArgb(0xF9, 0xF9, 0xF9); + private static readonly Color s_lightNormalPressed = Color.FromArgb(0xF5, 0xF5, 0xF5); + private static readonly Color s_lightDisabled = Color.FromArgb(0xFA, 0xFA, 0xFA); + private static readonly Color s_lightBorder = Color.FromArgb(0xD0, 0xD0, 0xD0); + + private static readonly Color s_lightDisabledText = Color.FromArgb(0xA0, 0xA0, 0xA0); + + private static bool IsDark => Application.IsDarkModeEnabled; + + private int FocusRingThickness + => ScaleBorderThickness(Math.Max(1, Scale(FocusRingThicknessLogical) - HighDpiCorrection)); + + private int FocusGapThickness + => Math.Max(1, Scale(FocusGapThicknessLogical) - HighDpiCorrection); + + private int FocusBodyInset + => Math.Max( + FocusRingThickness + FocusGapThickness, + Math.Max(1, Scale(FocusRingThicknessLogical)) + + Math.Max(1, Scale(FocusGapThicknessLogical))); + + private int HighDpiCorrection => DeviceDpi > ScaleHelper.OneHundredPercentLogicalDpi ? 1 : 0; + + private int GetCornerRadius(bool focused, bool isDefault) + => Math.Max(1, Scale(focused || isDefault ? FocusedCornerRadiusLogical : UnfocusedCornerRadiusLogical)); + + // When focused, the body is inset just enough to leave room for the focus ring and a single-pixel gap, + // keeping that gap tight so the rounded body claims as much real estate as possible. + private protected override Padding PaddingCore + => new(FocusBodyInset); + + // When the focus ring is not drawn there is nothing to inset for, so the rounded body expands to fill the + // whole client area - covering the band the ring and its gap would otherwise occupy. This gives the button + // a more generous, less cramped background without changing the control's own Padding. + private protected override Padding GetContentPadding(bool focusRingVisible) + => focusRingVisible ? PaddingCore : Padding.Empty; + + internal override Padding GetPreferredSizePadding() + => new(FocusBodyInset + Scale(ContentInsetLogical)); + + private protected override bool UseModernStateDefaults => true; + + private protected override bool PaintParentBackground => true; + + public override Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor) + { + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + RectangleF pathBounds = GetPathBounds(bounds); + float radius = GetCornerRadius(focused, isDefault); + int borderThickness = ScaleBorderThickness( + Math.Max(1, Scale(BorderThicknessLogical))); + + if (!IsDark + && !isDefault + && state != PushButtonState.Disabled + && borderThickness > 0) + { + Color borderColor = ResolveBorderColor(s_lightBorder); + using var borderBrush = borderColor.GetCachedSolidBrushScope(); + using GraphicsPath borderPath = CreateRingPath( + pathBounds, + radius, + borderThickness); + graphics.FillPath(borderBrush, borderPath); + + pathBounds = Inset(pathBounds, borderThickness); + radius = Math.Max(1, radius - (2 * borderThickness)); + } + + using var brush = backColor.GetCachedSolidBrushScope(); + using GraphicsPath bodyPath = CreateRoundedPath(pathBounds, radius); + graphics.FillPath(brush, bodyPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + + int inset = Scale(ContentInsetLogical); + return Rectangle.Inflate(bounds, -inset, -inset); + } + + public override void DrawFocusIndicator(Graphics graphics, Rectangle bounds, bool isDefault) + { + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + if (FocusRingThickness == 0) + { + return; + } + + RectangleF outerBounds = GetPathBounds(bounds); + int bodyInset = FocusRingThickness + FocusGapThickness; + float outerRadius = GetCornerRadius(focused: true, isDefault: isDefault) + + (2 * bodyInset); + + Color gapColor = IsDark ? s_darkGap : SystemColors.Window; + using (var gapBrush = gapColor.GetCachedSolidBrushScope()) + { + using GraphicsPath gapPath = CreateRingPath( + Inset(outerBounds, FocusRingThickness), + outerRadius - (2 * FocusRingThickness), + FocusGapThickness); + graphics.FillPath(gapBrush, gapPath); + } + + Color ringColor = ResolveBorderColor(IsDark ? s_darkFocusRing : SystemColors.WindowText); + using var ringBrush = ringColor.GetCachedSolidBrushScope(); + using GraphicsPath ringPath = CreateRingPath( + outerBounds, + outerRadius, + FocusRingThickness); + graphics.FillPath(ringBrush, ringPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + } + + public override Color GetTextColor(PushButtonState state, bool isDefault, Color backColor) + { + if (state == PushButtonState.Disabled) + { + Color preferredForeColor = IsDark + ? s_darkDisabledText + : s_lightDisabledText; + + return ModernControlColorMath.GetDisabledTextColor( + preferredForeColor, + backColor); + } + + return ModernButtonColorMath.GetReadableForeColor(backColor); + } + + public override Color GetBackgroundColor(PushButtonState state, bool isDefault) + { + if (state == PushButtonState.Disabled) + { + return IsDark ? s_darkDisabled : s_lightDisabled; + } + + if (isDefault) + { + return ModernButtonColorMath.GetDefaultButtonColor(state); + } + + return IsDark + ? state switch + { + PushButtonState.Hot => s_darkNormalHover, + PushButtonState.Pressed => s_darkNormalPressed, + _ => s_darkNormal + } + : state switch + { + PushButtonState.Hot => s_lightNormalHover, + PushButtonState.Pressed => s_lightNormalPressed, + _ => s_lightNormal + }; + } + + private static RectangleF GetPathBounds(Rectangle bounds) + => new( + bounds.X, + bounds.Y, + Math.Max(1, bounds.Width - 1), + Math.Max(1, bounds.Height - 1)); + + private static RectangleF Inset(RectangleF bounds, float inset) + => new( + bounds.X + inset, + bounds.Y + inset, + Math.Max(1, bounds.Width - (2 * inset)), + Math.Max(1, bounds.Height - (2 * inset))); + + private static GraphicsPath CreateRoundedPath(RectangleF bounds, float radius) + { + GraphicsPath path = new(); + float clampedRadius = Math.Clamp(radius, 1, Math.Min(bounds.Width, bounds.Height)); + path.AddRoundedRectangle(bounds, new SizeF(clampedRadius, clampedRadius)); + return path; + } + + private static GraphicsPath CreateRingPath( + RectangleF outerBounds, + float outerRadius, + float thickness) + { + GraphicsPath path = CreateRoundedPath(outerBounds, outerRadius); + RectangleF innerBounds = Inset(outerBounds, thickness); + float innerRadius = Math.Max(1, outerRadius - (2 * thickness)); + using GraphicsPath innerPath = CreateRoundedPath(innerBounds, innerRadius); + path.FillMode = FillMode.Alternate; + path.AddPath(innerPath, connect: false); + return path; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernFlatButtonRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernFlatButtonRenderer.cs new file mode 100644 index 00000000000..e73b92961af --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/ModernFlatButtonRenderer.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.VisualStyles; + +namespace System.Windows.Forms; + +/// +/// Renders a modern button with a rectangular body and a single border. +/// +internal sealed class ModernFlatButtonRenderer : ButtonDarkModeRendererBase +{ + private const int BorderThicknessLogical = 1; + private const int ContentInsetLogical = 4; + private const int FocusInsetLogical = 3; + + private static readonly Color s_lightBorder = Color.FromArgb(0xD0, 0xD0, 0xD0); + private static readonly Color s_darkBorder = Color.FromArgb(0x55, 0x55, 0x55); + private static readonly Color s_lightNormal = Color.FromArgb(0xFB, 0xFB, 0xFB); + private static readonly Color s_lightDisabled = Color.FromArgb(0xFA, 0xFA, 0xFA); + private static readonly Color s_darkNormal = Color.FromArgb(0x2D, 0x2D, 0x2D); + private static readonly Color s_darkDisabled = Color.FromArgb(0x25, 0x25, 0x25); + + private static bool IsDark => Application.IsDarkModeEnabled; + + private int BorderThickness + => ScaleBorderThickness(Math.Max(1, Scale(BorderThicknessLogical))); + + private protected override Padding PaddingCore => Padding.Empty; + + private protected override bool UseModernStateDefaults => true; + + internal override Padding GetPreferredSizePadding() + => new(Math.Max(BorderThickness, Scale(ContentInsetLogical))); + + public override Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor) + { + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.Default; + + using (var brush = backColor.GetCachedSolidBrushScope()) + { + graphics.FillRectangle(brush, bounds); + } + + int thickness = BorderThickness; + if (thickness > 0 && bounds.Width > 0 && bounds.Height > 0) + { + Color designBorderColor = isDefault + ? (IsDark ? Color.White : SystemColors.Highlight) + : (IsDark ? s_darkBorder : s_lightBorder); + + using var pen = new Pen(ResolveBorderColor(designBorderColor), thickness) + { + Alignment = PenAlignment.Inset + }; + + graphics.DrawRectangle(pen, bounds.X, bounds.Y, bounds.Width - 1, bounds.Height - 1); + } + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + + int inset = Math.Max(BorderThickness, Scale(ContentInsetLogical)); + return Rectangle.Inflate(bounds, -inset, -inset); + } + + public override void DrawFocusIndicator(Graphics graphics, Rectangle contentBounds, bool isDefault) + { + int inset = Scale(FocusInsetLogical); + Rectangle focusRectangle = Rectangle.Inflate(contentBounds, -inset, -inset); + if (focusRectangle.Width <= 0 || focusRectangle.Height <= 0) + { + return; + } + + Color focusColor = ResolveBorderColor(IsDark ? Color.White : SystemColors.WindowText); + using var focusPen = new Pen(focusColor) { DashStyle = DashStyle.Dot }; + graphics.DrawRectangle(focusPen, focusRectangle); + } + + public override Color GetTextColor(PushButtonState state, bool isDefault, Color backColor) + { + if (state == PushButtonState.Disabled) + { + Color preferredForeColor = IsDark + ? Color.FromArgb(0x88, 0x88, 0x88) + : Color.FromArgb(0xA0, 0xA0, 0xA0); + + return ModernControlColorMath.GetDisabledTextColor( + preferredForeColor, + backColor); + } + + return ModernButtonColorMath.GetReadableForeColor(backColor); + } + + public override Color GetBackgroundColor(PushButtonState state, bool isDefault) + { + if (state == PushButtonState.Disabled) + { + return IsDark ? s_darkDisabled : s_lightDisabled; + } + + if (isDefault) + { + return ModernButtonColorMath.GetDefaultButtonColor(state); + } + + Color baseColor = IsDark ? s_darkNormal : s_lightNormal; + + return state switch + { + PushButtonState.Hot => DeriveHoverColor(baseColor), + PushButtonState.Pressed => DerivePressedColor(baseColor), + _ => baseColor + }; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/PopupButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/PopupButtonDarkModeRenderer.cs index 5970ad57fca..83793cd6b73 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/PopupButtonDarkModeRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/PopupButtonDarkModeRenderer.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Drawing; @@ -9,222 +9,203 @@ namespace System.Windows.Forms; /// -/// Provides methods for rendering a button with Popup FlatStyle in dark mode. +/// Renders a Classic button in dark mode. /// -internal class PopupButtonDarkModeRenderer : ButtonDarkModeRendererBase +internal sealed class PopupButtonDarkModeRenderer : ButtonDarkModeRendererBase { - // UI constants - private const int ButtonCornerRadius = 5; - private const int FocusCornerRadius = 3; - private const int FocusPadding = 2; - private const int BorderThickness = 2; - private const int ContentOffset = 1; // Offset for content when pressed - - private protected override Padding PaddingCore { get; } = new(0); - - // Default border color adjustment constants - private const int DefaultBorderROffset = 30; - private const int DefaultBorderGOffset = 20; - private const int DefaultBorderBOffset = 40; - - /// - /// Draws button background with popup styling, including subtle 3D effect. - /// - public override Rectangle DrawButtonBackground(Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault, Color backColor) + private const int CornerRadiusLogical = 5; + private const int ContentInsetLogical = 3; + private const int FocusInsetLogical = 2; + private const int PressOffsetLogical = 1; + + private protected override Padding PaddingCore => Padding.Empty; + + private protected override bool PaintParentBackground => true; + + public override Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor) { - // Use padding from ButtonDarkModeRenderer - Padding padding = PaddingCore; - Rectangle paddedBounds = Rectangle.Inflate(bounds, -padding.Left, -padding.Top); + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return Rectangle.Empty; + } - // Content rect will be used to position text and images - Rectangle contentBounds = Rectangle.Inflate(paddedBounds, -padding.Left, -padding.Top); + Rectangle bodyBounds = bounds; + bodyBounds.Width--; + bodyBounds.Height--; - // Adjust content position when pressed to enhance 3D effect - if (state == PushButtonState.Pressed) + using GraphicsPath bodyPath = CreateRoundedPath(bodyBounds); + using (var brush = backColor.GetCachedSolidBrushScope()) { - contentBounds.Offset(ContentOffset, ContentOffset); + graphics.FillPath(brush, bodyPath); } - // Create path for rounded corners - using GraphicsPath path = CreateRoundedRectanglePath(paddedBounds, ButtonCornerRadius); + DrawButtonBorder( + graphics, + bodyBounds, + state, + isDefault); - // Fill the background using cached brush - using var brush = backColor.GetCachedSolidBrushScope(); - graphics.FillPath(brush, path); + int contentInset = Math.Max(1, Scale(ContentInsetLogical)); + Rectangle contentBounds = Rectangle.Inflate( + bodyBounds, + -contentInset, + -contentInset); - // Draw 3D effect borders - DrawButtonBorder(graphics, paddedBounds, state, isDefault); + if (state == PushButtonState.Pressed) + { + int pressOffset = Math.Max(1, Scale(PressOffsetLogical)); + contentBounds.Offset(pressOffset, pressOffset); + } - // Return content bounds (area inside the button for text/image) return contentBounds; } - /// - /// Draws a focus rectangle with dotted lines inside the button. - /// Adjusts for the 3D effect based on the button's state. - /// - public override void DrawFocusIndicator(Graphics graphics, Rectangle contentBounds, bool isDefault) + public override void DrawFocusIndicator( + Graphics graphics, + Rectangle contentBounds, + bool isDefault) { - // Create a slightly smaller rectangle for the focus indicator - Rectangle focusRect = Rectangle.Inflate(contentBounds, -FocusPadding, -FocusPadding); + int focusInset = Math.Max(1, Scale(FocusInsetLogical)); + Rectangle focusBounds = Rectangle.Inflate( + contentBounds, + -focusInset, + -focusInset); + if (focusBounds.Width <= 0 || focusBounds.Height <= 0) + { + return; + } - // Create dotted pen with appropriate color - Color focusColor = isDefault + Color focusBackColor = isDefault ? DefaultColors.AcceptFocusIndicatorBackColor : DefaultColors.FocusIndicatorBackColor; - - // See GDI+ best practices: pens with custom DashStyle must not use cached pens. - using var focusPen = new Pen(focusColor) - { - DashStyle = DashStyle.Dot - }; - - // Draw the focus rectangle with rounded corners - using GraphicsPath focusPath = CreateRoundedRectanglePath(focusRect, FocusCornerRadius); - graphics.DrawPath(focusPen, focusPath); + ControlPaint.DrawFocusRectangle( + graphics, + focusBounds, + DefaultColors.FocusBorderColor, + focusBackColor); } - /// - /// Gets the text color appropriate for the button state and type. - /// Adjusts color for 3D effect when needed. - /// - public override Color GetTextColor(PushButtonState state, bool isDefault) => - state == PushButtonState.Disabled + public override Color GetTextColor( + PushButtonState state, + bool isDefault, + Color backColor) + => state == PushButtonState.Disabled ? DefaultColors.DisabledTextColor - : isDefault - ? DefaultColors.AcceptButtonTextColor - : DefaultColors.NormalTextColor; - - /// - /// Gets the background color appropriate for the button state and type. - /// - public override Color GetBackgroundColor(PushButtonState state, bool isDefault) => - isDefault - ? state switch - { - PushButtonState.Normal => DefaultColors.StandardBackColor, - PushButtonState.Hot => DefaultColors.HoverBackColor, - PushButtonState.Pressed => DefaultColors.PressedBackColor, - PushButtonState.Disabled => DefaultColors.DisabledBackColor, - _ => DefaultColors.StandardBackColor - } - : state switch - { - PushButtonState.Normal => DefaultColors.StandardBackColor, - PushButtonState.Hot => DefaultColors.HoverBackColor, - PushButtonState.Pressed => DefaultColors.PressedBackColor, - PushButtonState.Disabled => DefaultColors.DisabledBackColor, - _ => DefaultColors.StandardBackColor - }; + : DefaultColors.NormalTextColor; - /// - /// Draws the 3D effect border for the button. - /// - private static void DrawButtonBorder(Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault) - { - // Save original smoothing mode to restore later - SmoothingMode originalMode = graphics.SmoothingMode; - - try + public override Color GetBackgroundColor( + PushButtonState state, + bool isDefault) + => state switch { - graphics.SmoothingMode = SmoothingMode.AntiAlias; - - // Create a GraphicsPath for the border to ensure consistent alignment - // The path needs to match exactly the same dimensions as the filled background - using GraphicsPath path = CreateRoundedRectanglePath(bounds, ButtonCornerRadius); - - // Popup style has a 3D effect border - Rectangle borderRect = bounds; - - // Use slightly more contrasting border colors for dark mode 3D effect - Color topLeftOuter, bottomRightOuter, topLeftInner, bottomRightInner; + PushButtonState.Hot => DefaultColors.HoverBackColor, + PushButtonState.Pressed => DefaultColors.PressedBackColor, + PushButtonState.Disabled => DefaultColors.DisabledBackColor, + _ => DefaultColors.StandardBackColor + }; - if (state == PushButtonState.Pressed) - { - // In pressed state, invert the 3D effect: highlight bottom/right, shadow top/left - topLeftOuter = DefaultColors.ShadowColor; // shadow - bottomRightOuter = DefaultColors.HighlightColor; // highlight - topLeftInner = DefaultColors.ShadowDarkColor; // deeper shadow - bottomRightInner = DefaultColors.HighlightBrightColor; // brighter highlight - } - else if (state == PushButtonState.Disabled) - { - // Disabled: subtle, low-contrast border - topLeftOuter = DefaultColors.DisabledBorderLightColor; - bottomRightOuter = DefaultColors.DisabledBorderDarkColor; - topLeftInner = DefaultColors.DisabledBorderMidColor; - bottomRightInner = DefaultColors.DisabledBorderMidColor; - } - else - { - // Normal/hot: highlight top/left, shadow bottom/right - topLeftOuter = DefaultColors.HighlightColor; // highlight - bottomRightOuter = DefaultColors.ShadowColor; // shadow - topLeftInner = DefaultColors.HighlightBrightColor; // brighter highlight - bottomRightInner = DefaultColors.ShadowDarkColor; // deeper shadow - } + private void DrawButtonBorder( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault) + { + int thickness = ScaleBorderThickness( + Math.Max(1, Scale(1))); + if (thickness == 0) + { + return; + } - // Custom pen needed for PenAlignment.Inset - can't use cached version - // See GDI+ best practices: pens with custom alignment must not use cached pens. - using (var topLeftOuterPen = new Pen(topLeftOuter) { Alignment = PenAlignment.Inset }) - using (var bottomRightOuterPen = new Pen(bottomRightOuter) { Alignment = PenAlignment.Inset }) - { - // Draw the outer 3D border lines - // Top - graphics.DrawLine(topLeftOuterPen, borderRect.Left, borderRect.Top, borderRect.Right - 1, borderRect.Top); - // Left - graphics.DrawLine(topLeftOuterPen, borderRect.Left, borderRect.Top, borderRect.Left, borderRect.Bottom - 1); - // Bottom - graphics.DrawLine(bottomRightOuterPen, borderRect.Left, borderRect.Bottom - 1, borderRect.Right - 1, borderRect.Bottom - 1); - // Right - graphics.DrawLine(bottomRightOuterPen, borderRect.Right - 1, borderRect.Top, borderRect.Right - 1, borderRect.Bottom - 1); - } + using GraphicsStateScope graphicsStateScope = new(graphics); + using GraphicsPath borderClip = CreateRoundedPath(bounds); + graphics.SetClip(borderClip); - // Inner border for more depth - borderRect.Inflate(-BorderThickness, -BorderThickness); + Color topLeftColor; + Color bottomRightColor; - using (var topLeftInnerPen = new Pen(topLeftInner) { Alignment = PenAlignment.Inset }) - using (var bottomRightInnerPen = new Pen(bottomRightInner) { Alignment = PenAlignment.Inset }) + if (FlatAppearance is { BorderColor.IsEmpty: false } appearance) + { + topLeftColor = appearance.BorderColor; + bottomRightColor = appearance.BorderColor; + } + else + { + (topLeftColor, bottomRightColor) = state switch { - // Draw the inner 3D border lines - // Top - graphics.DrawLine(topLeftInnerPen, borderRect.Left, borderRect.Top, borderRect.Right - 1, borderRect.Top); - // Left - graphics.DrawLine(topLeftInnerPen, borderRect.Left, borderRect.Top, borderRect.Left, borderRect.Bottom - 1); - // Bottom - graphics.DrawLine(bottomRightInnerPen, borderRect.Left, borderRect.Bottom - 1, borderRect.Right - 1, borderRect.Bottom - 1); - // Right - graphics.DrawLine(bottomRightInnerPen, borderRect.Right - 1, borderRect.Top, borderRect.Right - 1, borderRect.Bottom - 1); - } + PushButtonState.Pressed => ( + DefaultColors.ShadowDarkColor, + DefaultColors.HighlightBrightColor), + PushButtonState.Disabled => ( + DefaultColors.DisabledBorderLightColor, + DefaultColors.DisabledBorderDarkColor), + _ => ( + DefaultColors.HighlightColor, + DefaultColors.ShadowDarkColor) + }; + } - // For default buttons, add an additional inner border - if (isDefault && state != PushButtonState.Disabled) + using var topLeftPen = topLeftColor.GetCachedPenScope( + thickness); + using var bottomRightPen = bottomRightColor.GetCachedPenScope( + thickness); + graphics.DrawLine( + topLeftPen, + bounds.Left, + bounds.Bottom - 1, + bounds.Left, + bounds.Top); + graphics.DrawLine( + topLeftPen, + bounds.Left, + bounds.Top, + bounds.Right - 1, + bounds.Top); + graphics.DrawLine( + bottomRightPen, + bounds.Right - 1, + bounds.Top, + bounds.Right - 1, + bounds.Bottom - 1); + graphics.DrawLine( + bottomRightPen, + bounds.Right - 1, + bounds.Bottom - 1, + bounds.Left, + bounds.Bottom - 1); + + if (isDefault && state != PushButtonState.Disabled) + { + Rectangle defaultBounds = Rectangle.Inflate( + bounds, + -thickness, + -thickness); + if (defaultBounds.Width > 0 && defaultBounds.Height > 0) { - borderRect.Inflate(-BorderThickness, -BorderThickness); - Color innerBorderColor = Color.FromArgb( - Math.Max(0, DefaultColors.StandardBackColor.R - DefaultBorderROffset), - Math.Max(0, DefaultColors.StandardBackColor.G - DefaultBorderGOffset), - Math.Max(0, DefaultColors.StandardBackColor.B - DefaultBorderBOffset)); - - // Custom pen needed for PenAlignment.Inset - can't use cached version - using var defaultInnerBorderPen = new Pen(innerBorderColor) { Alignment = PenAlignment.Inset }; - graphics.DrawRectangle(defaultInnerBorderPen, borderRect); + using var defaultPen = + DefaultColors.AcceptFocusIndicatorBackColor + .GetCachedPenScope(thickness); + graphics.DrawRectangle(defaultPen, defaultBounds); } } - finally - { - // Restore original smoothing mode - graphics.SmoothingMode = originalMode; - } } - /// - /// Creates a GraphicsPath for a rounded rectangle. - /// - private static GraphicsPath CreateRoundedRectanglePath(Rectangle bounds, int radius) + private GraphicsPath CreateRoundedPath(Rectangle bounds) { GraphicsPath path = new(); - path.AddRoundedRectangle(bounds, new Size(radius, radius)); + int radius = Math.Clamp( + Scale(CornerRadiusLogical), + 1, + Math.Max(1, Math.Min(bounds.Width, bounds.Height))); + path.AddRoundedRectangle( + bounds, + new Size(radius, radius)); return path; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs index 706ccf777cd..774ad412f63 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/DarkMode/SystemButtonDarkModeRenderer.cs @@ -27,22 +27,39 @@ internal class SystemButtonDarkModeRenderer : ButtonDarkModeRendererBase private protected override Padding PaddingCore { get; } = new Padding(SystemStylePadding); + private protected override bool PaintParentBackground => true; + /// /// Draws button background with system styling (larger rounded corners). /// - public override Rectangle DrawButtonBackground(Graphics graphics, Rectangle bounds, PushButtonState state, bool isDefault, Color backColor) + public override Rectangle DrawButtonBackground( + Graphics graphics, + Rectangle bounds, + PushButtonState state, + bool isDefault, + bool focused, + Color backColor) { - // Shrink for DarkBorderGap and FocusBorderThickness - Rectangle fillBounds = Rectangle.Inflate(bounds, -SystemStylePadding, -SystemStylePadding); - - using GraphicsPath fillPath = CreateRoundedRectanglePath(fillBounds, CornerRadius - DarkBorderGapThickness); + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; - // Fill the background using cached brush - using var brush = backColor.GetCachedSolidBrushScope(); - graphics.FillPath(brush, fillPath); + RectangleF pathBounds = GetPathBounds(bounds); + using GraphicsPath fillPath = CreateRoundedPath(pathBounds, FocusIndicatorCornerRadius); + using var brush = backColor.GetCachedSolidBrushScope(); + graphics.FillPath(brush, fillPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } // Return content bounds (area inside the button for text/image) - return fillBounds; + return bounds; } /// @@ -50,26 +67,34 @@ public override Rectangle DrawButtonBackground(Graphics graphics, Rectangle boun /// public override void DrawFocusIndicator(Graphics graphics, Rectangle contentBounds, bool isDefault) { - // We need the bottom and the right border one pixel inside the button - Rectangle focusRect = new( - x: contentBounds.X, - y: contentBounds.Y, - width: contentBounds.Width - 1, - height: contentBounds.Height - 1); - - // Create path for the focus outline - using GraphicsPath focusPath = CreateRoundedRectanglePath(focusRect, FocusIndicatorCornerRadius); - - // System style uses a solid white border instead of dotted lines - using var focusPen = Color.White.GetCachedPenScope(FocusedButtonBorderThickness); + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; - graphics.DrawPath(focusPen, focusPath); + RectangleF outerBounds = GetPathBounds(contentBounds); + float outerRadius = FocusIndicatorCornerRadius + (2 * SystemStylePadding); + Color focusColor = ResolveBorderColor(Color.White); + using var focusBrush = focusColor.GetCachedSolidBrushScope(); + using GraphicsPath focusPath = CreateRingPath( + outerBounds, + outerRadius, + FocusedButtonBorderThickness); + graphics.FillPath(focusBrush, focusPath); + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } } /// /// Gets the text color appropriate for the button state and type. /// - public override Color GetTextColor(PushButtonState state, bool isDefault) => + public override Color GetTextColor(PushButtonState state, bool isDefault, Color backColor) => state == PushButtonState.Disabled ? DefaultColors.DisabledTextColor : isDefault @@ -133,7 +158,9 @@ public void DrawButtonBorder( // Outer border path Rectangle borderRect = Rectangle.Inflate(bounds, -SystemStylePadding, -SystemStylePadding); - using GraphicsPath borderPath = CreateRoundedRectanglePath(borderRect, CornerRadius); + using GraphicsPath borderPath = CreateRoundedPath( + GetPathBounds(borderRect), + CornerRadius); // We need to implement a subtle 3d effect around the already // painted filling. We do this by drawing a border with a 1px pen, @@ -268,15 +295,39 @@ private static GraphicsPath GetBottomRightSegmentPath(Rectangle bounds, int radi return path; } - /// - /// Creates a GraphicsPath for a rounded rectangle. - /// - private static GraphicsPath CreateRoundedRectanglePath(Rectangle bounds, int radius) + private static RectangleF GetPathBounds(Rectangle bounds) + => new( + bounds.X, + bounds.Y, + Math.Max(1, bounds.Width - 1), + Math.Max(1, bounds.Height - 1)); + + private static RectangleF Inset(RectangleF bounds, float inset) + => new( + bounds.X + inset, + bounds.Y + inset, + Math.Max(1, bounds.Width - (2 * inset)), + Math.Max(1, bounds.Height - (2 * inset))); + + private static GraphicsPath CreateRoundedPath(RectangleF bounds, float radius) { GraphicsPath path = new(); + float clampedRadius = Math.Clamp(radius, 1, Math.Min(bounds.Width, bounds.Height)); + path.AddRoundedRectangle(bounds, new SizeF(clampedRadius, clampedRadius)); + return path; + } - path.AddRoundedRectangle(bounds, new Size(radius, radius)); - + private static GraphicsPath CreateRingPath( + RectangleF outerBounds, + float outerRadius, + float thickness) + { + GraphicsPath path = CreateRoundedPath(outerBounds, outerRadius); + RectangleF innerBounds = Inset(outerBounds, thickness); + float innerRadius = Math.Max(1, outerRadius - (2 * thickness)); + using GraphicsPath innerPath = CreateRoundedPath(innerBounds, innerRadius); + path.FillMode = FillMode.Alternate; + path.AddPath(innerPath, connect: false); return path; } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonFlatAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonFlatAdapter.cs index 9986c57642c..02aff0a81c9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonFlatAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonFlatAdapter.cs @@ -13,7 +13,7 @@ internal override void PaintDown(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonFlatAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreateFlatAdapter(Control); adapter.PaintDown(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); return; } @@ -33,7 +33,7 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonFlatAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreateFlatAdapter(Control); adapter.PaintOver(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); return; } @@ -53,7 +53,7 @@ internal override void PaintUp(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonFlatAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreateFlatAdapter(Control); adapter.PaintUp(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); return; } @@ -80,7 +80,7 @@ private void PaintFlatWorker(PaintEventArgs e, Color checkColor, Color checkBack PaintField(e, layout, colors, checkColor, drawFocus: true); } - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonFlatAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateFlatAdapter(Control); // RadioButtonPopupLayout also uses this layout for down and over protected override LayoutOptions Layout(PaintEventArgs e) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonModernAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonModernAdapter.cs new file mode 100644 index 00000000000..bbd78441fb1 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonModernAdapter.cs @@ -0,0 +1,126 @@ +// 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.ButtonInternal; + +/// +/// Paints a normal-appearance with the modern animated glyph renderer. +/// +internal sealed class RadioButtonModernAdapter : RadioButtonBaseAdapter +{ + private readonly FlatStyle _flatStyle; + + internal RadioButtonModernAdapter(RadioButton control, FlatStyle flatStyle) : base(control) + { + _flatStyle = flatStyle; + } + + internal override void PaintUp(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintUp(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); + return; + } + + PaintCore(e); + } + + internal override void PaintDown(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintDown(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); + return; + } + + PaintCore(e); + } + + internal override void PaintOver(PaintEventArgs e, CheckState state) + { + if (Control.Appearance == Appearance.Button) + { + ButtonAdapter.PaintOver(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); + return; + } + + PaintCore(e); + } + + protected override ButtonBaseAdapter CreateButtonAdapter() + => _flatStyle switch + { + FlatStyle.Flat => DarkModeAdapterFactory.CreateFlatAdapter(Control), + FlatStyle.Popup => DarkModeAdapterFactory.CreatePopupAdapter(Control), + _ => DarkModeAdapterFactory.CreateStandardAdapter(Control) + }; + + protected override LayoutOptions Layout(PaintEventArgs e) + { + LayoutOptions layout = CommonLayout(); + layout.CheckSize = Math.Max( + Control.LogicalToDeviceUnits(13), + (int)(Control.Font.Height * 0.9f)); + + return layout; + } + + internal override LayoutOptions CommonLayout() + { + LayoutOptions layout = base.CommonLayout(); + layout.ShadowedText = false; + + return layout; + } + + private void PaintCore(PaintEventArgs e) + { + Graphics graphics = e.GraphicsInternal; + ParentBackgroundRenderer.Paint( + Control, + graphics, + Control.ClientRectangle, + Control.BackColor); + + LayoutData layout = Layout(e).Layout(); + AdjustFocusRectangle(layout); + PaintBackgroundImage(e); + + Color? customOnColor = Control.ShouldSerializeBackColor() + ? Control.BackColor + : null; + + Color? customBorderColor = Control.FlatAppearance.BorderColor.IsEmpty + ? null + : Control.FlatAppearance.BorderColor; + + Control.RadioGlyphRenderer.NotifyCheckedChanged(Control.Checked); + Control.RadioGlyphRenderer.DrawGlyph( + graphics, + layout.CheckBounds, + _flatStyle, + Control.Enabled, + Control.MouseIsOver, + Control.Focused && Control.ShowFocusCues, + customOnColor, + customBorderColor); + + PaintImage(e, layout); + + Color preferredTextColor = Control.ShouldSerializeForeColor() + ? Control.ForeColor + : Application.IsDarkModeEnabled + ? Color.FromArgb(0xF0, 0xF0, 0xF0) + : SystemColors.WindowText; + Color textColor = Control.Enabled + ? preferredTextColor + : ModernControlColorMath.GetDisabledTextColor( + preferredTextColor, + Control.Parent?.BackColor ?? Control.BackColor); + + PaintField(e, layout, PaintRender(e).Calculate(), textColor, drawFocus: true); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonPopupAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonPopupAdapter.cs index bf3aa38a2fd..e59c0b91d9a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonPopupAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonPopupAdapter.cs @@ -11,7 +11,7 @@ internal override void PaintUp(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintUp(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); } else @@ -40,7 +40,7 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintOver(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); } else @@ -65,7 +65,7 @@ internal override void PaintDown(PaintEventArgs e, CheckState state) { if (Control.Appearance == Appearance.Button) { - ButtonPopupAdapter adapter = new(Control); + ButtonBaseAdapter adapter = DarkModeAdapterFactory.CreatePopupAdapter(Control); adapter.PaintDown(e, Control.Checked ? CheckState.Checked : CheckState.Unchecked); } else @@ -85,7 +85,7 @@ internal override void PaintDown(PaintEventArgs e, CheckState state) } } - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonPopupAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreatePopupAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs index 0181286c263..7546f46c31a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/ButtonInternal/RadioButtonStandardAdapter.cs @@ -50,9 +50,7 @@ internal override void PaintOver(PaintEventArgs e, CheckState state) } } - private new ButtonStandardAdapter ButtonAdapter => (ButtonStandardAdapter)base.ButtonAdapter; - - protected override ButtonBaseAdapter CreateButtonAdapter() => new ButtonStandardAdapter(Control); + protected override ButtonBaseAdapter CreateButtonAdapter() => DarkModeAdapterFactory.CreateStandardAdapter(Control); protected override LayoutOptions Layout(PaintEventArgs e) { diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.Appearance.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.Appearance.Docs.cs new file mode 100644 index 00000000000..60087d6d9d6 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.Appearance.Docs.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class CheckBox +{ + /// + /// Gets or sets the value that determines the appearance of the check box. + /// + /// + /// + /// selects the framework's owner-drawn toggle renderer for a + /// two-state check box in an effective .NET 11-or-later mode. Because a native check box cannot render a + /// toggle switch, this appearance intentionally takes precedence over when + /// that property is . A check box with enabled retains + /// classic check-box behavior instead. + /// + /// + /// High Contrast resolves to classic rendering. Modern toggle metrics can increase preferred size; see the + /// + /// .NET 11 VisualStyles layout guidance. + /// + /// + [DefaultValue(Appearance.Normal)] + [Localizable(true)] + [SRCategory(nameof(SR.CatAppearance))] + [SRDescription(nameof(SR.CheckBoxAppearanceDescr))] + public partial Appearance Appearance { get; set; } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs index 57526b2644b..1f8089c5ddb 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/CheckBox.cs @@ -28,6 +28,10 @@ public partial class CheckBox : ButtonBase private ContentAlignment _checkAlign = ContentAlignment.MiddleLeft; private CheckState _checkState; private Appearance _appearance; + private bool _threeState; + + private Rendering.CheckBox.AnimatedCheckGlyphRenderer? _checkGlyphRenderer; + private Rendering.CheckBox.AnimatedToggleSwitchRenderer? _toggleSwitchRenderer; private int _flatSystemStylePaddingWidth; private int _flatSystemStyleMinimumHeight; @@ -53,14 +57,7 @@ public CheckBox() : base() private bool AccObjDoDefaultAction { get; set; } - /// - /// Gets or sets the value that determines the appearance of a check box control. - /// - [DefaultValue(Appearance.Normal)] - [Localizable(true)] - [SRCategory(nameof(SR.CatAppearance))] - [SRDescription(nameof(SR.CheckBoxAppearanceDescr))] - public Appearance Appearance + public partial Appearance Appearance { get => _appearance; set @@ -75,11 +72,13 @@ public Appearance Appearance using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.Appearance)) { _appearance = value; + ResetAdapter(); // UpdateOwnerDraw synchronizes control styles with the OwnerDraw state and recreates // the handle if they differ. Since we hijack FlatStyle.Standard for DarkMode, the transition // between Normal and Button appearance is critical for updating the OwnerDraw flag. UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); // If handle wasn't recreated (OwnerDraw state didn't change), refresh the appearance. if (OwnerDraw) @@ -97,16 +96,54 @@ public Appearance Appearance } private protected override bool OwnerDraw => + // Appearance.ToggleSwitch intentionally wins over FlatStyle.System: a toggle has no + // native BS_GROUPBOX rendering and must remain owner-drawn so the requested appearance + // is never ignored and mouse-state geometry is always available. + IsToggleSwitchAppearance + || // We want NO owner draw ONLY when we're // * In Dark Mode // * When _then_ the Appearance is Button // * But then ONLY when we're rendering with FlatStyle.Standard // (because that would let us usually let us draw with the VisualStyleRenderers, // which cause HighDPI issues in Dark Mode). - (!Application.IsDarkModeEnabled + ((!Application.IsDarkModeEnabled || Appearance != Appearance.Button || FlatStyle != FlatStyle.Standard) - && base.OwnerDraw; + && base.OwnerDraw); + + /// + /// Gets a value indicating whether the check box should render as the modern, animated toggle switch. + /// + private bool IsToggleSwitchAppearance + { + get + { + return Appearance == Appearance.ToggleSwitch + && EffectiveVisualStylesMode >= VisualStylesMode.Net11 + && !ThreeState; + } + } + + private Rendering.CheckBox.AnimatedToggleSwitchRenderer ToggleSwitchRenderer => + _toggleSwitchRenderer ??= new(this, Rendering.CheckBox.ModernCheckBoxStyle.Rounded); + + internal Rendering.CheckBox.AnimatedCheckGlyphRenderer CheckGlyphRenderer + => _checkGlyphRenderer ??= new(this); + + private void UpdateToggleSwitchStyles() + { + if (IsToggleSwitchAppearance) + { + // Owner-paint with WinForms double buffering for a flicker-free, fluent animation. + SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + _toggleSwitchRenderer?.SynchronizeState(); + } + else + { + _toggleSwitchRenderer?.StopAnimation(); + } + } [SRCategory(nameof(SR.CatPropertyChanged))] [SRDescription(nameof(SR.CheckBoxOnAppearanceChangedDescr))] @@ -199,6 +236,14 @@ public CheckState CheckState return; } + // For the animated toggle switch, stop any in-flight animation (leaving the thumb at its current + // position) before the state changes, then start a fresh animation toward the new position. + bool animateToggleSwitch = IsToggleSwitchAppearance && IsHandleCreated; + if (animateToggleSwitch) + { + ToggleSwitchRenderer.PrepareStateChange(); + } + bool oldChecked = Checked; _checkState = value; @@ -218,6 +263,11 @@ public CheckState CheckState _notifyAccessibilityStateChangedNeeded = !checkedChanged; OnCheckStateChanged(EventArgs.Empty); _notifyAccessibilityStateChangedNeeded = false; + + if (animateToggleSwitch) + { + ToggleSwitchRenderer.StartAnimation(); + } } } @@ -288,8 +338,29 @@ private void ScaleConstants() internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (IsToggleSwitchAppearance) + { + return Rendering.CheckBox.ToggleSwitchMetrics.Create(this).GetPreferredSize(this); + } + + if (Appearance == Appearance.Button && FlatStyle == FlatStyle.Popup) + { + return DarkModeAdapterFactory.CreatePopupAdapter(this).GetPreferredSizeCore(proposedConstraints) + + Padding.Size; + } + if (Appearance == Appearance.Button) { + if (EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + && FlatStyle is FlatStyle.Standard or FlatStyle.Flat) + { + ButtonBaseAdapter modernAdapter = FlatStyle == FlatStyle.Flat + ? DarkModeAdapterFactory.CreateFlatAdapter(this) + : DarkModeAdapterFactory.CreateStandardAdapter(this); + + return modernAdapter.GetPreferredSizeCore(proposedConstraints) + Padding.Size; + } + ButtonStandardAdapter adapter = new(this); return adapter.GetPreferredSizeCore(proposedConstraints); } @@ -312,7 +383,9 @@ internal override Rectangle OverChangeRectangle { get { - if (Appearance == Appearance.Button) + if (IsToggleSwitchAppearance + || Appearance == Appearance.Button + || !OwnerDraw) { return base.OverChangeRectangle; } @@ -333,7 +406,8 @@ internal override Rectangle DownChangeRectangle { get { - if (Appearance == Appearance.Button + if (IsToggleSwitchAppearance + || Appearance == Appearance.Button || !OwnerDraw) { return base.DownChangeRectangle; @@ -365,7 +439,29 @@ public override ContentAlignment TextAlign [DefaultValue(false)] [SRCategory(nameof(SR.CatBehavior))] [SRDescription(nameof(SR.CheckBoxThreeStateDescr))] - public bool ThreeState { get; set; } + public bool ThreeState + { + get => _threeState; + set + { + if (_threeState == value) + { + return; + } + + _toggleSwitchRenderer?.StopAnimation(); + _threeState = value; + ResetAdapter(); + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, nameof(ThreeState)); + + if (IsHandleCreated) + { + Invalidate(); + } + } + } /// /// Occurs when the value of the property changes. @@ -497,6 +593,64 @@ protected override void OnHandleCreated(EventArgs e) { PInvokeCore.SendMessage(this, PInvoke.BM_SETCHECK, (WPARAM)(int)_checkState); } + + UpdateToggleSwitchStyles(); + } + + /// + protected override void OnPaint(PaintEventArgs pevent) + { + if (IsToggleSwitchAppearance) + { + using GraphicsStateScope scope = new(pevent.Graphics); + ToggleSwitchRenderer.RenderControl(pevent.Graphics); + return; + } + + base.OnPaint(pevent); + } + + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + + // Entering or leaving the modern toggle-switch appearance changes whether the control is owner-drawn. + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); + + if (IsHandleCreated) + { + Invalidate(); + } + } + + /// + protected override void OnSystemColorsChanged(EventArgs e) + { + base.OnSystemColorsChanged(e); + _checkGlyphRenderer?.InvalidateAccentColor(); + _toggleSwitchRenderer?.InvalidateAccentColor(); + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); + } + + internal ContentAlignment RtlTranslatedCheckAlign + => RtlTranslateContent(CheckAlign); + + internal bool ShowFocusCuesInternal => ShowFocusCues; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _checkGlyphRenderer?.Dispose(); + _checkGlyphRenderer = null; + _toggleSwitchRenderer?.Dispose(); + _toggleSwitchRenderer = null; + } + + base.Dispose(disposing); } /// @@ -526,11 +680,24 @@ protected override void OnMouseUp(MouseEventArgs mevent) base.OnMouseUp(mevent); } - internal override ButtonBaseAdapter CreateFlatAdapter() => new CheckBoxFlatAdapter(this); + internal override ButtonBaseAdapter CreateFlatAdapter() + => UseModernGlyphRenderer + ? new CheckBoxModernAdapter(this, FlatStyle.Flat) + : new CheckBoxFlatAdapter(this); + + internal override ButtonBaseAdapter CreatePopupAdapter() + => UseModernGlyphRenderer + ? new CheckBoxModernAdapter(this, FlatStyle.Popup) + : new CheckBoxPopupAdapter(this); - internal override ButtonBaseAdapter CreatePopupAdapter() => new CheckBoxPopupAdapter(this); + internal override ButtonBaseAdapter CreateStandardAdapter() + => UseModernGlyphRenderer + ? new CheckBoxModernAdapter(this, FlatStyle.Standard) + : new CheckBoxStandardAdapter(this); - internal override ButtonBaseAdapter CreateStandardAdapter() => new CheckBoxStandardAdapter(this); + private bool UseModernGlyphRenderer + => Appearance == Appearance.Normal + && EffectiveVisualStylesMode >= VisualStylesMode.Net11; /// /// Overridden to handle mnemonics properly. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/FlatButtonAppearance.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/FlatButtonAppearance.cs index 2377fbf24f9..adbd5c5e3f7 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/FlatButtonAppearance.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/FlatButtonAppearance.cs @@ -114,10 +114,13 @@ public Color CheckedBackColor [SRCategory(nameof(SR.CatAppearance))] [SRDescription(nameof(SR.ButtonMouseDownBackColorDescr))] [EditorBrowsable(EditorBrowsableState.Always)] - [DefaultValue(typeof(Color), "")] public Color MouseDownBackColor { - get => _mouseDownBackColor; + get => _mouseDownBackColor.IsEmpty + && _owner.FlatStyle != FlatStyle.Popup + && _owner.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + ? ModernButtonColorMath.GetMouseDownColor() + : _mouseDownBackColor; set { if (_mouseDownBackColor != value) @@ -138,10 +141,13 @@ public Color MouseDownBackColor [SRCategory(nameof(SR.CatAppearance))] [SRDescription(nameof(SR.ButtonMouseOverBackColorDescr))] [EditorBrowsable(EditorBrowsableState.Always)] - [DefaultValue(typeof(Color), "")] public Color MouseOverBackColor { - get => _mouseOverBackColor; + get => _mouseOverBackColor.IsEmpty + && _owner.FlatStyle != FlatStyle.Popup + && _owner.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + ? ModernButtonColorMath.GetMouseOverColor(_owner, this) + : _mouseOverBackColor; set { if (_mouseOverBackColor != value) @@ -151,4 +157,16 @@ public Color MouseOverBackColor } } } + + internal Color MouseDownBackColorCore => _mouseDownBackColor; + + internal Color MouseOverBackColorCore => _mouseOverBackColor; + + private void ResetMouseDownBackColor() => MouseDownBackColor = Color.Empty; + + private bool ShouldSerializeMouseDownBackColor() => !_mouseDownBackColor.IsEmpty; + + private void ResetMouseOverBackColor() => MouseOverBackColor = Color.Empty; + + private bool ShouldSerializeMouseOverBackColor() => !_mouseOverBackColor.IsEmpty; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs index a6674f1ea69..bf101c70025 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/Buttons/RadioButton.cs @@ -32,6 +32,8 @@ public partial class RadioButton : ButtonBase private bool _autoCheck = true; private ContentAlignment _checkAlign = ContentAlignment.MiddleLeft; private Appearance _appearance = Appearance.Normal; + private Rendering.RadioButton.AnimatedRadioGlyphRenderer? _radioGlyphRenderer; + private Rendering.CheckBox.AnimatedToggleSwitchRenderer? _toggleSwitchRenderer; private int _flatSystemStylePaddingWidth; private int _flatSystemStyleMinimumHeight; @@ -89,9 +91,11 @@ public Appearance Appearance using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.Appearance)) { _appearance = value; + ResetAdapter(); // UpdateOwnerDraw checks if OwnerDraw state changed and calls RecreateHandle if needed. UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); // If handle wasn't recreated (OwnerDraw state didn't change), refresh the appearance. if (OwnerDraw) @@ -158,6 +162,12 @@ public bool Checked { if (_isChecked != value) { + bool animateToggleSwitch = IsToggleSwitchAppearance && IsHandleCreated; + if (animateToggleSwitch) + { + ToggleSwitchRenderer.PrepareStateChange(); + } + _isChecked = value; if (IsHandleCreated) @@ -169,6 +179,11 @@ public bool Checked Update(); PerformAutoUpdates(tabbedInto: false); OnCheckedChanged(EventArgs.Empty); + + if (animateToggleSwitch) + { + ToggleSwitchRenderer.StartAnimation(); + } } } } @@ -181,10 +196,15 @@ public bool Checked // * but then ONLY when we're rendering with FlatStyle.Standard // (because that would let us usually let us draw with the VisualStyleRenderers, // which cause HighDPI issues in Dark Mode). - (!Application.IsDarkModeEnabled - || Appearance != Appearance.Button - || FlatStyle != FlatStyle.Standard) - && base.OwnerDraw; + IsToggleSwitchAppearance + || ((!Application.IsDarkModeEnabled + || Appearance != Appearance.Button + || FlatStyle != FlatStyle.Standard) + && base.OwnerDraw); + + private bool IsToggleSwitchAppearance + => Appearance == Appearance.ToggleSwitch + && EffectiveVisualStylesMode >= VisualStylesMode.Net11; /// [Browsable(false)] @@ -263,6 +283,17 @@ private void ScaleConstants() internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (IsToggleSwitchAppearance) + { + return Rendering.CheckBox.ToggleSwitchMetrics.Create(this).GetPreferredSize(this); + } + + if (Appearance == Appearance.Button && FlatStyle == FlatStyle.Popup) + { + return DarkModeAdapterFactory.CreatePopupAdapter(this).GetPreferredSizeCore(proposedConstraints) + + Padding.Size; + } + if (FlatStyle != FlatStyle.System) { return base.GetPreferredSizeCore(proposedConstraints); @@ -348,6 +379,44 @@ protected override void OnHandleCreated(EventArgs e) { PInvokeCore.SendMessage(this, PInvoke.BM_SETCHECK, (WPARAM)(BOOL)_isChecked); } + + UpdateToggleSwitchStyles(); + } + + /// + protected override void OnPaint(PaintEventArgs pevent) + { + if (IsToggleSwitchAppearance) + { + using GraphicsStateScope scope = new(pevent.Graphics); + ToggleSwitchRenderer.RenderControl(pevent.Graphics); + return; + } + + base.OnPaint(pevent); + } + + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); + + if (IsHandleCreated) + { + Invalidate(); + } + } + + /// + protected override void OnSystemColorsChanged(EventArgs e) + { + base.OnSystemColorsChanged(e); + _radioGlyphRenderer?.InvalidateAccentColor(); + _toggleSwitchRenderer?.InvalidateAccentColor(); + UpdateOwnerDraw(); + UpdateToggleSwitchStyles(); } /// @@ -467,11 +536,56 @@ private void WipeTabStops(bool tabbedInto) } } - internal override ButtonBaseAdapter CreateFlatAdapter() => new RadioButtonFlatAdapter(this); + internal override ButtonBaseAdapter CreateFlatAdapter() + => UseModernGlyphRenderer + ? new RadioButtonModernAdapter(this, FlatStyle.Flat) + : new RadioButtonFlatAdapter(this); + + internal override ButtonBaseAdapter CreatePopupAdapter() + => UseModernGlyphRenderer + ? new RadioButtonModernAdapter(this, FlatStyle.Popup) + : new RadioButtonPopupAdapter(this); + + internal override ButtonBaseAdapter CreateStandardAdapter() + => UseModernGlyphRenderer + ? new RadioButtonModernAdapter(this, FlatStyle.Standard) + : new RadioButtonStandardAdapter(this); + + internal Rendering.RadioButton.AnimatedRadioGlyphRenderer RadioGlyphRenderer + => _radioGlyphRenderer ??= new(this); + + internal Rendering.CheckBox.AnimatedToggleSwitchRenderer ToggleSwitchRenderer => + _toggleSwitchRenderer ??= new(this, Rendering.CheckBox.ModernCheckBoxStyle.Rounded); + + private void UpdateToggleSwitchStyles() + { + if (IsToggleSwitchAppearance) + { + SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + _toggleSwitchRenderer?.SynchronizeState(); + } + else + { + _toggleSwitchRenderer?.StopAnimation(); + } + } + + private bool UseModernGlyphRenderer + => Appearance == Appearance.Normal + && EffectiveVisualStylesMode >= VisualStylesMode.Net11; - internal override ButtonBaseAdapter CreatePopupAdapter() => new RadioButtonPopupAdapter(this); + protected override void Dispose(bool disposing) + { + if (disposing) + { + _radioGlyphRenderer?.Dispose(); + _radioGlyphRenderer = null; + _toggleSwitchRenderer?.Dispose(); + _toggleSwitchRenderer = null; + } - internal override ButtonBaseAdapter CreateStandardAdapter() => new RadioButtonStandardAdapter(this); + base.Dispose(disposing); + } private void OnAppearanceChanged(EventArgs e) { @@ -481,6 +595,11 @@ private void OnAppearanceChanged(EventArgs e) } } + internal ContentAlignment RtlTranslatedCheckAlign + => RtlTranslateContent(CheckAlign); + + internal bool ShowFocusCuesInternal => ShowFocusCues; + protected override void OnMouseUp(MouseEventArgs mevent) { if (mevent.Button == MouseButtons.Left diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatComboAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatComboAdapter.cs index 5e95e902664..b549e59d668 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatComboAdapter.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatComboAdapter.cs @@ -55,7 +55,7 @@ public FlatComboAdapter(ComboBox comboBox, bool smallButton) } } - public bool IsValid(ComboBox combo) + public virtual bool IsValid(ComboBox combo) { return (combo.ClientRectangle == _clientRect && combo.RightToLeft == _origRightToLeft); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatStyle.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatStyle.Docs.cs new file mode 100644 index 00000000000..8769b1f6a39 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.FlatStyle.Docs.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class ComboBox +{ + /// + /// Gets or sets the flat-style appearance of the combo box. + /// + /// + /// + /// In an effective .NET 11-or-later mode, uses a rounded field matching + /// the TextBoxBase color scheme, uses the same colors with a square border, + /// and uses Standard geometry with a Windows-accent border. Each style adds + /// a one-logical-pixel internal inset on top of . The native + /// style is not modernized. + /// + /// + /// Modern non- controls share field metrics with an AutoSize, + /// single-line TextBox whose Padding includes the same inset. Prefer + /// TableLayoutPanel rows for mixed TextBox and ComboBox layouts. See the + /// + /// .NET 11 VisualStyles layout guidance. + /// + /// + [SRCategory(nameof(SR.CatAppearance))] + [DefaultValue(FlatStyle.Standard)] + [Localizable(true)] + [SRDescription(nameof(SR.ComboBoxFlatStyleDescr))] + public partial FlatStyle FlatStyle { get; set; } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.Modern.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.Modern.cs new file mode 100644 index 00000000000..90eeab9368b --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.Modern.cs @@ -0,0 +1,770 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Windows.Forms.Layout; +using Windows.Win32.Graphics.Dwm; + +namespace System.Windows.Forms; + +public partial class ComboBox +{ + private NativeComboBaseline _nativeComboBaseline; + private bool _applyingModernComboLayout; + private bool _nativeComboHandleInitialized; + private bool _normalizingNativeComboBaseline; + private int _modernComboLayoutWriteCount; + + internal bool UsesModernComboAdapter + => EffectiveVisualStylesMode >= VisualStylesMode.Net11 + && FlatStyle != FlatStyle.System; + + private bool UsesComboAdapter + => UsesModernComboAdapter + || FlatStyle is FlatStyle.Flat or FlatStyle.Popup; + + private int ModernPreferredHeight + { + get + { + if (DrawMode != DrawMode.Normal) + { + return ItemHeight + + (2 * SystemInformation.FixedFrameBorderSize.Height); + } + + return ModernControlVisualStyles.GetPreferredFieldHeight( + fontHeight: FontHeight, + fieldPadding: GetModernFieldPadding(), + deviceDpi: DeviceDpiInternal); + } + } + + private void ResetComboAdapter() + => Properties.RemoveValue(s_propFlatComboAdapter); + + private unsafe void CaptureNativeComboBaseline( + COMBOBOXINFO comboBoxInfo) + { + if (_nativeComboBaseline.IsCaptured) + { + return; + } + + int selectionFieldItemHeight = (int)PInvokeCore.SendMessage( + this, + PInvoke.CB_GETITEMHEIGHT, + (WPARAM)(-1)); + + if (selectionFieldItemHeight <= 0) + { + return; + } + + PInvokeCore.GetWindowRect(this, out RECT windowBounds); + + Rectangle editBounds = DropDownStyle == ComboBoxStyle.DropDownList + || comboBoxInfo.hwndItem.IsNull + ? Rectangle.Empty + : GetChildBounds(comboBoxInfo.hwndItem); + + Rectangle simpleListBounds = DropDownStyle == ComboBoxStyle.Simple + && !comboBoxInfo.hwndList.IsNull + ? GetChildBounds(comboBoxInfo.hwndList) + : Rectangle.Empty; + + _nativeComboBaseline = new NativeComboBaseline + { + IsCaptured = true, + DeviceDpi = DeviceDpiInternal, + SelectionFieldItemHeight = selectionFieldItemHeight, + SelectionFieldFrameHeight = Math.Max( + 0, + windowBounds.Height - selectionFieldItemHeight), + EditBounds = editBounds, + SimpleListBounds = simpleListBounds, + ClientSize = ClientSize + }; + } + + private unsafe void EnsureNativeComboBaseline() + { + if (_nativeComboBaseline.IsCaptured + || !_nativeComboHandleInitialized + || !IsHandleCreated) + { + return; + } + + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + + if (PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo)) + { + CaptureNativeComboBaseline(comboBoxInfo); + } + } + + private void InitializeNativeComboBaseline() + { + if (_nativeComboBaseline.IsCaptured + || _normalizingNativeComboBaseline + || !_nativeComboHandleInitialized + || !IsHandleCreated) + { + return; + } + + _normalizingNativeComboBaseline = true; + + try + { + // Force the native ComboBox to settle rcItem, rcButton, and child bounds before + // capturing them. A handle created with a requested height has not done this yet. + UpdateStylesCore(); + } + finally + { + _normalizingNativeComboBaseline = false; + } + + EnsureNativeComboBaseline(); + } + + private ModernComboTargetState ComputeModernComboTargetState() + { + bool usesModernMetrics = UsesModernComboAdapter; + int selectionFieldItemHeight = 0; + + if (DropDownStyle != ComboBoxStyle.Simple + && DrawMode == DrawMode.Normal) + { + int desiredHeight = usesModernMetrics + ? ModernPreferredHeight + : GetClassicPreferredHeight(); + + selectionFieldItemHeight = Math.Max( + 1, + desiredHeight + - ScaleNativeBaselineValue(_nativeComboBaseline.SelectionFieldFrameHeight)); + } + + Padding chromeInsets = usesModernMetrics + ? GetModernChromeInsets() + : Padding.Empty; + + GetAdjustedNativeChildBounds( + out Rectangle editBounds, + out Rectangle simpleListBounds); + + Padding editMargins = Padding.Empty; + + if (usesModernMetrics && !editBounds.IsEmpty) + { + int topInset = chromeInsets.Top + Padding.Top; + int bottomInset = chromeInsets.Bottom + Padding.Bottom; + editBounds.Y += topInset; + + editBounds.Height = Math.Max( + 1, + editBounds.Height - topInset - bottomInset); + + editBounds.Height = Math.Max( + 1, + Math.Min( + editBounds.Height, + ClientRectangle.Bottom + - bottomInset + - editBounds.Top)); + + // Inset the edit window horizontally so its rectangular corners clear the rounded + // field arcs, and reserve the (now wider) drop-down button on the button side. + int leftInset = chromeInsets.Left + Padding.Left; + int rightInset = chromeInsets.Right + Padding.Right; + int extraButtonWidth = DropDownStyle == ComboBoxStyle.Simple + ? 0 + : ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.ComboBoxButtonExtraWidth, + DeviceDpiInternal); + + if (RightToLeft == RightToLeft.Yes) + { + // In RTL the drop-down button sits on the left, so its reservation goes there. + editBounds.X += rightInset + extraButtonWidth; + } + else + { + editBounds.X += leftInset; + } + + editBounds.Width = Math.Max( + 1, + editBounds.Width - leftInset - rightInset - extraButtonWidth); + + if (!simpleListBounds.IsEmpty) + { + int simpleListBottom = GetCurrentSimpleListBottom( + simpleListBounds.Bottom); + + simpleListBounds.Y = editBounds.Bottom + bottomInset; + + simpleListBounds.Height = Math.Max( + 1, + simpleListBottom - simpleListBounds.Y); + } + } + + return new ModernComboTargetState + { + SelectionFieldItemHeight = selectionFieldItemHeight, + EditMargins = editMargins, + EditBounds = editBounds, + SimpleListBounds = simpleListBounds + }; + } + + private void GetAdjustedNativeChildBounds( + out Rectangle editBounds, + out Rectangle simpleListBounds) + { + editBounds = ScaleNativeBaselineRectangle( + _nativeComboBaseline.EditBounds); + + simpleListBounds = ScaleNativeBaselineRectangle( + _nativeComboBaseline.SimpleListBounds); + + Size baselineClientSize = ScaleNativeBaselineSize( + _nativeComboBaseline.ClientSize); + + int widthDelta = ClientSize.Width - baselineClientSize.Width; + int heightDelta = ClientSize.Height - baselineClientSize.Height; + + if (!editBounds.IsEmpty) + { + editBounds.Width = Math.Max( + 1, + editBounds.Width + widthDelta); + + if (DropDownStyle == ComboBoxStyle.Simple) + { + int selectionFieldItemHeight = (int)PInvokeCore.SendMessage( + this, + PInvoke.CB_GETITEMHEIGHT, + (WPARAM)(-1)); + + editBounds.Height = Math.Max( + 1, + editBounds.Height + + selectionFieldItemHeight + - ScaleNativeBaselineValue( + _nativeComboBaseline.SelectionFieldItemHeight)); + } + else + { + editBounds.Height = Math.Max( + 1, + editBounds.Height + heightDelta); + } + } + + if (!simpleListBounds.IsEmpty) + { + simpleListBounds.Width = Math.Max( + 1, + simpleListBounds.Width + widthDelta); + + simpleListBounds.Height = Math.Max( + 1, + simpleListBounds.Height + heightDelta); + } + } + + private unsafe int GetCurrentSimpleListBottom( + int fallback) + { + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + + return !PInvoke.GetComboBoxInfo( + hwndCombo: HWND, + pcbi: ref comboBoxInfo) + || comboBoxInfo.hwndList.IsNull + ? fallback + : GetChildBounds(comboBoxInfo.hwndList).Bottom; + } + + private int ScaleNativeBaselineValue(int value) + { + if (_nativeComboBaseline.DeviceDpi == DeviceDpiInternal) + { + return value; + } + + return (int)Math.Round( + value * DeviceDpiInternal / (double)_nativeComboBaseline.DeviceDpi); + } + + private Rectangle ScaleNativeBaselineRectangle(Rectangle bounds) + => bounds.IsEmpty + ? Rectangle.Empty + : new( + x: ScaleNativeBaselineValue(bounds.X), + y: ScaleNativeBaselineValue(bounds.Y), + width: ScaleNativeBaselineValue(bounds.Width), + height: ScaleNativeBaselineValue(bounds.Height)); + + private Size ScaleNativeBaselineSize(Size size) + => new( + width: ScaleNativeBaselineValue(size.Width), + height: ScaleNativeBaselineValue(size.Height)); + + /// + /// Applies the complete native ComboBox target for the current managed state. + /// + private unsafe void ApplyModernComboLayout() + { + if (!IsHandleCreated) + { + ApplyManagedPreferredHeightBeforeHandle(); + return; + } + + if (!_nativeComboBaseline.IsCaptured + && !UsesModernComboAdapter) + { + return; + } + + InitializeNativeComboBaseline(); + + if (_applyingModernComboLayout + || _normalizingNativeComboBaseline + || !_nativeComboBaseline.IsCaptured) + { + return; + } + + _applyingModernComboLayout = true; + + try + { + ModernComboTargetState target = ComputeModernComboTargetState(); + ApplySelectionFieldItemHeight(target.SelectionFieldItemHeight); + + // Changing the selection-field height synchronously changes the native client geometry. + target = ComputeModernComboTargetState(); + + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + + if (!PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo)) + { + return; + } + + if (!target.EditBounds.IsEmpty) + { + ApplyEditMargins( + comboBoxInfo.hwndItem, + target.EditMargins); + } + + ApplyChildBounds( + comboBoxInfo.hwndItem, + target.EditBounds); + + ApplyChildBounds( + comboBoxInfo.hwndList, + target.SimpleListBounds); + } + finally + { + _applyingModernComboLayout = false; + } + } + + private void ApplyManagedPreferredHeightBeforeHandle() + { + if (DropDownStyle == ComboBoxStyle.Simple) + { + return; + } + + int targetHeight = UsesModernComboAdapter + ? ModernPreferredHeight + : GetClassicPreferredHeight(); + + if (Height != targetHeight) + { + Height = targetHeight; + } + } + + private void ApplySelectionFieldItemHeight(int targetHeight) + { + if (targetHeight <= 0) + { + return; + } + + int currentHeight = (int)PInvokeCore.SendMessage( + this, + PInvoke.CB_GETITEMHEIGHT, + (WPARAM)(-1)); + + if (currentHeight == targetHeight) + { + return; + } + + PInvokeCore.SendMessage( + this, + PInvoke.CB_SETITEMHEIGHT, + (WPARAM)(-1), + (LPARAM)targetHeight); + + _modernComboLayoutWriteCount++; + } + + private void ApplyEditMargins( + HWND editHandle, + Padding targetMargins) + { + if (editHandle.IsNull) + { + return; + } + + nint currentMargins = PInvokeCore.SendMessage( + editHandle, + PInvokeCore.EM_GETMARGINS); + + int currentLeft = PARAM.LOWORD(currentMargins); + int currentRight = PARAM.HIWORD(currentMargins); + + if (currentLeft == targetMargins.Left + && currentRight == targetMargins.Right) + { + return; + } + + PInvokeCore.SendMessage( + editHandle, + PInvokeCore.EM_SETMARGINS, + (WPARAM)(PInvoke.EC_LEFTMARGIN | PInvoke.EC_RIGHTMARGIN), + LPARAM.MAKELPARAM( + targetMargins.Left, + targetMargins.Right)); + _modernComboLayoutWriteCount++; + } + + private void ApplyChildBounds(HWND childHandle, Rectangle targetBounds) + { + if (childHandle.IsNull + || targetBounds.IsEmpty + || GetChildBounds(childHandle) == targetBounds) + { + return; + } + + PInvoke.SetWindowPos( + childHandle, + HWND.Null, + targetBounds.Left, + targetBounds.Top, + targetBounds.Width, + targetBounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + + _modernComboLayoutWriteCount++; + } + + private Rectangle GetChildBounds(HWND child) + { + PInvokeCore.GetWindowRect(child, out RECT bounds); + + Point topLeft = PointToClient( + new Point(bounds.left, bounds.top)); + + return new Rectangle( + topLeft, + new Size(bounds.Width, bounds.Height)); + } + + private Padding GetModernChromeInsets() + { + // Minimal modern field inset plus a small arc-clearance so the flat native edit child's + // rectangular corners are not painted into the rounded-corner arcs. This is chrome padding + // only; the caller still adds the user's Padding on top (see ComputeModernComboTargetState + // and GetModernFieldPadding). It deliberately excludes the classic 3D-border metrics + // (Fixed3DBorderPadding / InternalChromeInset) that the previous implementation inherited + // from the classic field model; the modern field owns a single rounded border across the + // full control, so those insets only produced a spurious inner margin. + int inset = ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.BorderThickness + + ModernControlVisualStyles.ComboBoxStyleInset + + ModernControlVisualStyles.ComboBoxFieldArcClearance, + DeviceDpiInternal); + + return new Padding(inset); + } + + private Rectangle GetNativeComboBaselineEditBounds() + => _nativeComboBaseline.EditBounds; + + private Rectangle GetNativeComboBaselineSimpleListBounds() + => _nativeComboBaseline.SimpleListBounds; + + private int GetNativeComboBaselineSelectionFieldItemHeight() + => _nativeComboBaseline.SelectionFieldItemHeight; + + private int GetModernComboLayoutWriteCount() + => _modernComboLayoutWriteCount; + + private Padding GetModernFieldPadding() + { + SystemVisualSettings settings = Application.SystemVisualSettings; + + int styleInset = ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.ComboBoxStyleInset, + DeviceDpiInternal); + + // Vertical clearance is kept on the classic-derived field model so the modern preferred + // height stays aligned with TextBox (GetPreferredFieldHeight only consumes the vertical + // component). Horizontal clearance uses the minimal modern field inset so the field text + // and drop-down-list caption are not over-inset by classic 3D metrics. + Padding verticalSource = ModernControlVisualStyles.GetFieldPadding( + BorderStyle.Fixed3D, + Padding + new Padding(styleInset), + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + + Padding horizontalSource = GetModernChromeInsets(); + + return new Padding( + left: horizontalSource.Left + Padding.Left, + top: verticalSource.Top, + right: horizontalSource.Right + Padding.Right, + bottom: verticalSource.Bottom); + } + + /// + /// Hit-tests a left-button click against the modern drop-down button. + /// + /// The click location in client coordinates. + /// + /// when the click lands on the drop-down button (or anywhere in a + /// drop-down-list combo, which acts as a single button); otherwise . + /// + /// + /// + /// Modern VisualStyles mode expands the client area over the themed drop-down button via + /// the WM_NCCALCSIZE handling in , so the button is no longer + /// part of the native non-client area. As a result comctl32 stops toggling the list when + /// the button is clicked, and drives from + /// this hit-test instead. + /// + /// + private bool IsModernDropDownButtonClick(Point clientPoint) + { + if (!UsesModernComboAdapter || DropDownStyle == ComboBoxStyle.Simple) + { + return false; + } + + // A drop-down-list combo behaves as a single button, so any click toggles the list. + // An editable combo toggles only when the click lands on the drop-down button itself, + // leaving text-area clicks to the hosted edit control. + return DropDownStyle == ComboBoxStyle.DropDownList + || FlatComboBoxAdapter._dropDownRect.Contains(clientPoint); + } + + private unsafe void ApplyModernDropDownCornerPreference( + HWND dropDownHandle) + { + if (DropDownStyle == ComboBoxStyle.Simple + || dropDownHandle.IsNull + || !OsVersion.IsWindows11_OrGreater()) + { + return; + } + + DWM_WINDOW_CORNER_PREFERENCE preference = UsesModernComboAdapter + ? DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUNDSMALL + : DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_DEFAULT; + + _ = PInvoke.DwmSetWindowAttribute( + dropDownHandle, + DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, + &preference, + sizeof(DWM_WINDOW_CORNER_PREFERENCE)); + } + + private unsafe void RefreshModernDropDownCornerPreference() + { + if (!IsHandleCreated + || DropDownStyle == ComboBoxStyle.Simple + || !OsVersion.IsWindows11_OrGreater()) + { + return; + } + + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + + if (PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo)) + { + ApplyModernDropDownCornerPreference( + comboBoxInfo.hwndList); + } + + ApplyModernDropDownCornerPreference( + _dropDownHandle); + } + + internal static bool SupportsModernDropDownCorners(Version windowsVersion) + => windowsVersion >= new Version(10, 0, 22000); + + internal override Size GetPreferredSizeCore( + Size proposedConstraints) + { + Size preferredSize = base.GetPreferredSizeCore( + proposedConstraints); + + if (UsesModernComboAdapter + && DropDownStyle != ComboBoxStyle.Simple) + { + preferredSize.Height = ModernPreferredHeight; + } + + return preferredSize; + } + + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + ResetComboAdapter(); + ResetHeightCache(); + + // Crossing the modern/classic boundary is reported as VisualStylesModeChangeImpact.Recreate + // (see GetVisualStylesModeChangeImpact), so the base recreates the handle here. That is the + // only clean way to unwind the modern native-window state (the WM_NCCALCSIZE client + // expansion and the per-handle modern baseline); a fresh classic handle then behaves exactly + // as before, and a fresh modern handle captures a clean baseline. + base.OnVisualStylesModeChanged(e); + ApplyModernComboLayout(); + RefreshModernDropDownCornerPreference(); + } + + /// + protected override void OnSystemVisualSettingsChanged( + SystemVisualSettingsChangedEventArgs e) + { + base.OnSystemVisualSettingsChanged(e); + + if (!UsesModernComboAdapter) + { + return; + } + + if ((e.Changed + & (SystemVisualSettingsCategories.TextScale + | SystemVisualSettingsCategories.FocusMetrics)) != 0) + { + ResetComboAdapter(); + ResetHeightCache(); + CommonProperties.xClearPreferredSizeCache(this); + ApplyModernComboLayout(); + + LayoutTransaction.DoLayout( + this, + this, + PropertyNames.SystemVisualSettings); + + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.SystemVisualSettings); + } + } + + if ((e.Changed + & (SystemVisualSettingsCategories.TextScale + | SystemVisualSettingsCategories.FocusMetrics + | SystemVisualSettingsCategories.AccentColor)) != 0) + { + Invalidate(); + } + } + + /// + protected override void OnPaddingChanged(EventArgs e) + { + ResetComboAdapter(); + ResetHeightCache(); + base.OnPaddingChanged(e); + + if (!UsesModernComboAdapter) + { + return; + } + + CommonProperties.xClearPreferredSizeCache(this); + ApplyModernComboLayout(); + Invalidate(); + } + + /// + protected override void OnLayout(LayoutEventArgs levent) + { + base.OnLayout(levent); + ApplyModernComboLayout(); + } + + /// + /// + /// + /// Net11 and later share ComboBox field metrics. Crossing the modern/classic boundary bakes or + /// removes native-window state (the WM_NCCALCSIZE client expansion and the per-handle modern + /// baseline), which cannot be unwound in place, so the handle is recreated. Modern-to-modern + /// transitions only repaint. remains native. + /// + /// + protected override VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + { + if (FlatStyle == FlatStyle.System) + { + return VisualStylesModeChangeImpact.None; + } + + bool oldUsesModernMetrics = oldMode >= VisualStylesMode.Net11; + bool newUsesModernMetrics = newMode >= VisualStylesMode.Net11; + + return oldUsesModernMetrics != newUsesModernMetrics + ? VisualStylesModeChangeImpact.Recreate + : VisualStylesModeChangeImpact.Repaint; + } + + /// + protected override void RescaleConstantsForDpi( + int deviceDpiOld, + int deviceDpiNew) + { + ResetComboAdapter(); + ResetHeightCache(); + base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew); + ApplyModernComboLayout(); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboAdapter.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboAdapter.cs new file mode 100644 index 00000000000..5cbe9dcde95 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboAdapter.cs @@ -0,0 +1,477 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms; + +public partial class ComboBox +{ + /// + /// Paints the modern ComboBox field and drop-down button over the native control. + /// + internal sealed class ModernComboAdapter : FlatComboAdapter + { + private readonly Rectangle _clientBounds; + private readonly Rectangle _buttonBounds; + private readonly FlatStyle _flatStyle; + private readonly ComboBoxStyle _dropDownStyle; + private readonly int _deviceDpi; + + public ModernComboAdapter(ComboBox comboBox) + : base(comboBox, smallButton: false) + { + _clientBounds = comboBox.ClientRectangle; + _flatStyle = comboBox.FlatStyle; + _dropDownStyle = comboBox.DropDownStyle; + _deviceDpi = comboBox.DeviceDpiInternal; + + if (_dropDownStyle == ComboBoxStyle.Simple) + { + _buttonBounds = Rectangle.Empty; + _dropDownRect = Rectangle.Empty; + return; + } + + int buttonWidth = SystemInformation.GetHorizontalScrollBarArrowWidthForDpi( + _deviceDpi) + + ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.ComboBoxButtonExtraWidth, + _deviceDpi); + _buttonBounds = new Rectangle( + comboBox.RightToLeft == RightToLeft.Yes + ? _clientBounds.Left + : Math.Max(_clientBounds.Left, _clientBounds.Right - buttonWidth), + _clientBounds.Top, + Math.Min(buttonWidth, _clientBounds.Width), + _clientBounds.Height); + _dropDownRect = _buttonBounds; + } + + public override bool IsValid(ComboBox combo) + => base.IsValid(combo) + && combo.ClientRectangle == _clientBounds + && combo.FlatStyle == _flatStyle + && combo.DropDownStyle == _dropDownStyle + && combo.DeviceDpiInternal == _deviceDpi + && combo.UsesModernComboAdapter; + + public override void DrawFlatCombo( + ComboBox comboBox, + Graphics graphics) + { + Rectangle clientBounds = comboBox.ClientRectangle; + if (clientBounds.Width <= 1 || clientBounds.Height <= 1) + { + return; + } + + Rectangle borderBounds = clientBounds; + borderBounds.Width--; + borderBounds.Height--; + + using GraphicsStateScope state = new(graphics); + if (_flatStyle != FlatStyle.Flat) + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + } + + PaintFieldSurface( + comboBox, + graphics, + clientBounds); + DrawDropDownListText( + comboBox, + graphics, + clientBounds); + DrawDropDownButton(comboBox, graphics); + + switch (_flatStyle) + { + case FlatStyle.Standard: + DrawStandardFrame( + comboBox, + graphics, + clientBounds, + borderBounds); + break; + case FlatStyle.Flat: + DrawFlatFrame( + comboBox, + graphics, + borderBounds); + break; + case FlatStyle.Popup: + DrawPopupFrame( + comboBox, + graphics, + clientBounds, + borderBounds); + break; + } + } + + private static void PaintFieldSurface( + ComboBox comboBox, + Graphics graphics, + Rectangle bounds) + { + // Paint the entire modern field surface in one pass. This covers any client-area + // frame the native ComboBox drew (the sharp inner rectangle), so only our rounded + // field, the drop-down button, and the flat edit child remain visible. The rounded + // corners are restored afterwards by CutOutRoundedCorners, and the single rounded + // border is drawn last, spanning the full control. + Color background = GetEffectiveBackColor(comboBox); + using var brush = background.GetCachedSolidBrushScope(); + graphics.FillRectangle(brush, bounds); + } + + private static void DrawStandardFrame( + ComboBox comboBox, + Graphics graphics, + Rectangle clientBounds, + Rectangle borderBounds) + { + Color background = GetEffectiveBackColor(comboBox); + Color borderColor = GetBorderColor( + comboBox, + background, + useAccent: false); + + CutOutRoundedCorners( + comboBox, + graphics, + clientBounds); + DrawRoundedBorder( + comboBox, + graphics, + borderBounds, + borderColor); + } + + private static void DrawFlatFrame( + ComboBox comboBox, + Graphics graphics, + Rectangle bounds) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + Color background = GetEffectiveBackColor(comboBox); + Color borderColor = GetBorderColor( + comboBox, + background, + useAccent: false); + using var pen = borderColor.GetCachedPenScope( + GetBorderThickness(comboBox)); + graphics.DrawRectangle(pen, bounds); + } + + private static void DrawPopupFrame( + ComboBox comboBox, + Graphics graphics, + Rectangle clientBounds, + Rectangle borderBounds) + { + Color background = GetEffectiveBackColor(comboBox); + Color borderColor = GetBorderColor( + comboBox, + background, + useAccent: true); + CutOutRoundedCorners( + comboBox, + graphics, + clientBounds); + DrawRoundedBorder( + comboBox, + graphics, + borderBounds, + borderColor); + } + + private void DrawDropDownButton( + ComboBox comboBox, + Graphics graphics) + { + if (_buttonBounds.IsEmpty) + { + return; + } + + Color background = GetEffectiveBackColor(comboBox); + Color buttonColor = comboBox._mousePressed + ? PopupButtonColorMath.Blend( + background, + Application.SystemVisualSettings.AccentColor, + 0.24f) + : comboBox.MouseIsOver || comboBox.ContainsFocus + ? PopupButtonColorMath.Blend( + background, + Application.SystemVisualSettings.AccentColor, + 0.12f) + : PopupButtonColorMath.TowardsContrast( + background, + 0.035f); + if (!comboBox.Enabled) + { + buttonColor = PopupButtonColorMath.Mute( + buttonColor, + 0.55f); + } + + using (var brush = buttonColor.GetCachedSolidBrushScope()) + { + graphics.FillRectangle(brush, _buttonBounds); + } + + Color chevronColor = comboBox.Enabled + ? PopupButtonColorMath.GetReadableForeColor(buttonColor) + : ModernControlColorMath.GetDisabledTextColor( + comboBox.ForeColor, + buttonColor); + int halfWidth = Math.Max( + 2, + ScaleHelper.ScaleToDpi(3, _deviceDpi)); + int halfHeight = Math.Max( + 1, + ScaleHelper.ScaleToDpi(2, _deviceDpi)); + int stroke = Math.Max( + 1, + ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.BorderThickness, + _deviceDpi)); + Point center = new( + _buttonBounds.Left + (_buttonBounds.Width / 2), + _buttonBounds.Top + (_buttonBounds.Height / 2)); + Point[] points = + [ + new(center.X - halfWidth, center.Y - halfHeight), + new(center.X, center.Y + halfHeight), + new(center.X + halfWidth, center.Y - halfHeight) + ]; + using var pen = chevronColor.GetCachedPenScope(stroke); + graphics.DrawLines(pen, points); + } + + private void DrawDropDownListText( + ComboBox comboBox, + Graphics graphics, + Rectangle clientBounds) + { + if (_dropDownStyle != ComboBoxStyle.DropDownList + || comboBox.DrawMode != DrawMode.Normal) + { + return; + } + + Rectangle selectionBounds = clientBounds; + if (comboBox.RightToLeft == RightToLeft.Yes) + { + selectionBounds.X = _buttonBounds.Right; + selectionBounds.Width = Math.Max( + 0, + clientBounds.Right - selectionBounds.X); + } + else + { + selectionBounds.Width = Math.Max( + 0, + _buttonBounds.Left - clientBounds.Left); + } + + bool drawFocusedSelection = comboBox.ContainsFocus; + Color background = drawFocusedSelection + ? SystemColors.Highlight + : GetEffectiveBackColor(comboBox); + using (var backgroundBrush = background.GetCachedSolidBrushScope()) + { + graphics.FillRectangle( + backgroundBrush, + selectionBounds); + } + + Rectangle textBounds = GetDropDownListTextBounds( + comboBox, + clientBounds); + if (textBounds.Width <= 0 + || textBounds.Height <= 0 + || string.IsNullOrEmpty(comboBox.Text)) + { + return; + } + + Color textColor = drawFocusedSelection + ? SystemColors.HighlightText + : comboBox.Enabled + ? comboBox.ShouldSerializeForeColor() + ? comboBox.ForeColor + : PopupButtonColorMath.GetReadableForeColor( + background) + : ModernControlColorMath.GetDisabledTextColor( + comboBox.ForeColor, + background); + TextFormatFlags flags = TextFormatFlags.SingleLine + | TextFormatFlags.VerticalCenter + | TextFormatFlags.EndEllipsis + | TextFormatFlags.NoPrefix + | TextFormatFlags.PreserveGraphicsClipping + | TextFormatFlags.PreserveGraphicsTranslateTransform; + if (comboBox.RightToLeft == RightToLeft.Yes) + { + flags |= TextFormatFlags.Right + | TextFormatFlags.RightToLeft; + } + + TextRenderer.DrawText( + graphics, + comboBox.Text, + comboBox.Font, + textBounds, + textColor, + background, + flags); + + if (drawFocusedSelection + && comboBox.ShowFocusCues) + { + ControlPaint.DrawFocusRectangle( + graphics, + textBounds, + textColor, + background); + } + } + + private Rectangle GetDropDownListTextBounds( + ComboBox comboBox, + Rectangle clientBounds) + { + Padding padding = comboBox.GetModernFieldPadding(); + int left = comboBox.RightToLeft == RightToLeft.Yes + ? _buttonBounds.Right + padding.Left + : clientBounds.Left + padding.Left; + int right = comboBox.RightToLeft == RightToLeft.Yes + ? clientBounds.Right - padding.Right + : _buttonBounds.Left - padding.Right; + + return new Rectangle( + left, + clientBounds.Top + padding.Top, + Math.Max(0, right - left), + Math.Max( + 0, + clientBounds.Height - padding.Vertical)); + } + + private static void CutOutRoundedCorners( + ComboBox comboBox, + Graphics graphics, + Rectangle bounds) + { + using GraphicsPath path = CreateFieldPath( + comboBox, + bounds); + using Region corners = new(bounds); + corners.Exclude(path); + Color parentColor = comboBox.ParentInternal?.BackColor + ?? SystemColors.Control; + using GraphicsStateScope state = new(graphics); + graphics.SetClip( + corners, + CombineMode.Intersect); + ParentBackgroundRenderer.Paint( + comboBox, + graphics, + bounds, + parentColor); + } + + private static void DrawRoundedBorder( + ComboBox comboBox, + Graphics graphics, + Rectangle bounds, + Color borderColor) + { + using GraphicsPath path = CreateFieldPath( + comboBox, + bounds); + int thickness = GetBorderThickness(comboBox); + using var pen = borderColor.GetCachedPenScope(thickness); + graphics.DrawPath(pen, path); + + // The corners are cut out with a non-antialiased region; blend the resulting corner + // artifacts into the parent by tracing the parent color just outside the border. + int radius = GetFieldCornerRadius(comboBox, bounds); + Color parentColor = comboBox.ParentInternal?.BackColor + ?? SystemColors.Control; + ParentBackgroundRenderer.PaintRoundedBorderRegionMitigation( + graphics, + bounds, + new Size(radius, radius), + thickness, + parentColor); + } + + private static Color GetBorderColor( + ComboBox comboBox, + Color background, + bool useAccent) + { + Color borderColor = useAccent + ? Application.SystemVisualSettings.AccentColor + : comboBox.ForeColor; + + return comboBox.Enabled + ? borderColor + : ModernControlColorMath.GetDisabledTextColor( + borderColor, + background); + } + + private static int GetBorderThickness(ComboBox comboBox) + { + SystemVisualSettings settings = Application.SystemVisualSettings; + Size borderMetrics = ModernControlVisualStyles.GetFocusBorderMetrics( + settings.FocusBorderMetrics, + settings.TextScaleFactor, + comboBox.DeviceDpiInternal); + + return Math.Max(borderMetrics.Width, borderMetrics.Height); + } + + private static GraphicsPath CreateFieldPath( + ComboBox comboBox, + Rectangle bounds) + { + GraphicsPath path = new(); + int radius = GetFieldCornerRadius(comboBox, bounds); + path.AddRoundedRectangle( + bounds, + new Size(radius, radius)); + + return path; + } + + private static int GetFieldCornerRadius( + ComboBox comboBox, + Rectangle bounds) + => Math.Clamp( + ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.FieldCornerRadius, + comboBox.DeviceDpiInternal), + 1, + Math.Max( + 1, + Math.Min(bounds.Width, bounds.Height))); + + private static Color GetEffectiveBackColor(ComboBox comboBox) + => comboBox.BackColor.A == byte.MaxValue + ? comboBox.BackColor + : comboBox.ParentInternal?.BackColor + ?? SystemColors.Window; + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboTargetState.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboTargetState.cs new file mode 100644 index 00000000000..7d115e82521 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboTargetState.cs @@ -0,0 +1,23 @@ +// 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; + +public partial class ComboBox +{ + /// + /// Describes the complete native ComboBox target for the current managed state. + /// + private readonly struct ModernComboTargetState + { + public int SelectionFieldItemHeight { get; init; } + + public Padding EditMargins { get; init; } + + public Rectangle EditBounds { get; init; } + + public Rectangle SimpleListBounds { get; init; } + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.NativeComboBaseline.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.NativeComboBaseline.cs new file mode 100644 index 00000000000..3eb94b99bdc --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.NativeComboBaseline.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. + +using System.Drawing; + +namespace System.Windows.Forms; + +public partial class ComboBox +{ + /// + /// Captures the untouched native ComboBox layout for one handle lifetime. + /// + private readonly struct NativeComboBaseline + { + public bool IsCaptured { get; init; } + + public int DeviceDpi { get; init; } + + public int SelectionFieldItemHeight { get; init; } + + public int SelectionFieldFrameHeight { get; init; } + + public Rectangle EditBounds { get; init; } + + public Rectangle SimpleListBounds { get; init; } + + public Size ClientSize { get; init; } + } +} 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..a6c8d06eec0 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 @@ -75,6 +75,11 @@ public partial class ComboBox : ListControl private bool _mouseEvents; private bool _mouseInEdit; + // Modern VisualStyles: set on WM_LBUTTONDOWN when the press lands on the modern drop-down + // button, so the list is toggled on the matching WM_LBUTTONUP. Opening on button-up avoids + // the freshly shown list popup capturing the still-pending button-up and closing again. + private bool _modernDropDownButtonPressed; + private bool _sorted; private bool _fireSetFocus = true; private bool _fireLostFocus = true; @@ -157,7 +162,9 @@ public AutoCompleteMode AutoCompleteMode } bool resetAutoComplete = false; - if (_autoCompleteMode != AutoCompleteMode.None && value == AutoCompleteMode.None) + + if (_autoCompleteMode != AutoCompleteMode.None + && value == AutoCompleteMode.None) { resetAutoComplete = true; } @@ -300,7 +307,8 @@ internal ChildAccessibleObject ChildEditAccessibleObject { get { - _childEditAccessibleObject ??= new ComboBoxChildEditUiaProvider(this, _childEdit!.HWND); + _childEditAccessibleObject ??= + new ComboBoxChildEditUiaProvider(this, _childEdit!.HWND); return _childEditAccessibleObject; } @@ -311,7 +319,11 @@ internal ChildAccessibleObject ChildListAccessibleObject get { _childListAccessibleObject ??= - new ComboBoxChildListUiaProvider(this, DropDownStyle == ComboBoxStyle.Simple ? _childListBox!.HWND : _dropDownHandle); + new ComboBoxChildListUiaProvider( + owningComboBox: this, + childListControlhandle: DropDownStyle == ComboBoxStyle.Simple + ? _childListBox!.HWND + : _dropDownHandle); return _childListAccessibleObject; } @@ -321,15 +333,18 @@ internal AccessibleObject ChildTextAccessibleObject { get { - _childTextAccessibleObject ??= new ComboBoxChildTextUiaProvider(this); + _childTextAccessibleObject ??= + new ComboBoxChildTextUiaProvider(this); return _childTextAccessibleObject; } } - internal void ClearChildEditAccessibleObject() => _childEditAccessibleObject = null; + internal void ClearChildEditAccessibleObject() + => _childEditAccessibleObject = null; - internal void ClearChildListAccessibleObject() => _childListAccessibleObject = null; + internal void ClearChildListAccessibleObject() + => _childListAccessibleObject = null; protected override CreateParams CreateParams { @@ -339,6 +354,7 @@ protected override CreateParams CreateParams cp.ClassName = PInvoke.WC_COMBOBOX; cp.Style |= (int)WINDOW_STYLE.WS_VSCROLL | PInvoke.CBS_HASSTRINGS | PInvoke.CBS_AUTOHSCROLL; cp.ExStyle |= (int)WINDOW_EX_STYLE.WS_EX_CLIENTEDGE; + if (!_integralHeight) { cp.Style |= PInvoke.CBS_NOINTEGRALHEIGHT; @@ -349,15 +365,17 @@ protected override CreateParams CreateParams case ComboBoxStyle.Simple: cp.Style |= PInvoke.CBS_SIMPLE; break; + case ComboBoxStyle.DropDown: cp.Style |= PInvoke.CBS_DROPDOWN; // Make sure we put the height back or we won't be able to size the dropdown! - cp.Height = PreferredHeight; + cp.Height = GetClassicPreferredHeight(); break; + case ComboBoxStyle.DropDownList: cp.Style |= PInvoke.CBS_DROPDOWNLIST; // Comment above... - cp.Height = PreferredHeight; + cp.Height = GetClassicPreferredHeight(); break; } @@ -366,6 +384,7 @@ protected override CreateParams CreateParams case DrawMode.OwnerDrawFixed: cp.Style |= PInvoke.CBS_OWNERDRAWFIXED; break; + case DrawMode.OwnerDrawVariable: cp.Style |= PInvoke.CBS_OWNERDRAWVARIABLE; break; @@ -380,12 +399,7 @@ protected override CreateParams CreateParams /// This is more efficient than setting the size in the control's constructor. /// protected override Size DefaultSize - { - get - { - return new Size(121, PreferredHeight); - } - } + => new Size(121, PreferredHeight); /// /// The ListSource to consume as this ListBox's source of data. @@ -433,7 +447,10 @@ public DrawMode DrawMode [SRDescription(nameof(SR.ComboBoxDropDownWidthDescr))] public int DropDownWidth { - get => Properties.TryGetValue(s_propDropDownWidth, out int dropDownWidth) ? dropDownWidth : Width; + get => Properties.TryGetValue(s_propDropDownWidth, out int dropDownWidth) + ? dropDownWidth + : Width; + set { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); @@ -441,6 +458,7 @@ public int DropDownWidth if (Properties.GetValueOrDefault(s_propDropDownWidth) != value) { Properties.AddValue(s_propDropDownWidth, value); + if (IsHandleCreated) { PInvokeCore.SendMessage(this, PInvoke.CB_SETDROPPEDWIDTH, (WPARAM)value); @@ -459,7 +477,10 @@ public int DropDownWidth [DefaultValue(106)] public int DropDownHeight { - get => Properties.TryGetValue(s_propDropDownHeight, out int dropDownHeight) ? dropDownHeight : DefaultDropDownHeight; + get => Properties.TryGetValue(s_propDropDownHeight, out int dropDownHeight) + ? dropDownHeight + : DefaultDropDownHeight; + set { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); @@ -495,24 +516,46 @@ public bool DroppedDown } } - /// - /// Gets or sets the flat style appearance of the button control. - /// - [SRCategory(nameof(SR.CatAppearance))] - [DefaultValue(FlatStyle.Standard)] - [Localizable(true)] - [SRDescription(nameof(SR.ComboBoxFlatStyleDescr))] - public FlatStyle FlatStyle + public partial FlatStyle FlatStyle { - get - { - return _flatStyle; - } + get => _flatStyle; + set { // valid values are 0x0 to 0x3 SourceGenerated.EnumValidator.Validate(value); + + if (_flatStyle == value) + { + return; + } + + bool usedModernMetrics = UsesModernComboAdapter; _flatStyle = value; + ResetComboAdapter(); + + if (usedModernMetrics != UsesModernComboAdapter) + { + ResetHeightCache(); + CommonProperties.xClearPreferredSizeCache(this); + ApplyModernComboLayout(); + + LayoutTransaction.DoLayout( + this, + this, + PropertyNames.FlatStyle); + + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.FlatStyle); + } + } + + ApplyModernComboLayout(); + RefreshModernDropDownCornerPreference(); Invalidate(); } } @@ -720,7 +763,9 @@ internal bool MouseIsOver // Nothing to see here... Just keep on walking... // Turns out that with Theming off, we don't get quite the same messages as with theming on, so // our drawing gets a little messed up. So in case theming is off, force a draw here. - if ((!ContainsFocus || !Application.RenderWithVisualStyles) && FlatStyle == FlatStyle.Popup) + if (UsesModernComboAdapter + || ((!ContainsFocus || !Application.RenderWithVisualStyles) + && FlatStyle == FlatStyle.Popup)) { Invalidate(); Update(); @@ -729,15 +774,31 @@ internal bool MouseIsOver } } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + /// + /// Gets or sets the distance between the ComboBox text and its field edges. + /// + /// + /// + /// In an effective .NET 11-or-later visual-styles mode, WinForms applies an additional + /// one-logical-pixel style inset around the user-provided value. The public default remains + /// . + /// + /// + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public new Padding Padding { get => base.Padding; set => base.Padding = value; } + private new bool ShouldSerializePadding() + => Padding != DefaultPadding; + + private void ResetPadding() + => Padding = DefaultPadding; + [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] public new event EventHandler? PaddingChanged @@ -757,55 +818,66 @@ public int PreferredHeight { get { - if (!FormattingEnabled) + if (UsesModernComboAdapter + && DropDownStyle != ComboBoxStyle.Simple) { - // do preferred height the old broken way for everett apps - // we need this for compat reasons because (get this) - // (a) everett PreferredHeight was always wrong. - // (b) so, when ComboBox1.Size = actualdefaultsize was called, it would enter setboundscore - // (c) this updated requestedheight - // (d) if the user then changed the combo to simple style, the height did not change. - // We simply cannot match this behavior if PreferredHeight is corrected so that (b) never - // occurs. We simply do not know when Size was set. - - // So in whidbey, the behavior will be: - // (1) user uses default size = setting dropdownstyle=simple will revert to simple height - // (2) user uses nondefault size = setting dropdownstyle=simple will not change height from this value + return ModernPreferredHeight; + } - // In everett - // if the user manually sets Size = (121, 20) in code (usually height gets forced to 21), then he will see Whidbey.(1) above - // user usually uses nondefault size and will experience whidbey.(2) above + return GetClassicPreferredHeight(); + } + } - Size textSize = TextRenderer.MeasureText(LayoutUtils.TestString, Font, new Size(short.MaxValue, (int)(FontHeight * 1.25)), TextFormatFlags.SingleLine); - _prefHeightCache = (short)(textSize.Height + SystemInformation.BorderSize.Height * 8 + Padding.Size.Height); + private int GetClassicPreferredHeight() + { + if (!FormattingEnabled) + { + // do preferred height the old broken way for everett apps + // we need this for compat reasons because (get this) + // (a) everett PreferredHeight was always wrong. + // (b) so, when ComboBox1.Size = actualdefaultsize was called, it would enter setboundscore + // (c) this updated requestedheight + // (d) if the user then changed the combo to simple style, the height did not change. + // We simply cannot match this behavior if PreferredHeight is corrected so that (b) never + // occurs. We simply do not know when Size was set. - return _prefHeightCache; - } + // So in whidbey, the behavior will be: + // (1) user uses default size = setting dropdownstyle=simple will revert to simple height + // (2) user uses nondefault size = setting dropdownstyle=simple will not change height from this value - // Normally we do this sort of calculation in GetPreferredSizeCore which has builtin - // caching, but in this case we can not because PreferredHeight is used in ApplySizeConstraints - // which is used by GetPreferredSize (infinite loop). - if (_prefHeightCache < 0) - { - Size textSize = TextRenderer.MeasureText(LayoutUtils.TestString, Font, new Size(short.MaxValue, (int)(FontHeight * 1.25)), TextFormatFlags.SingleLine); + // In everett + // if the user manually sets Size = (121, 20) in code (usually height gets forced to 21), then he will see Whidbey.(1) above + // user usually uses nondefault size and will experience whidbey.(2) above - // For a "simple" style ComboBox, the preferred height depends on the - // number of items in the ComboBox. - if (DropDownStyle == ComboBoxStyle.Simple) - { - int itemCount = Items.Count + 1; - _prefHeightCache = (short)(textSize.Height * itemCount + SystemInformation.BorderSize.Height * 16 + Padding.Size.Height); - } - else - { - // We do this old school rather than use SizeFromClientSize because CreateParams calls this - // method and SizeFromClientSize calls CreateParams (another infinite loop.) - _prefHeightCache = (short)GetComboHeight(); - } - } + Size textSize = TextRenderer.MeasureText(LayoutUtils.TestString, Font, new Size(short.MaxValue, (int)(FontHeight * 1.25)), TextFormatFlags.SingleLine); + _prefHeightCache = (short)(textSize.Height + SystemInformation.BorderSize.Height * 8 + Padding.Size.Height); return _prefHeightCache; } + + // Normally we do this sort of calculation in GetPreferredSizeCore which has builtin + // caching, but in this case we can not because PreferredHeight is used in ApplySizeConstraints + // which is used by GetPreferredSize (infinite loop). + if (_prefHeightCache < 0) + { + Size textSize = TextRenderer.MeasureText(LayoutUtils.TestString, Font, new Size(short.MaxValue, (int)(FontHeight * 1.25)), TextFormatFlags.SingleLine); + + // For a "simple" style ComboBox, the preferred height depends on the + // number of items in the ComboBox. + if (DropDownStyle == ComboBoxStyle.Simple) + { + int itemCount = Items.Count + 1; + _prefHeightCache = (short)(textSize.Height * itemCount + SystemInformation.BorderSize.Height * 16 + Padding.Size.Height); + } + else + { + // We do this old school rather than use SizeFromClientSize because CreateParams calls this + // method and SizeFromClientSize calls CreateParams (another infinite loop.) + _prefHeightCache = (short)GetComboHeight(); + } + } + + return _prefHeightCache; } // ComboBox.PreferredHeight returns incorrect values @@ -1575,10 +1647,7 @@ private void ChildWndProc(ref Message m) DefChildWndProc(ref m); if (_childEdit is not null && m.HWnd == _childEdit.Handle) { - PInvokeCore.SendMessage( - _childEdit, - PInvokeCore.EM_SETMARGINS, - (WPARAM)(PInvoke.EC_LEFTMARGIN | PInvoke.EC_RIGHTMARGIN)); + ApplyModernComboLayout(); } break; @@ -2332,16 +2401,37 @@ protected override unsafe void OnHandleCreated(EventArgs e) _fromHandleCreate = false; } + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + bool hasComboBoxInfo = PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo); if (Application.IsDarkModeEnabled) { // Style the ComboBox Open-Button: PInvoke.SetWindowTheme(HWND, $"{DarkModeIdentifier}_{ComboBoxButtonThemeIdentifier}", null); - COMBOBOXINFO cInfo = default; - cInfo.cbSize = (uint)sizeof(COMBOBOXINFO); // Style the ComboBox drop-down (including its ScrollBar(s)): - _ = PInvoke.GetComboBoxInfo(HWND, ref cInfo); - PInvoke.SetWindowTheme(cInfo.hwndList, $"{DarkModeIdentifier}_{ExplorerThemeIdentifier}", null); + if (hasComboBoxInfo) + { + PInvoke.SetWindowTheme( + comboBoxInfo.hwndList, + $"{DarkModeIdentifier}_{ExplorerThemeIdentifier}", + null); + } + } + + _nativeComboHandleInitialized = true; + if (UsesModernComboAdapter) + { + InitializeNativeComboBaseline(); + ApplyModernComboLayout(); + } + + if (hasComboBoxInfo) + { + ApplyModernDropDownCornerPreference( + comboBoxInfo.hwndList); } if (_itemsCollection is not null) @@ -2369,6 +2459,11 @@ protected override unsafe void OnHandleCreated(EventArgs e) /// protected override void OnHandleDestroyed(EventArgs e) { + _nativeComboBaseline = default; + _applyingModernComboLayout = false; + _nativeComboHandleInitialized = false; + _normalizingNativeComboBaseline = false; + _modernComboLayoutWriteCount = 0; _dropDownHandle = HWND.Null; if (Disposing) { @@ -2654,7 +2749,10 @@ protected virtual void OnDropDownStyleChanged(EventArgs e) protected override void OnParentBackColorChanged(EventArgs e) { base.OnParentBackColorChanged(e); - if (DropDownStyle == ComboBoxStyle.Simple) + if (DropDownStyle == ComboBoxStyle.Simple + || (UsesModernComboAdapter + && FlatStyle is FlatStyle.Standard + or FlatStyle.Popup)) { Invalidate(); } @@ -2680,6 +2778,8 @@ protected override void OnFontChanged(EventArgs e) } CommonProperties.xClearPreferredSizeCache(this); + ResetComboAdapter(); + ApplyModernComboLayout(); } private void OnAutoCompleteCustomSourceChanged(object? sender, CollectionChangeEventArgs e) @@ -2813,7 +2913,10 @@ private void UpdateControl(bool recreate) protected override void OnResize(EventArgs e) { base.OnResize(e); - if (DropDownStyle == ComboBoxStyle.Simple && IsHandleCreated) + ApplyModernComboLayout(); + + if (DropDownStyle == ComboBoxStyle.Simple + && IsHandleCreated) { // simple style combo boxes have more painting problems than you can shake a stick at InvalidateEverything(); @@ -3388,6 +3491,8 @@ private void UpdateItemHeight() } } } + + ApplyModernComboLayout(); } /// @@ -3461,6 +3566,8 @@ private void WmParentNotify(ref Message m) // Reset the child list accessible object in case the DDL is recreated. // For instance when dialog window containing the ComboBox is reopened. _childListAccessibleObject = null; + ApplyModernDropDownCornerPreference( + _dropDownHandle); } } @@ -3616,6 +3723,46 @@ protected override unsafe void WndProc(ref Message m) { switch (m.MsgInternal) { + // Modern VisualStyles: expand the client area to the full window so the drop-down + // button, which the themed ComboBox otherwise reserves as non-client (outside + // ClientRectangle), becomes part of the client and is covered by our rounded field. + // Simple combos have no drop-down button (their client already spans the full width + // and hosts a permanent list), so they must never be expanded. + case PInvokeCore.WM_NCCALCSIZE: + if (UsesModernComboAdapter + && DropDownStyle != ComboBoxStyle.Simple + && m.WParamInternal != 0u) + { + RECT* ncRects = (RECT*)(nint)m.LParamInternal; + RECT proposedWindow = ncRects[0]; + base.WndProc(ref m); + ncRects[0] = proposedWindow; + return; + } + + base.WndProc(ref m); + break; + + // Modern VisualStyles: comctl32 still reports the drop-down button region as a + // non-client hit (HTVSCROLL) even though WM_NCCALCSIZE folded it into our client + // area, so a button click would arrive as WM_NCLBUTTONDOWN and never reach the + // WM_LBUTTONDOWN hit-test below. Force HTCLIENT across the expanded client so the + // button click is delivered as a normal client message we can act on. + case PInvokeCore.WM_NCHITTEST: + base.WndProc(ref m); + if (UsesModernComboAdapter + && DropDownStyle != ComboBoxStyle.Simple + && m.ResultInternal != PInvoke.HTCLIENT) + { + Point hitPoint = PointToClient(PARAM.ToPoint(m.LParamInternal)); + if (ClientRectangle.Contains(hitPoint)) + { + m.ResultInternal = (LRESULT)(nint)PInvoke.HTCLIENT; + } + } + + break; + // We don't want to fire the focus events twice - // once in the ComboBox and once in the ChildWndProc. case PInvokeCore.WM_SETFOCUS: @@ -3718,10 +3865,29 @@ protected override unsafe void WndProc(ref Message m) WmReflectMeasureItem(ref m); break; case PInvokeCore.WM_LBUTTONDOWN: + // Modern VisualStyles: the drop-down button now lives inside our expanded client + // area, so comctl32 no longer opens the list on a button click. Track the press + // here and toggle on the matching WM_LBUTTONUP (see _modernDropDownButtonPressed). + if (IsModernDropDownButtonClick(PARAM.ToPoint(m.LParamInternal))) + { + _modernDropDownButtonPressed = true; + Focus(); + break; + } + _mouseEvents = true; base.WndProc(ref m); break; case PInvokeCore.WM_LBUTTONUP: + // Modern VisualStyles: complete the drop-down button interaction started on + // WM_LBUTTONDOWN by toggling the list now that the button has been released. + if (_modernDropDownButtonPressed) + { + _modernDropDownButtonPressed = false; + DroppedDown = !DroppedDown; + break; + } + PInvokeCore.GetWindowRect(this, out var rect); Rectangle clientRect = rect; @@ -3755,9 +3921,12 @@ protected override unsafe void WndProc(ref Message m) break; case PInvokeCore.WM_PAINT: + ApplyModernComboLayout(); if (!GetStyle(ControlStyles.UserPaint) - && (FlatStyle == FlatStyle.Flat || FlatStyle == FlatStyle.Popup) - && !(SystemInformation.HighContrast && BackColor == SystemColors.Window)) + && UsesComboAdapter + && (UsesModernComboAdapter + || !(SystemInformation.HighContrast + && BackColor == SystemColors.Window))) { using RegionScope dropDownRegion = new(FlatComboBoxAdapter._dropDownRect); using RegionScope windowRegion = new(Bounds); @@ -3777,6 +3946,52 @@ protected override unsafe void WndProc(ref Message m) using SaveDcScope savedDcState = new(dc); + // Modern VisualStyles: double-buffer the native field paint and our rounded + // overpaint into an offscreen buffer that is blitted in a single pass, so the + // drop-down button and rounded chrome no longer flicker between the native + // paint and our overpaint. Classic Flat keeps painting straight to the DC. + if (UsesModernComboAdapter) + { + Rectangle bufferBounds = ClientRectangle; + if (bufferBounds is { Width: > 0, Height: > 0 }) + { + using Graphics targetGraphics = Graphics.FromHdcInternal((IntPtr)dc); + BufferedGraphicsContext bufferContext = BufferedGraphicsManager.Current; + using BufferedGraphics buffer = bufferContext.Allocate(targetGraphics, bufferBounds); + Graphics bufferGraphics = buffer.Graphics; + + // Let the native ComboBox paint the field (minus our drop-down button) + // into the buffer, exactly as it would into the window DC. + IntPtr bufferHdc = bufferGraphics.GetHdc(); + try + { + if (getRegionSucceeded) + { + PInvokeCore.SelectClipRgn((HDC)bufferHdc, dropDownRegion); + } + + m.WParamInternal = (WPARAM)(HDC)bufferHdc; + DefWndProc(ref m); + + if (getRegionSucceeded) + { + PInvokeCore.SelectClipRgn((HDC)bufferHdc, windowRegion); + } + } + finally + { + bufferGraphics.ReleaseHdcInternal(bufferHdc); + } + + // Overpaint the modern rounded field, button, and text, then blit the + // finished buffer to the window in one clip-limited pass. + FlatComboBoxAdapter.DrawFlatCombo(this, bufferGraphics); + buffer.Render(targetGraphics); + } + + return; + } + if (getRegionSucceeded) { PInvokeCore.SelectClipRgn(dc, dropDownRegion); @@ -3794,24 +4009,58 @@ protected override unsafe void WndProc(ref Message m) FlatComboBoxAdapter.DrawFlatCombo(this, g); // Special handling for disabled DropDownList in dark mode - if (Application.IsDarkModeEnabled && !Enabled && DropDownStyle == ComboBoxStyle.DropDownList) + if (Application.IsDarkModeEnabled + && !Enabled + && DropDownStyle == ComboBoxStyle.DropDownList + && !UsesModernComboAdapter) { // The text area for DropDownList (excluding the dropdown button) Rectangle textBounds = ClientRectangle; - textBounds.Width -= SystemInformation.VerticalScrollBarWidth; - - // Fill the background - using var bgBrush = new SolidBrush(Color.FromArgb(64, 64, 64)); - g.FillRectangle(bgBrush, textBounds); - - // Draw the text - TextRenderer.DrawText( - g, - Text, - Font, - textBounds, - Color.FromArgb(180, 180, 180), - TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + TextFormatFlags textFormatFlags = TextFormatFlags.VerticalCenter + | TextFormatFlags.EndEllipsis; + int buttonWidth = SystemInformation.GetHorizontalScrollBarArrowWidthForDpi( + DeviceDpiInternal); + + if (UsesModernComboAdapter) + { + int frameInset = ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.Fixed3DBorderPadding + + ModernControlVisualStyles.BorderThickness, + DeviceDpiInternal); + textBounds.Inflate( + -frameInset, + -frameInset); + } + + if (RightToLeft == RightToLeft.Yes) + { + textBounds.X += buttonWidth; + textBounds.Width -= buttonWidth; + textFormatFlags |= TextFormatFlags.Right + | TextFormatFlags.RightToLeft; + } + else + { + textBounds.Width -= buttonWidth; + textFormatFlags |= TextFormatFlags.Left; + } + + if (textBounds.Width > 0 + && textBounds.Height > 0) + { + // Fill the background + using var bgBrush = new SolidBrush(Color.FromArgb(64, 64, 64)); + g.FillRectangle(bgBrush, textBounds); + + // Draw the text + TextRenderer.DrawText( + g, + Text, + Font, + textBounds, + Color.FromArgb(180, 180, 180), + textFormatFlags); + } } return; @@ -3822,13 +4071,15 @@ protected override unsafe void WndProc(ref Message m) case PInvokeCore.WM_PRINTCLIENT: // All the fancy stuff we do in OnPaint has to happen again in OnPrint. - if (!GetStyle(ControlStyles.UserPaint) && (FlatStyle == FlatStyle.Flat || FlatStyle == FlatStyle.Popup)) + if (!GetStyle(ControlStyles.UserPaint) + && UsesComboAdapter) { DefWndProc(ref m); if (((nint)m.LParamInternal & PInvoke.PRF_CLIENT) == PInvoke.PRF_CLIENT) { - if (!GetStyle(ControlStyles.UserPaint) && (FlatStyle == FlatStyle.Flat || FlatStyle == FlatStyle.Popup)) + if (!GetStyle(ControlStyles.UserPaint) + && UsesComboAdapter) { using Graphics g = Graphics.FromHdcInternal((HDC)m.WParamInternal); FlatComboBoxAdapter.DrawFlatCombo(this, g); @@ -3861,6 +4112,13 @@ protected override unsafe void WndProc(ref Message m) } _suppressNextWindowsPos = false; + if (DropDownStyle == ComboBoxStyle.Simple + && _nativeComboBaseline.IsCaptured + && !_applyingModernComboLayout) + { + ApplyModernComboLayout(); + } + break; case PInvokeCore.WM_NCDESTROY: @@ -3896,5 +4154,7 @@ private FlatComboAdapter FlatComboBoxAdapter } internal virtual FlatComboAdapter CreateFlatComboAdapterInstance() - => new(this, smallButton: false); + => UsesModernComboAdapter + ? new ModernComboAdapter(this) + : new FlatComboAdapter(this, smallButton: false); } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.FlatStyle.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.FlatStyle.Docs.cs new file mode 100644 index 00000000000..4622a3f886e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.FlatStyle.Docs.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms; + +public partial class GroupBox +{ + /// + /// Gets or sets the flat-style appearance of the group box. + /// + /// + /// + /// In an effective .NET 11-or-later mode, renders a borderless rectangular + /// surface with an enlarged caption, renders a rounded accent outline with an + /// ambient-size inline caption, and renders a Windows-accent header band. + /// remains a native BS_GROUPBOX in every mode. + /// + /// + /// The modern Standard surface intentionally moves down and reserves + /// more space above its content than below. Its caption aligns with . When the + /// ambient font is regular and a matching installed Semibold family exists, modern captions use that real + /// face; otherwise they preserve the ambient weight. AutoSize layouts remeasure automatically. See the + /// + /// .NET 11 VisualStyles layout guidance. + /// + /// + [SRCategory(nameof(SR.CatAppearance))] + [DefaultValue(FlatStyle.Standard)] + [SRDescription(nameof(SR.ButtonFlatStyleDescr))] + public partial FlatStyle FlatStyle { get; set; } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.GroupBoxAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.GroupBoxAccessibleObject.cs index 4aaa530237b..f181d2bd751 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.GroupBoxAccessibleObject.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.GroupBoxAccessibleObject.cs @@ -10,6 +10,8 @@ public partial class GroupBox { internal class GroupBoxAccessibleObject : ControlAccessibleObject { + private const int HeadingLevel2 = 80052; + internal GroupBoxAccessibleObject(GroupBox owner) : base(owner) { } @@ -20,6 +22,10 @@ internal override VARIANT GetPropertyValue(UIA_PROPERTY_ID propertyID) => propertyID switch { UIA_PROPERTY_ID.UIA_IsKeyboardFocusablePropertyId => VARIANT.True, + UIA_PROPERTY_ID.UIA_HeadingLevelPropertyId + when this.TryGetOwnerAs(out GroupBox? owner) + && owner.UsesModernRenderer + => (VARIANT)HeadingLevel2, _ => base.GetPropertyValue(propertyID) }; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.Modern.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.Modern.cs new file mode 100644 index 00000000000..0e343b21332 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.Modern.cs @@ -0,0 +1,598 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Text; +using System.Windows.Forms.Layout; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms; + +public partial class GroupBox +{ + private const string MissingSemiBoldFamily = ""; + + private static readonly ConcurrentDictionary s_semiBoldFamilyNames = + new(StringComparer.OrdinalIgnoreCase); + + private Font? _modernCaptionFont; + private Font? _modernCaptionSourceFont; + private FlatStyle _modernCaptionFlatStyle; + private float _modernCaptionTextScale; + private int _modernCaptionDpi; + + private bool UsesModernRenderer + => OwnerDraw + && EffectiveVisualStylesMode >= VisualStylesMode.Net11; + + private Font ModernCaptionFont + { + get + { + float textScale = Math.Clamp( + Application.SystemVisualSettings.TextScaleFactor, + 1f, + 2.25f); + + if (_modernCaptionFont is null + || !ReferenceEquals(_modernCaptionSourceFont, Font) + || _modernCaptionFlatStyle != FlatStyle + || _modernCaptionTextScale != textScale + || _modernCaptionDpi != DeviceDpiInternal) + { + InvalidateModernCaptionFont(); + + _modernCaptionFont = CreateModernCaptionFont(textScale); + _modernCaptionSourceFont = Font; + _modernCaptionFlatStyle = FlatStyle; + _modernCaptionTextScale = textScale; + _modernCaptionDpi = DeviceDpiInternal; + } + + return _modernCaptionFont; + } + } + + private Rectangle ModernDisplayRectangle + { + get + { + Padding decoration = GetModernDecorationPadding(); + Size size = ClientSize; + + return new Rectangle( + decoration.Left, + decoration.Top, + Math.Max(size.Width - decoration.Horizontal, 0), + Math.Max(size.Height - decoration.Vertical, 0)); + } + } + + private Padding GetModernDecorationPadding() + { + int horizontalInset = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxContentHorizontalInset); + int topInset = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxContentTopInset); + int bottomInset = FlatStyle == FlatStyle.Standard + ? ScaleModernMetric( + ModernControlVisualStyles.GroupBoxContentBottomInset) + : topInset; + int captionHeight = ModernCaptionFont.Height; + int top = FlatStyle switch + { + FlatStyle.Standard => captionHeight + + ScaleModernMetric(ModernControlVisualStyles.GroupBoxCaptionGap) + + topInset, + FlatStyle.Flat => captionHeight + topInset, + FlatStyle.Popup => captionHeight + + (2 * ScaleModernMetric(ModernControlVisualStyles.GroupBoxHeaderVerticalPadding)) + + topInset, + _ => captionHeight + topInset + }; + + return new Padding( + left: Padding.Left + horizontalInset, + top: Padding.Top + top, + right: Padding.Right + horizontalInset, + bottom: Padding.Bottom + bottomInset); + } + + private void DrawModernGroupBox(PaintEventArgs e) + { + Rectangle clientBounds = ClientRectangle; + if (clientBounds.Width <= 1 || clientBounds.Height <= 1) + { + return; + } + + Rectangle strokeBounds = clientBounds; + strokeBounds.Width--; + strokeBounds.Height--; + + using GraphicsStateScope state = new(e.Graphics); + if (FlatStyle != FlatStyle.Standard) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + } + + switch (FlatStyle) + { + case FlatStyle.Standard: + DrawModernCard(e, clientBounds); + break; + case FlatStyle.Flat: + DrawModernOutline(e, strokeBounds); + break; + case FlatStyle.Popup: + DrawModernPopup(e, strokeBounds); + break; + } + } + + private void DrawModernCard(PaintEventArgs e, Rectangle bounds) + { + int captionHeight = ModernCaptionFont.Height; + int captionGap = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxCaptionGap); + Rectangle frameBounds = new( + bounds.Left, + bounds.Top + captionHeight + captionGap, + bounds.Width, + Math.Max(0, bounds.Height - captionHeight - captionGap)); + Rectangle captionBounds = GetStandardCaptionBounds( + bounds, + captionHeight); + + Color effectiveBackColor = DisabledColor; + Color surfaceColor = PopupButtonColorMath.TowardsContrast( + effectiveBackColor, + 0.035f); + if (!Enabled) + { + surfaceColor = PopupButtonColorMath.Mute(surfaceColor, 0.55f); + } + + using var surfaceBrush = surfaceColor.GetCachedSolidBrushScope(); + e.Graphics.FillRectangle(surfaceBrush, frameBounds); + DrawModernCaption( + e.Graphics, + captionBounds, + GetCaptionColor(effectiveBackColor)); + } + + private void DrawModernOutline(PaintEventArgs e, Rectangle bounds) + { + int captionHeight = ModernCaptionFont.Height; + int inset = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxContentHorizontalInset); + Rectangle frameBounds = new( + bounds.Left, + bounds.Top + (captionHeight / 2), + bounds.Width, + Math.Max(0, bounds.Height - (captionHeight / 2))); + Rectangle captionBounds = new( + bounds.Left + inset, + bounds.Top, + Math.Max(0, bounds.Width - (2 * inset)), + captionHeight); + Color effectiveBackColor = DisabledColor; + Color borderColor = Application.SystemVisualSettings.AccentColor; + if (!Enabled) + { + borderColor = PopupButtonColorMath.Mute(borderColor, 0.55f); + } + + DrawRoundedFrame(e.Graphics, frameBounds, borderColor); + + Rectangle captionBackground = GetCaptionTextBounds( + e.Graphics, + captionBounds); + if (!captionBackground.IsEmpty) + { + captionBackground.Inflate( + GetModernBorderThickness() + 2, + 0); + PaintBackground(e, captionBackground); + } + + DrawModernCaption( + e.Graphics, + captionBounds, + GetCaptionColor(effectiveBackColor)); + } + + private void DrawModernPopup(PaintEventArgs e, Rectangle bounds) + { + int verticalPadding = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxHeaderVerticalPadding); + int horizontalPadding = ScaleModernMetric( + ModernControlVisualStyles.GroupBoxHeaderHorizontalPadding); + int headerHeight = ModernCaptionFont.Height + (2 * verticalPadding); + Rectangle headerBounds = new( + bounds.Left, + bounds.Top, + bounds.Width, + Math.Min(bounds.Height, headerHeight)); + Rectangle captionBounds = new( + bounds.Left + horizontalPadding, + bounds.Top + verticalPadding, + Math.Max(0, bounds.Width - (2 * horizontalPadding)), + ModernCaptionFont.Height); + + Color bodyColor = BackColor.A == 0 + ? Color.Transparent + : BackColor; + Color headerColor = Application.SystemVisualSettings.AccentColor; + Color borderColor = PopupButtonColorMath.TowardsContrast( + headerColor, + 0.2f); + if (!Enabled) + { + bodyColor = PopupButtonColorMath.Mute(bodyColor, 0.55f); + headerColor = PopupButtonColorMath.Mute(headerColor, 0.55f); + borderColor = PopupButtonColorMath.Mute(borderColor, 0.55f); + } + + using GraphicsPath path = CreateModernFramePath(bounds); + using (var bodyBrush = bodyColor.GetCachedSolidBrushScope()) + { + e.Graphics.FillPath(bodyBrush, path); + } + + using (GraphicsStateScope state = new(e.Graphics)) + { + e.Graphics.SetClip(headerBounds); + using var headerBrush = headerColor.GetCachedSolidBrushScope(); + e.Graphics.FillPath(headerBrush, path); + } + + using var borderPen = borderColor.GetCachedPenScope( + GetModernBorderThickness()); + e.Graphics.DrawPath(borderPen, path); + + // The rounded frame is clipped with a non-antialiased region; blend the resulting corner + // artifacts into the parent by tracing the parent color just outside the border. + int frameRadius = GetModernFrameCornerRadius(bounds); + ParentBackgroundRenderer.PaintRoundedBorderRegionMitigation( + e.Graphics, + bounds, + new Size(frameRadius, frameRadius), + GetModernBorderThickness(), + ParentInternal?.BackColor ?? BackColor); + + Color captionColor = Enabled + ? PopupButtonColorMath.GetReadableForeColor(headerColor) + : ModernControlColorMath.GetDisabledTextColor( + PopupButtonColorMath.GetReadableForeColor(headerColor), + headerColor); + DrawModernCaption(e.Graphics, captionBounds, captionColor); + } + + private void DrawRoundedFrame( + Graphics graphics, + Rectangle bounds, + Color borderColor) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + using GraphicsPath path = CreateModernFramePath(bounds); + using var pen = borderColor.GetCachedPenScope( + GetModernBorderThickness()); + graphics.DrawPath(pen, path); + + // The rounded frame is clipped with a non-antialiased region; blend the resulting corner + // artifacts into the parent by tracing the parent color just outside the border. + int frameRadius = GetModernFrameCornerRadius(bounds); + ParentBackgroundRenderer.PaintRoundedBorderRegionMitigation( + graphics, + bounds, + new Size(frameRadius, frameRadius), + GetModernBorderThickness(), + ParentInternal?.BackColor ?? BackColor); + } + + private GraphicsPath CreateModernFramePath(Rectangle bounds) + { + GraphicsPath path = new(); + int radius = GetModernFrameCornerRadius(bounds); + path.AddRoundedRectangle(bounds, new Size(radius, radius)); + + return path; + } + + private int GetModernFrameCornerRadius(Rectangle bounds) + => Math.Clamp( + ScaleModernMetric(ModernControlVisualStyles.GroupBoxCornerRadius), + 1, + Math.Max(1, Math.Min(bounds.Width, bounds.Height))); + + private Color GetCaptionColor(Color backgroundColor) + => Enabled + ? ForeColor + : ModernControlColorMath.GetDisabledTextColor( + ForeColor, + backgroundColor); + + private Rectangle GetStandardCaptionBounds( + Rectangle bounds, + int captionHeight) + => new( + bounds.Left + Padding.Left, + bounds.Top + Padding.Top, + Math.Max(0, bounds.Width - Padding.Horizontal), + captionHeight); + + private Font CreateModernCaptionFont(float textScale) + { + float styleScale = FlatStyle == FlatStyle.Flat + ? 1f + : ModernControlVisualStyles.GroupBoxCaptionFontScale; + string semiBoldFamilyName = Font.Style == FontStyle.Regular + ? s_semiBoldFamilyNames.GetOrAdd( + Font.FontFamily.Name, + FindSemiBoldFamilyName) + : MissingSemiBoldFamily; + + if (semiBoldFamilyName.Length == 0) + { + return new Font( + Font.FontFamily, + Font.Size * styleScale * textScale, + Font.Style, + Font.Unit, + Font.GdiCharSet, + Font.GdiVerticalFont); + } + + using FontFamily semiBoldFamily = new(semiBoldFamilyName); + return new Font( + semiBoldFamily, + Font.Size * styleScale * textScale, + FontStyle.Regular, + Font.Unit, + Font.GdiCharSet, + Font.GdiVerticalFont); + } + + internal static string FindSemiBoldFamilyName(string sourceFamilyName) + { + string baseFamilyName = sourceFamilyName.EndsWith( + " Regular", + StringComparison.OrdinalIgnoreCase) + ? sourceFamilyName[..^" Regular".Length] + : sourceFamilyName; + string expectedName = IsSemiBoldFamilyName(sourceFamilyName) + ? sourceFamilyName + : $"{baseFamilyName} Semibold"; + + using InstalledFontCollection installedFonts = new(); + foreach (FontFamily family in installedFonts.Families) + { + if (family.Name.Equals( + expectedName, + StringComparison.OrdinalIgnoreCase)) + { + return family.Name; + } + } + + return MissingSemiBoldFamily; + } + + internal static bool IsSemiBoldFamilyName(string familyName) + => familyName.Contains( + "Semibold", + StringComparison.OrdinalIgnoreCase) + || familyName.Contains( + "Semi Bold", + StringComparison.OrdinalIgnoreCase) + || familyName.Contains( + "Demibold", + StringComparison.OrdinalIgnoreCase) + || familyName.Contains( + "Demi Bold", + StringComparison.OrdinalIgnoreCase); + + private int GetModernBorderThickness() + { + SystemVisualSettings settings = Application.SystemVisualSettings; + Size borderMetrics = ModernControlVisualStyles.GetFocusBorderMetrics( + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + + return Math.Max(borderMetrics.Width, borderMetrics.Height); + } + + private Rectangle GetCaptionTextBounds( + Graphics graphics, + Rectangle availableBounds) + { + if (string.IsNullOrEmpty(Text) + || availableBounds.Width <= 0 + || availableBounds.Height <= 0) + { + return Rectangle.Empty; + } + + Size measuredSize = UseCompatibleTextRendering + ? Size.Ceiling( + graphics.MeasureString( + Text, + ModernCaptionFont, + availableBounds.Width)) + : TextRenderer.MeasureText( + graphics, + Text, + ModernCaptionFont, + availableBounds.Size, + TextFormatFlags.SingleLine + | TextFormatFlags.NoPadding); + int width = Math.Min(measuredSize.Width, availableBounds.Width); + int x = RightToLeft == RightToLeft.Yes + ? availableBounds.Right - width + : availableBounds.Left; + + return new Rectangle( + x, + availableBounds.Top, + width, + availableBounds.Height); + } + + private void DrawModernCaption( + Graphics graphics, + Rectangle bounds, + Color color) + { + if (string.IsNullOrEmpty(Text) || bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + if (UseCompatibleTextRendering) + { + using var brush = color.GetCachedSolidBrushScope(); + using StringFormat format = new() + { + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Center, + HotkeyPrefix = ShowKeyboardCues + ? HotkeyPrefix.Show + : HotkeyPrefix.Hide, + Trimming = StringTrimming.EllipsisCharacter + }; + + if (RightToLeft == RightToLeft.Yes) + { + format.FormatFlags |= StringFormatFlags.DirectionRightToLeft; + } + + graphics.DrawString(Text, ModernCaptionFont, brush, bounds, format); + return; + } + + TextFormatFlags flags = TextFormatFlags.SingleLine + | TextFormatFlags.VerticalCenter + | TextFormatFlags.EndEllipsis + | TextFormatFlags.PreserveGraphicsClipping + | TextFormatFlags.PreserveGraphicsTranslateTransform; + if (!ShowKeyboardCues) + { + flags |= TextFormatFlags.HidePrefix; + } + + if (RightToLeft == RightToLeft.Yes) + { + flags |= TextFormatFlags.Right | TextFormatFlags.RightToLeft; + } + + TextRenderer.DrawText( + graphics, + Text, + ModernCaptionFont, + bounds, + color, + flags); + } + + private int ScaleModernMetric(int value) + => ScaleHelper.ScaleToDpi(value, DeviceDpiInternal); + + private void InvalidateModernCaptionFont() + { + _modernCaptionFont?.Dispose(); + _modernCaptionFont = null; + _modernCaptionSourceFont = null; + _modernCaptionFlatStyle = default; + _modernCaptionTextScale = 0f; + _modernCaptionDpi = 0; + } + + /// + protected override void OnSystemVisualSettingsChanged( + SystemVisualSettingsChangedEventArgs e) + { + base.OnSystemVisualSettingsChanged(e); + + if (!UsesModernRenderer) + { + return; + } + + if ((e.Changed & SystemVisualSettingsCategories.TextScale) != 0) + { + InvalidateModernCaptionFont(); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayout( + this, + this, + PropertyNames.SystemVisualSettings); + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.SystemVisualSettings); + } + } + + if ((e.Changed + & (SystemVisualSettingsCategories.TextScale + | SystemVisualSettingsCategories.AccentColor + | SystemVisualSettingsCategories.FocusMetrics)) != 0) + { + Invalidate(); + } + } + + /// + /// + /// + /// Net11 and later share GroupBox metrics. Crossing the classic or disabled boundary + /// changes the caption and content geometry, while modern-to-modern transitions repaint. + /// + /// + protected override VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + { + if (FlatStyle == FlatStyle.System) + { + return VisualStylesModeChangeImpact.None; + } + + bool oldUsesModernMetrics = oldMode >= VisualStylesMode.Net11; + bool newUsesModernMetrics = newMode >= VisualStylesMode.Net11; + + return oldUsesModernMetrics != newUsesModernMetrics + ? VisualStylesModeChangeImpact.Metrics + : VisualStylesModeChangeImpact.Repaint; + } + + /// + protected override void RescaleConstantsForDpi( + int deviceDpiOld, + int deviceDpiNew) + { + InvalidateModernCaptionFont(); + base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + InvalidateModernCaptionFont(); + } + + base.Dispose(disposing); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.cs index c4f606b8446..70365a4b2fc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.cs @@ -143,6 +143,11 @@ public override Rectangle DisplayRectangle { get { + if (UsesModernRenderer) + { + return ModernDisplayRectangle; + } + Size size = ClientSize; if (_fontHeight == -1) @@ -168,10 +173,7 @@ public override Rectangle DisplayRectangle } } - [SRCategory(nameof(SR.CatAppearance))] - [DefaultValue(FlatStyle.Standard)] - [SRDescription(nameof(SR.ButtonFlatStyleDescr))] - public FlatStyle FlatStyle + public partial FlatStyle FlatStyle { get { @@ -189,6 +191,10 @@ public FlatStyle FlatStyle bool originalOwnerDraw = OwnerDraw; _flatStyle = value; + if (value != FlatStyle.System) + { + InvalidateModernCaptionFont(); + } // In CreateParams, we pick our class style based on OwnerDraw // if this has changed we need to recreate @@ -209,6 +215,22 @@ public FlatStyle FlatStyle { Refresh(); } + + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayout( + this, + this, + PropertyNames.FlatStyle); + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.FlatStyle); + } + } } } @@ -395,6 +417,13 @@ public bool UseCompatibleTextRendering protected override void OnPaint(PaintEventArgs e) { + if (UsesModernRenderer) + { + DrawModernGroupBox(e); + base.OnPaint(e); + return; + } + // BACKCOMPAT requirement: // // Why the Height/Width < 10 check? This is because uxtheme doesn't seem to handle those cases similar to @@ -597,6 +626,18 @@ private void DrawGroupBox(PaintEventArgs e) internal override Size GetPreferredSizeCore(Size proposedSize) { + if (UsesModernRenderer) + { + Padding decoration = GetModernDecorationPadding(); + Size modernBorderSize = SizeFromClientSize(Size.Empty); + Size totalDecoration = modernBorderSize + decoration.Size; + Size preferredSize = LayoutEngine.GetPreferredSize( + this, + proposedSize - totalDecoration); + + return preferredSize + totalDecoration; + } + // Translating 0,0 from ClientSize to actual Size tells us how much space is required for the borders. Size borderSize = SizeFromClientSize(Size.Empty); Size totalPadding = borderSize + new Size(0, _fontHeight) + Padding.Size; @@ -607,6 +648,7 @@ internal override Size GetPreferredSizeCore(Size proposedSize) protected override void OnFontChanged(EventArgs e) { + InvalidateModernCaptionFont(); _fontHeight = -1; _cachedFont = null; Invalidate(); @@ -637,6 +679,7 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) // be called on us by our parent. _fontHeight = -1; _cachedFont = null; + InvalidateModernCaptionFont(); } base.ScaleControl(factor, specified); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/PropertyGrid/PropertyGrid.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/PropertyGrid/PropertyGrid.cs index 56110e517ac..c5ad94413eb 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/PropertyGrid/PropertyGrid.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/PropertyGrid/PropertyGrid.cs @@ -80,6 +80,10 @@ public partial class PropertyGrid : ContainerControl, IComPropertyBrowser, IProp private Color _selectedItemWithFocusBackColor = SystemColors.Highlight; private bool _canShowVisualStyleGlyphs = true; + // The PropertyGrid pins its effective VisualStylesMode to Classic (see the VisualStylesMode override). + // A value assigned through the setter is remembered here for potential future use only. + private VisualStylesMode _requestedVisualStylesMode = VisualStylesMode.Inherit; + private AttributeCollection? _browsableAttributes; private SnappableControl? _targetMove; @@ -819,6 +823,39 @@ public Color LineColor remove => base.PaddingChanged -= value; } + /// + /// Gets or sets how the renders itself when visual styles are applied. + /// + /// + /// Always . Assigning a different value is remembered but does + /// not change how the grid or its hosted editing controls render. + /// + /// + /// + /// The pins its effective to + /// . The editing controls hosted inside the grid (the value + /// text boxes and drop-down editors) read the ambient from their + /// parent. They must render in the classic style so their frames and glyphs line up with the grid's + /// own fixed layout. Because this override always returns and + /// is a virtual ambient property, those children never switch to a + /// modern renderer, regardless of or the parent + /// form's setting. + /// + /// + /// A value assigned through the setter is stored for potential future use but currently has no + /// effect; the getter continues to report . This keeps the + /// surface forward compatible for a later release in which the grid may honor a modern mode. + /// + /// + [Browsable(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public override VisualStylesMode VisualStylesMode + { + get => VisualStylesMode.Classic; + set => _requestedVisualStylesMode = value; + } + /// /// Sets or gets the current property sort type, which can be /// PropertySort.Categorized or PropertySort.Alphabetical. diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs index 4a6c7972a34..3affbca00ed 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.cs @@ -309,7 +309,9 @@ protected override CreateParams CreateParams // Remove the WS_BORDER style from the control, if we're trying to set it, // to prevent the control from displaying the single point rectangle around the 3D border - if (BorderStyle == BorderStyle.FixedSingle && ((cp.Style & (int)WINDOW_STYLE.WS_BORDER) != 0)) + if (EffectiveVisualStylesMode < VisualStylesMode.Net11 + && BorderStyle == BorderStyle.FixedSingle + && ((cp.Style & (int)WINDOW_STYLE.WS_BORDER) != 0)) { cp.Style &= ~(int)WINDOW_STYLE.WS_BORDER; cp.ExStyle |= (int)WINDOW_EX_STYLE.WS_EX_CLIENTEDGE; @@ -319,7 +321,34 @@ protected override CreateParams CreateParams } } - // public bool CanUndo {}; <-- inherited from TextBoxBase + /// + /// RichEdit (MSFTEDIT) reserves its own border and scrollbar space during + /// WM_NCCALCSIZE, so the modern Visual Styles padding band is carved on top of the client + /// rectangle the native handler produces. + /// + private protected override bool ReservesNativeNonClientArea => true; + + /// + /// RichEdit reserves the scrollbar space itself while processing WM_NCCALCSIZE (see + /// ). Return the configured reservation for preferred-size + /// measurement; the native client-area calculation explicitly excludes it. + /// + private protected override Padding GetScrollBarPadding() + { + Padding padding = Padding.Empty; + + if (Multiline && !WordWrap && (ScrollBars & RichTextBoxScrollBars.Horizontal) != 0) + { + padding.Bottom = SystemInformation.GetHorizontalScrollBarHeightForDpi(DeviceDpiInternal); + } + + if (Multiline && (ScrollBars & RichTextBoxScrollBars.Vertical) != 0) + { + padding.Right = SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpiInternal); + } + + return padding; + } /// /// Controls whether or not the rich edit control will automatically highlight URLs. @@ -425,6 +454,12 @@ public override Font Font internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // TextBoxBase owns the modern inset, user Padding, and scrollbar geometry. + return base.GetPreferredSizeCore(proposedConstraints); + } + Size scrollBarPadding = Size.Empty; // If the RTB is multiline, we won't have a horizontal scrollbar. @@ -645,6 +680,7 @@ public RichTextBoxScrollBars ScrollBars using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.ScrollBars)) { _richTextBoxFlags[s_scrollBarsSection] = (int)value; + CommonProperties.xClearPreferredSizeCache(this); RecreateHandle(); } } @@ -2246,6 +2282,68 @@ private static bool GetCharInCharSet(char c, char[] charSet, bool negate) public override int GetLineFromCharIndex(int index) => (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_EXLINEFROMCHAR, 0, index); + /// + /// Scrolls the caret into view, showing as much of the surrounding text as possible. This overrides + /// the base edit-control behavior so the RichEdit-specific handling lives with the RichTextBox. + /// + private protected override unsafe void ScrollToCaretCore() + { + using ComScope richEdit = new(null); + + if (PInvokeCore.SendMessage( + hWnd: this, + Msg: PInvokeCore.EM_GETOLEINTERFACE, + wParam: 0, + lParam: (void**)richEdit) == 0) + { + // No OLE interface available; fall back to the plain edit behavior. + base.ScrollToCaretCore(); + return; + } + + using var textDocument = richEdit.TryQuery(out HRESULT hr); + + if (hr.Succeeded) + { + // When the user calls RichTextBox::ScrollToCaret we want the RichTextBox to show as much text as + // possible. Here is how we do that: + // + // 1. We scroll the RichTextBox all the way to the bottom so the last line of text is the last visible line. + // 2. We get the first visible line. + // 3. If the first visible line is smaller than the start of the selection, then we are done: + // The selection fits inside the RichTextBox display rectangle. + // 4. Otherwise, scroll the selection to the top of the RichTextBox. + + GetSelectionStartAndLength(out int selStart, out int selLength); + int selStartLine = GetLineFromCharIndex(selStart); + + using ComScope windowTextRange = new(null); + textDocument.Value->Range(WindowText.Length - 1, WindowText.Length - 1, windowTextRange).ThrowOnFailure(); + + // 1. Scroll the RichTextBox all the way to the bottom + windowTextRange.Value->ScrollIntoView((int)tomConstants.tomEnd).ThrowOnFailure(); + + // 2. Get the first visible line. + int firstVisibleLine = (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_GETFIRSTVISIBLELINE); + + // 3. If the first visible line is smaller than the start of the selection, we are done. + if (firstVisibleLine <= selStartLine) + { + return; + } + else + { + // 4. Scroll the selection to the top of the RichTextBox. + using ComScope selectionTextRange = new(null); + textDocument.Value->Range(selStart, selStart + selLength, selectionTextRange).ThrowOnFailure(); + selectionTextRange.Value->ScrollIntoView((int)tomConstants.tomStart).ThrowOnFailure(); + return; + } + } + + base.ScrollToCaretCore(); + } + /// /// Returns the location of the character at the given index. /// @@ -2500,6 +2598,11 @@ protected override void OnHandleCreated(EventArgs e) SendZoomFactor(_zoomMultiplier); + // RichEdit does not send the WM_CTLCOLOR messages that a plain EDIT control uses to lazily trigger + // the modern Visual Styles client-area carve, so provoke it explicitly once the handle exists. + // This is a no-op unless EffectiveVisualStylesMode is Net11 or above. + RecalculateVisualStylesClientArea(); + SystemEvents.UserPreferenceChanged += UserPreferenceChangedHandler; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs index d0670ce3e1f..08c4fd56f9a 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/MaskedTextBox.cs @@ -2884,7 +2884,9 @@ private void WmPrint(ref Message m) { base.WndProc(ref m); if (((nint)m.LParamInternal & PInvoke.PRF_NONCLIENT) != 0 - && Application.RenderWithVisualStyles && BorderStyle == BorderStyle.Fixed3D) + && Application.RenderWithVisualStyles + && BorderStyle == BorderStyle.Fixed3D + && EffectiveVisualStylesMode < VisualStylesMode.Net11) { using Graphics g = Graphics.FromHdc((HDC)m.WParamInternal); Rectangle rect = new(0, 0, Size.Width - 1, Size.Height - 1); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs index e3fce96d521..5f29b798482 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBox.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Drawing; using System.Drawing.Design; +using System.Windows.Forms.Layout; using System.Windows.Forms.VisualStyles; using Windows.Win32.UI.Accessibility; @@ -383,6 +384,8 @@ public ScrollBars ScrollBars _scrollBars = value; RecreateHandle(); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.ScrollBars); } } } @@ -391,6 +394,13 @@ public ScrollBars ScrollBars internal override Size GetPreferredSizeCore(Size proposedConstraints) { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // TextBoxBase already includes the native scrollbar reservation in the modern geometry. + // Applying the legacy adjustment here would count each scrollbar twice. + return base.GetPreferredSizeCore(proposedConstraints); + } + Size scrollBarPadding = Size.Empty; if (Multiline && !WordWrap && (ScrollBars & ScrollBars.Horizontal) != 0) @@ -411,6 +421,31 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) return prefSize + scrollBarPadding; } + private protected override Padding GetScrollBarPadding() + { + if (IsHandleCreated) + { + return base.GetScrollBarPadding(); + } + + Padding padding = Padding.Empty; + + if (Multiline + && !WordWrap + && _textAlign == HorizontalAlignment.Left + && (_scrollBars & ScrollBars.Horizontal) != 0) + { + padding.Bottom = SystemInformation.GetHorizontalScrollBarHeightForDpi(DeviceDpiInternal); + } + + if (Multiline && (_scrollBars & ScrollBars.Vertical) != 0) + { + padding.Right = SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpiInternal); + } + + return padding; + } + /// /// Gets or sets the current text in the text box. /// @@ -800,8 +835,12 @@ private void ResetAutoCompleteCustomSource() private void WmPrint(ref Message m) { base.WndProc(ref m); + + // In modern Visual Styles mode the base TextBoxBase NC painting owns the border chrome, so we + // must not also draw the classic Fixed3D edge here - that would double-draw over the modern chrome. if (((nint)m.LParamInternal & PInvoke.PRF_NONCLIENT) != 0 && Application.RenderWithVisualStyles - && BorderStyle == BorderStyle.Fixed3D) + && BorderStyle == BorderStyle.Fixed3D + && EffectiveVisualStylesMode < VisualStylesMode.Net11) { using Graphics g = Graphics.FromHdc((HDC)m.WParamInternal); Rectangle rect = new(0, 0, Size.Width - 1, Size.Height - 1); diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs index 0ec7ce5fdb5..6a733a37fca 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TextBox/TextBoxBase.cs @@ -5,12 +5,13 @@ using System.ComponentModel; using System.Drawing; using System.Drawing.Design; +using System.Drawing.Drawing2D; using System.Runtime.InteropServices; using System.Text; using System.Windows.Forms.Layout; +using System.Windows.Forms.Rendering.Animation; using Windows.Win32.System.Variant; using Windows.Win32.UI.Accessibility; -using Windows.Win32.UI.Controls.RichEdit; namespace System.Windows.Forms; @@ -51,6 +52,17 @@ public abstract partial class TextBoxBase : Control /// The current border for this edit control. /// private BorderStyle _borderStyle = BorderStyle.Fixed3D; + private AnimatedFocusIndicatorRenderer? _focusIndicatorRenderer; + + private const OBJECT_IDENTIFIER HorizontalScrollBarObjectId = (OBJECT_IDENTIFIER)(-6); + private const OBJECT_IDENTIFIER VerticalScrollBarObjectId = (OBJECT_IDENTIFIER)(-5); + private const uint StateSystemInvisible = 0x00008000; + + /// + /// One-shot latch that gates the single NC-calc round trip used to carve the modern Visual + /// Styles padding band. Set by and reset on handle recreation. + /// + private bool _triggerNewClientSizeRequest; /// /// Controls the maximum length of text in the edit control. @@ -83,8 +95,7 @@ public abstract partial class TextBoxBase : Control private BitVector32 _textBoxFlags; /// - /// Creates a new TextBox control. Uses the parent's current font and color - /// set. + /// Creates a new TextBox control. Uses the parent's current font and color set. /// internal TextBoxBase() : base() { @@ -116,10 +127,7 @@ internal TextBoxBase() : base() [SRDescription(nameof(SR.TextBoxAcceptsTabDescr))] public bool AcceptsTab { - get - { - return _textBoxFlags[s_acceptsTab]; - } + get => _textBoxFlags[s_acceptsTab]; set { if (_textBoxFlags[s_acceptsTab] != value) @@ -148,10 +156,8 @@ public event EventHandler? AcceptsTabChanged [SRDescription(nameof(SR.TextBoxShortcutsEnabledDescr))] public virtual bool ShortcutsEnabled { - get - { - return _textBoxFlags[s_shortcutsEnabled]; - } + get => _textBoxFlags[s_shortcutsEnabled]; + set { s_shortcutsToDisable ??= @@ -193,6 +199,7 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) if (_textBoxFlags[s_readOnly]) { int k = (int)keyData; + if (k is ((int)Shortcut.CtrlL) // align left or ((int)Shortcut.CtrlR) // align right or ((int)Shortcut.CtrlE) // align center @@ -202,27 +209,29 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) } } - if (!ReadOnly && (keyData == (Keys.Control | Keys.Back) || keyData == (Keys.Control | Keys.Shift | Keys.Back))) + if (ReadOnly + || (keyData != (Keys.Control | Keys.Back) + && keyData != (Keys.Control | Keys.Shift | Keys.Back))) { - if (SelectionLength != 0) - { - SetSelectedTextInternal(string.Empty, clearUndo: false); - } - else if (SelectionStart != 0) - { - int boundaryStart = ClientUtils.GetWordBoundaryStart(Text, SelectionStart); - int length = SelectionStart - boundaryStart; - BeginUpdateInternal(); - SelectionStart = boundaryStart; - SelectionLength = length; - EndUpdateInternal(); - SetSelectedTextInternal(string.Empty, clearUndo: false); - } + return returnedValue; + } - return true; + if (SelectionLength != 0) + { + SetSelectedTextInternal(string.Empty, clearUndo: false); + } + else if (SelectionStart != 0) + { + int boundaryStart = ClientUtils.GetWordBoundaryStart(Text, SelectionStart); + int length = SelectionStart - boundaryStart; + BeginUpdateInternal(); + SelectionStart = boundaryStart; + SelectionLength = length; + EndUpdateInternal(); + SetSelectedTextInternal(string.Empty, clearUndo: false); } - return returnedValue; + return true; } /// @@ -242,10 +251,8 @@ protected override bool ProcessCmdKey(ref Message msg, Keys keyData) [EditorBrowsable(EditorBrowsableState.Never)] public override bool AutoSize { - get - { - return _textBoxFlags[s_autoSize]; - } + get => _textBoxFlags[s_autoSize]; + set { // Note that we intentionally do not call base. TextBoxes size themselves by @@ -356,8 +363,24 @@ public BorderStyle BorderStyle SourceGenerated.EnumValidator.Validate(value); _borderStyle = value; + _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); + CommonProperties.xClearPreferredSizeCache(this); + + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + AdjustHeight(false); + } + UpdateStyles(); - RecreateHandle(); + + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11 && IsHandleCreated) + { + RecalculateVisualStylesClientArea(); + } + else + { + RecreateHandle(); + } // PreferredSize depends on BorderStyle : thru CreateParams.ExStyle in User32!AdjustRectEx. // So when the BorderStyle changes let the parent of this control know about it. @@ -377,9 +400,11 @@ public event EventHandler? BorderStyleChanged remove => Events.RemoveHandler(s_borderStyleChangedEvent, value); } - internal virtual bool CanRaiseTextChangedEvent => true; + internal virtual bool CanRaiseTextChangedEvent + => true; - protected override bool CanEnableIme => !(ReadOnly || PasswordProtect) && base.CanEnableIme; + protected override bool CanEnableIme + => !(ReadOnly || PasswordProtect) && base.CanEnableIme; /// /// Gets a value indicating whether the user can undo the previous operation in a text box control. @@ -388,7 +413,9 @@ public event EventHandler? BorderStyleChanged [Browsable(false)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] [SRDescription(nameof(SR.TextBoxCanUndoDescr))] - public bool CanUndo => IsHandleCreated && (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_CANUNDO) != 0; + public bool CanUndo + => IsHandleCreated + && (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_CANUNDO) != 0; /// /// Returns the parameters needed to create the handle. Inheriting classes @@ -405,6 +432,7 @@ protected override CreateParams CreateParams CreateParams cp = base.CreateParams; cp.ClassName = PInvoke.WC_EDIT; cp.Style |= PInvoke.ES_AUTOHSCROLL | PInvoke.ES_AUTOVSCROLL; + if (!_textBoxFlags[s_hideSelection]) { cp.Style |= PInvoke.ES_NOHIDESEL; @@ -423,6 +451,7 @@ protected override CreateParams CreateParams case BorderStyle.Fixed3D: cp.ExStyle |= (int)WINDOW_EX_STYLE.WS_EX_CLIENTEDGE; break; + case BorderStyle.FixedSingle: cp.Style |= (int)WINDOW_STYLE.WS_BORDER; break; @@ -431,12 +460,23 @@ protected override CreateParams CreateParams if (_textBoxFlags[s_multiline]) { cp.Style |= PInvoke.ES_MULTILINE; + if (_textBoxFlags[s_wordWrap]) { cp.Style &= ~PInvoke.ES_AUTOHSCROLL; } } + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // Under a modern VisualStylesMode we custom-draw the frame in the non-client area (see OnNcPaint), + // so the natively drawn border must be suppressed to avoid a double frame. Only the effective + // Win32 styles are stripped here; the public BorderStyle property remains authoritative for what + // the custom non-client paint draws. + cp.Style &= ~(int)WINDOW_STYLE.WS_BORDER; + cp.ExStyle &= ~(int)WINDOW_EX_STYLE.WS_EX_CLIENTEDGE; + } + return cp; } } @@ -481,12 +521,7 @@ protected override Cursor DefaultCursor /// This is more efficient than setting the size in the control's constructor. /// protected override Size DefaultSize - { - get - { - return new Size(100, PreferredHeight); - } - } + => new Size(100, PreferredHeight); /// /// Gets or sets the foreground color of the control. @@ -496,17 +531,10 @@ protected override Size DefaultSize [SRDescription(nameof(SR.ControlForeColorDescr))] public override Color ForeColor { - get - { - if (ShouldSerializeForeColor()) - { - return base.ForeColor; - } - else - { - return SystemColors.WindowText; - } - } + get => ShouldSerializeForeColor() + ? base.ForeColor + : SystemColors.WindowText; + set => base.ForeColor = value; } @@ -519,10 +547,7 @@ public override Color ForeColor [SRDescription(nameof(SR.TextBoxHideSelectionDescr))] public bool HideSelection { - get - { - return _textBoxFlags[s_hideSelection]; - } + get => _textBoxFlags[s_hideSelection]; set { @@ -550,7 +575,10 @@ public event EventHandler? HideSelectionChanged /// protected override ImeMode ImeModeBase { - get => (DesignMode || CanEnableIme) ? base.ImeModeBase : ImeMode.Disable; + get => (DesignMode || CanEnableIme) + ? base.ImeModeBase + : ImeMode.Disable; + set => base.ImeModeBase = value; } @@ -576,9 +604,11 @@ public string[] Lines while (lineStart < text.Length) { int lineEnd = lineStart; + for (; lineEnd < text.Length; lineEnd++) { char c = text[lineEnd]; + if (c is '\r' or '\n') { break; @@ -610,18 +640,12 @@ public string[] Lines return [.. list]; } - set - { + + set => // unparse this string list... - if (value is not null && value.Length > 0) - { - Text = string.Join(Environment.NewLine, value); - } - else - { - Text = string.Empty; - } - } + Text = value is not null && value.Length > 0 + ? string.Join(Environment.NewLine, value) + : string.Empty; } /// @@ -634,10 +658,8 @@ public string[] Lines [SRDescription(nameof(SR.TextBoxMaxLengthDescr))] public virtual int MaxLength { - get - { - return _maxLength; - } + get => _maxLength; + set { ArgumentOutOfRangeException.ThrowIfNegative(value); @@ -662,22 +684,26 @@ public bool Modified { get { - if (IsHandleCreated) + if (!IsHandleCreated) + { + return _textBoxFlags[s_modified]; + } + else { - bool curState = (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_GETMODIFY) != 0; + bool curState = (int)PInvokeCore.SendMessage( + this, + PInvokeCore.EM_GETMODIFY) != 0; + if (_textBoxFlags[s_modified] != curState) { - // Raise ModifiedChanged event. See WmReflectCommand for more info. + // Raise ModifiedChanged event. + // See WmReflectCommand for more info. _textBoxFlags[s_modified] = curState; OnModifiedChanged(EventArgs.Empty); } return curState; } - else - { - return _textBoxFlags[s_modified]; - } } set @@ -686,11 +712,14 @@ public bool Modified { if (IsHandleCreated) { - PInvokeCore.SendMessage(this, PInvokeCore.EM_SETMODIFY, (WPARAM)(BOOL)value); - // Must maintain this state always in order for the - // test in the Get method to work properly. + PInvokeCore.SendMessage( + this, + PInvokeCore.EM_SETMODIFY, + (WPARAM)(BOOL)value); } + // Must maintain this state always in order for the + // test in the Get method to work properly. _textBoxFlags[s_modified] = value; OnModifiedChanged(EventArgs.Empty); } @@ -716,33 +745,36 @@ public event EventHandler? ModifiedChanged [RefreshProperties(RefreshProperties.All)] public virtual bool Multiline { - get - { - return _textBoxFlags[s_multiline]; - } + get => _textBoxFlags[s_multiline]; set { - if (_textBoxFlags[s_multiline] != value) + if (_textBoxFlags[s_multiline] == value) { - using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.Multiline)) - { - _textBoxFlags[s_multiline] = value; + return; + } - if (value) - { - // Multi-line textboxes do not have fixed height - SetStyle(ControlStyles.FixedHeight, false); - } - else - { - // Single-line textboxes may have fixed height, depending on AutoSize - SetStyle(ControlStyles.FixedHeight, AutoSize); - } + using (LayoutTransaction.CreateTransactionIf( + condition: AutoSize, + controlToLayout: ParentInternal, + elementCausingLayout: this, + property: PropertyNames.Multiline)) + { + _textBoxFlags[s_multiline] = value; - RecreateHandle(); - AdjustHeight(false); - OnMultilineChanged(EventArgs.Empty); + if (value) + { + // Multi-line textboxes do not have fixed height + SetStyle(ControlStyles.FixedHeight, false); } + else + { + // Single-line textboxes may have fixed height, depending on AutoSize + SetStyle(ControlStyles.FixedHeight, AutoSize); + } + + RecreateHandle(); + AdjustHeight(false); + OnMultilineChanged(EventArgs.Empty); } } } @@ -755,15 +787,47 @@ public event EventHandler? MultilineChanged remove => Events.RemoveHandler(s_multilineChangedEvent, value); } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + /// + /// Gets or sets the distance, in pixels, between the text displayed in a text box control and the top, bottom and side edges of the control. + /// + /// + /// + /// Note: While shadowing this property may seem redundant at first glance, it is necessary for binary compatibility. + /// Prior to .NET 11, this property was marked with Browsable(false) and + /// EditorBrowsable(EditorBrowsableState.Never), + /// and was never serialized. Effectively, did not work before .NET 11. While this changed in .NET 11, + /// removing the property shadowing would constitute a binary breaking change. Therefore, the shadowing is retained + /// with updated attributes to ensure Padding now functions as expected. + /// + /// + /// As long as the default value for the Padding property is not modified, -derived + /// controls (, ) will behave as they always have. However, if you + /// opt into modern styling by calling and set + /// to a value other than or + /// , the control will automatically apply styling that conforms to the + /// Windows 10+ Fluent Design Language. This provides both stylistic improvements (such as Dark Mode support) and + /// functional enhancements (including increased touch targets for easier mouse and touch interaction), thereby + /// meeting the accessibility requirements of modern high-resolution displays. + /// + /// + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public new Padding Padding { get => base.Padding; set => base.Padding = value; } + private new bool ShouldSerializePadding() + => Padding != DefaultPadding; + + private void ResetPadding() + => Padding = DefaultPadding; + + // Not new API and not a breaking change: TextBoxBase.PaddingChanged is already in + // PublicAPI.Shipped.txt. It is re-declared here with 'new' purely to apply the designer-hiding + // attributes below (Browsable(false) / EditorBrowsable(Never)) on top of Control.PaddingChanged. [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] @@ -791,54 +855,209 @@ public event EventHandler? MultilineChanged [EditorBrowsable(EditorBrowsableState.Advanced)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] [SRDescription(nameof(SR.TextBoxPreferredHeightDescr))] - public int PreferredHeight + public int PreferredHeight => + EffectiveVisualStylesMode switch + { + VisualStylesMode.Disabled => PreferredHeightClassic, + VisualStylesMode.Classic => PreferredHeightClassic, + >= VisualStylesMode.Net11 => PreferredHeightCore, + + // We should never be here. + _ => throw new InvalidEnumArgumentException( + argumentName: nameof(VisualStylesMode), + invalidValue: (int)VisualStylesMode, + enumClass: typeof(VisualStylesMode)) + }; + + /// + /// Returns the preferred height for modern Visual Styles, taking the carved padding band + /// (including the live scrollbar allowance and the user ) into account. + /// + private protected virtual int PreferredHeightCore + { + get + { + Padding visualStylesPadding = GetVisualStylesPadding( + includeScrollbars: true); + int preferredHeight = FontHeight + visualStylesPadding.Vertical; + + if (AutoSize && !Multiline && BorderStyle == BorderStyle.Fixed3D) + { + preferredHeight = ModernControlVisualStyles.GetPreferredFieldHeight( + FontHeight, + visualStylesPadding, + DeviceDpiInternal); + } + + return preferredHeight; + } + } + + /// + /// Returns the classic (Everett-compatible) preferred height for a single-line text box. + /// + /// + /// + /// COMPAT: we must return the same busted height we did in Everett, even if it does not take + /// multiline and word wrap into account. For better accuracy and/or wrapping use + /// instead. + /// + /// + private int PreferredHeightClassic { get { - // COMPAT we must return the same busted height we did in Everett, even - // if it doesn't take multiline and word wrap into account. For better accuracy and/or wrapping use - // GetPreferredSize instead. int height = FontHeight; + if (_borderStyle != BorderStyle.None) { - height += SystemInformation.GetBorderSizeForDpi(DeviceDpiInternal).Height * 4 + 3; + height += SystemInformation.GetBorderSizeForDpi(DeviceDpiInternal).Height + * 4 + 3; } return height; } } - // GetPreferredSizeCore - // This method can return a different value than PreferredHeight! It properly handles - // border style + multiline and wordwrap. + /// + /// Defines the visible part of the padding band carved for modern Visual Styles chrome. + /// + /// + /// to add the live scrollbar allowance (see ) + /// on top of the border padding; otherwise . + /// + /// The visible padding dimensions. + /// + /// + /// The visible part of the padding is the area by which we extend the real-estate of the control + /// with its back color. Border/corner reservation, the DPI-scaled internal chrome inset, the + /// user-provided , and scrollbar reservation are each added once. + /// + /// + private protected Padding GetVisualStylesPadding(bool includeScrollbars) + { + SystemVisualSettings settings = Application.SystemVisualSettings; + Padding padding = ModernControlVisualStyles.GetFieldPadding( + BorderStyle, + Padding, + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + + if (includeScrollbars) + { + padding += GetScrollBarPadding(); + } + + return padding; + } + + private int ScaleVisualStylesMetric(int logicalValue) + => ScaleHelper.ScaleToDpi(logicalValue, DeviceDpiInternal); + + private Size GetVisualStylesFocusBorderMetrics() + { + SystemVisualSettings settings = Application.SystemVisualSettings; + return GetVisualStylesFocusBorderMetrics( + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + } + + private int GetVisualStylesFocusBandHeight() + { + SystemVisualSettings settings = Application.SystemVisualSettings; + return GetVisualStylesFocusBandHeight( + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + } + + internal static Size GetVisualStylesFocusBorderMetrics( + Size focusBorderMetrics, + float textScaleFactor, + int deviceDpi) + => ModernControlVisualStyles.GetFocusBorderMetrics( + focusBorderMetrics, + textScaleFactor, + deviceDpi); + + internal static int GetVisualStylesFocusBandHeight( + Size focusBorderMetrics, + float textScaleFactor, + int deviceDpi) + => ModernControlVisualStyles.GetFocusBandHeight( + focusBorderMetrics, + textScaleFactor, + deviceDpi); + internal static bool CanRenderVisualStylesRoundedChrome( + Rectangle bounds, + int cornerSize, + int borderThickness) + { + int minimumDimension = cornerSize + borderThickness; + return bounds.Width >= minimumDimension && bounds.Height >= minimumDimension; + } + + internal static Color GetVisualStylesFocusColor(bool highContrast) + => highContrast + ? SystemColors.Highlight + : Application.GetWindowsAccentColor(); - internal override Size GetPreferredSizeCore(Size proposedConstraints) + /// + /// Returns the additional padding required to clear the live scrollbars, + /// if any are currently shown. + /// + private protected virtual Padding GetScrollBarPadding() { - // 3px vertical space is required between the text and the border to keep the last - // line from being clipped. - // This 3 pixel size was added in everett and we do this to maintain compat. - // old everett behavior was FontHeight + [SystemInformation.BorderSize.Height * 4 + 3] - // however the [ ] was only added if borderstyle was not none. - Size bordersAndPadding = SizeFromClientSize(Size.Empty) + Padding.Size; + Padding padding = Padding.Empty; + + // Are the scrollbars visible? + WINDOW_STYLE style = (WINDOW_STYLE)PInvokeCore.GetWindowLong( + this, + WINDOW_LONG_PTR_INDEX.GWL_STYLE); - if (BorderStyle != BorderStyle.None) + bool hasHScroll = (style & WINDOW_STYLE.WS_HSCROLL) != 0; + bool hasVScroll = (style & WINDOW_STYLE.WS_VSCROLL) != 0; + + if (hasHScroll) { - bordersAndPadding += new Size(0, 3); + padding.Bottom += SystemInformation.GetHorizontalScrollBarHeightForDpi(DeviceDpiInternal); } - if (BorderStyle == BorderStyle.FixedSingle) + if (hasVScroll) { - // Bump these by 2px to match BorderStyle.Fixed3D - they'll be omitted from the SizeFromClientSize call. - bordersAndPadding.Width += 2; - bordersAndPadding.Height += 2; + padding.Right += SystemInformation.GetVerticalScrollBarWidthForDpi(DeviceDpiInternal); } - // Reduce constraints by border/padding size - proposedConstraints -= bordersAndPadding; + return padding; + } + + /// + /// Computes the preferred size of the control, honoring border style, multiline, and word wrap. + /// + /// + /// + /// This can return a different value than . + /// is a single-line, border-only height kept for backward compatibility, whereas this method measures + /// the actual text against the available width and additionally accounts for multiline and word wrap, + /// and - for modern Visual Styles - the carved adorner band and the user . + /// + /// + internal override Size GetPreferredSizeCore(Size proposedConstraints) + { + // 3px vertical space is required between the text and the border to keep the last + // line from being clipped. + + // This 3 pixel size was added in Everett, and we do this to maintain compat. + // Old Everett behavior was FontHeight + [SystemInformation.BorderSize.Height * 4 + 3], + // however the [ ] was only added if BorderStyle was not None. + Padding padding = default; // Fit the text to the remaining space. - // Fixed for .NET Framework 4.0 + // Originally fixed for .NET Framework 4.0 TextFormatFlags format = TextFormatFlags.NoPrefix; + if (!Multiline) { format |= TextFormatFlags.SingleLine; @@ -848,11 +1067,50 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) format |= TextFormatFlags.WordBreak; } + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // For modern Visual Styles we take the carved adorner band (including scrollbars + // and the user Padding) into account when measuring. + padding = GetVisualStylesPadding(includeScrollbars: true); + proposedConstraints -= padding.Size; + } + else + { + // Keep the pre-.NET 11 layout contract for classic or disabled visual styles. In these + // modes we retain the historic border sizing, but user Padding does not participate in + // preferred-size calculations until a modern visual styles mode is selected explicitly. + Size borders = SizeFromClientSize(Size.Empty); + + if (BorderStyle != BorderStyle.None) + { + borders += new Size(0, 3); + } + + if (BorderStyle == BorderStyle.FixedSingle) + { + // Bump these by 2px to match BorderStyle.Fixed3D - they'll be omitted from the SizeFromClientSize call. + borders.Width += 2; + borders.Height += 2; + } + + // Preserve the classic border-only measurement contract until a modern + // visual styles mode is selected. + padding.Left = 0; + padding.Top = 0; + padding.Right = borders.Width; + padding.Bottom = borders.Height; + + // Reduce constraints by the classic border size. + proposedConstraints -= borders; + } + Size textSize = TextRenderer.MeasureText(Text, Font, proposedConstraints, format); // We use this old computation as a lower bound to ensure backwards compatibility. textSize.Height = Math.Max(textSize.Height, FontHeight); - Size preferredSize = textSize + bordersAndPadding; + + Size preferredSize = textSize + new Size(padding.Horizontal, padding.Vertical); + return preferredSize; } @@ -885,9 +1143,9 @@ internal unsafe void GetSelectionStartAndLength(out int start, out int length) // the windows call. This eliminates a problem on nt4 where // a huge negative # is being returned. start = Math.Max(0, start); + // ditto for end end = Math.Max(0, end); - length = end - start; } @@ -898,14 +1156,9 @@ internal unsafe void GetSelectionStartAndLength(out int start, out int length) end = start + length - 1; - if (t is null) - { - len = 0; - } - else - { - len = t.Length; - } + len = t is null + ? 0 + : t.Length; Debug.Assert(end <= len, $"SelectionEnd is outside the set of valid caret positions for the current WindowText (end ={end}, WindowText.Length ={len})"); @@ -922,26 +1175,25 @@ internal unsafe void GetSelectionStartAndLength(out int start, out int length) [SRDescription(nameof(SR.TextBoxReadOnlyDescr))] public bool ReadOnly { - get - { - return _textBoxFlags[s_readOnly]; - } + get => _textBoxFlags[s_readOnly]; + set { - if (_textBoxFlags[s_readOnly] != value) + if (_textBoxFlags[s_readOnly] == value) { - _textBoxFlags[s_readOnly] = value; - - if (IsHandleCreated) - { - PInvokeCore.SendMessage(this, PInvokeCore.EM_SETREADONLY, (WPARAM)(BOOL)value); - EnsureReadonlyBackgroundColor(value); - } + return; + } - OnReadOnlyChanged(EventArgs.Empty); + _textBoxFlags[s_readOnly] = value; - VerifyImeRestrictedModeChanged(); + if (IsHandleCreated) + { + PInvokeCore.SendMessage(this, PInvokeCore.EM_SETREADONLY, (WPARAM)(BOOL)value); + EnsureReadonlyBackgroundColor(value); } + + OnReadOnlyChanged(EventArgs.Empty); + VerifyImeRestrictedModeChanged(); } } @@ -952,7 +1204,10 @@ private void EnsureReadonlyBackgroundColor(bool value) && DarkModeRequestState is true && !ShouldSerializeBackColor()) { - base.BackColor = value ? SystemColors.ControlLight : SystemColors.Window; + base.BackColor = value + ? SystemColors.ControlLight + : SystemColors.Window; + Invalidate(); } } @@ -978,12 +1233,11 @@ public virtual string SelectedText get { GetSelectionStartAndLength(out int selStart, out int selLength); + return Text.Substring(selStart, selLength); } - set - { - SetSelectedTextInternal(value, true); - } + + set => SetSelectedTextInternal(value, true); } /// @@ -1077,11 +1331,13 @@ public int SelectionStart public override string Text { get => base.Text; + set { if (value != base.Text) { base.Text = value; + if (IsHandleCreated) { // clear the modified flag @@ -1098,9 +1354,16 @@ public virtual int TextLength => IsHandleCreated ? PInvokeCore.GetWindowTextLength(this) : Text.Length; + /// + /// Gets or sets the underlying native window text (the edit control's caption). This is overridden + /// so that setting the text from code raises a "code update" flag for the duration of the assignment: + /// updating the text on a created handle produces a WM_COMMAND / EN_CHANGE notification, + /// and the flag lets us swallow it so we do not raise a duplicate event. + /// internal override string WindowText { get => base.WindowText; + set { value ??= string.Empty; @@ -1111,6 +1374,7 @@ internal override string WindowText if (!WindowText.Equals(value)) { _textBoxFlags[s_codeUpdateText] = true; + try { base.WindowText = value; @@ -1133,6 +1397,7 @@ internal void ForceWindowText(string? value) value ??= string.Empty; _textBoxFlags[s_codeUpdateText] = true; + try { if (IsHandleCreated) @@ -1160,13 +1425,15 @@ internal void ForceWindowText(string? value) [SRDescription(nameof(SR.TextBoxWordWrapDescr))] public bool WordWrap { - get - { - return _textBoxFlags[s_wordWrap]; - } + get => _textBoxFlags[s_wordWrap]; + set { - using (LayoutTransaction.CreateTransactionIf(AutoSize, ParentInternal, this, PropertyNames.WordWrap)) + using (LayoutTransaction.CreateTransactionIf( + condition: AutoSize, + controlToLayout: ParentInternal, + elementCausingLayout: this, + property: PropertyNames.WordWrap)) { if (_textBoxFlags[s_wordWrap] != value) { @@ -1185,12 +1452,15 @@ private void AdjustHeight(bool returnIfAnchored) { // If we're anchored to two opposite sides of the form, don't adjust the size because // we'll lose our anchored size by resetting to the requested width. - if (returnIfAnchored && (Anchor & (AnchorStyles.Top | AnchorStyles.Bottom)) == (AnchorStyles.Top | AnchorStyles.Bottom)) + if (returnIfAnchored + && (Anchor & (AnchorStyles.Top | AnchorStyles.Bottom)) + == (AnchorStyles.Top | AnchorStyles.Bottom)) { return; } int saveHeight = _requestedHeight; + try { if (_textBoxFlags[s_autoSize] && !_textBoxFlags[s_multiline]) @@ -1210,6 +1480,7 @@ private void AdjustHeight(bool returnIfAnchored) } _integralHeightAdjust = true; + try { Height = saveHeight; @@ -1281,22 +1552,30 @@ public void ClearUndo() protected bool ContainsNavigationKeyCode(Keys keyCode) => keyCode switch { - Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown or Keys.Home or Keys.End or Keys.Left or Keys.Right => true, + Keys.Up or Keys.Down or Keys.PageUp or Keys.PageDown + or Keys.Home or Keys.End or Keys.Left or Keys.Right => true, _ => false, }; /// /// Copies the current selection in the text box to the Clipboard. /// - public void Copy() => PInvokeCore.SendMessage(this, PInvokeCore.WM_COPY); + public void Copy() + => PInvokeCore.SendMessage(this, PInvokeCore.WM_COPY); - protected override AccessibleObject CreateAccessibilityInstance() => new TextBoxBaseAccessibleObject(this); + protected override AccessibleObject CreateAccessibilityInstance() + => new TextBoxBaseAccessibleObject(this); protected override void CreateHandle() { + // Should the handle be (re)created at this point, we need to reset the latch so the + // modern Visual Styles client area is re-carved against the new window. + _triggerNewClientSizeRequest = false; + // This "creatingHandle" stuff is to avoid property change events // when we set the Text property. _textBoxFlags[s_creatingHandle] = true; + try { base.CreateHandle(); @@ -1313,52 +1592,58 @@ protected override void CreateHandle() /// /// Moves the current selection in the text box to the Clipboard. /// - public void Cut() => PInvokeCore.SendMessage(this, PInvokeCore.WM_CUT); + public void Cut() + => PInvokeCore.SendMessage(this, PInvokeCore.WM_CUT); /// /// Returns the text end position (one past the last input character). This property is virtual to allow MaskedTextBox /// to set the last input char position as opposed to the last char position which may be a mask character. /// - internal virtual int GetEndPosition() - { + internal virtual int GetEndPosition() => // +1 because RichTextBox has this funny EOF pseudo-character after all the text. - return IsHandleCreated ? TextLength + 1 : TextLength; - } + IsHandleCreated + ? TextLength + 1 + : TextLength; /// /// Overridden to handle TAB key. /// protected override bool IsInputKey(Keys keyData) { - if ((keyData & Keys.Alt) != Keys.Alt) + if ((keyData & Keys.Alt) == Keys.Alt) { - switch (keyData & Keys.KeyCode) - { - case Keys.Tab: - // Single-line RichEd's want tab characters (see WM_GETDLGCODE), - // so we don't ask it - return Multiline && _textBoxFlags[s_acceptsTab] && ((keyData & Keys.Control) == 0); - case Keys.Escape: - if (Multiline) - { - return false; - } + return base.IsInputKey(keyData); + } - break; - case Keys.Back: - if (!ReadOnly) - { - return true; - } + switch (keyData & Keys.KeyCode) + { + case Keys.Tab: + // Single-line RichEd's want tab characters (see WM_GETDLGCODE), + // so we don't ask it + return Multiline && _textBoxFlags[s_acceptsTab] && ((keyData & Keys.Control) == 0); - break; - case Keys.PageUp: - case Keys.PageDown: - case Keys.Home: - case Keys.End: + case Keys.Escape: + if (Multiline) + { + return false; + } + + break; + + case Keys.Back: + if (!ReadOnly) + { return true; - // else fall through to base - } + } + + break; + + case Keys.PageUp: + case Keys.PageDown: + case Keys.Home: + case Keys.End: + return true; + // else fall through to base } return base.IsInputKey(keyData); @@ -1371,6 +1656,7 @@ protected override bool IsInputKey(Keys keyData) protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); + if (!IsHandleCreated) { return; @@ -1380,8 +1666,8 @@ protected override void OnHandleCreated(EventArgs e) // the border size/etc. CommonProperties.xClearPreferredSizeCache(this); AdjustHeight(true); - UpdateMaxLength(); + if (_textBoxFlags[s_modified]) { PInvokeCore.SendMessage(this, PInvokeCore.EM_SETMODIFY, (WPARAM)(BOOL)true); @@ -1394,10 +1680,14 @@ protected override void OnHandleCreated(EventArgs e) ScrollToCaret(); _textBoxFlags[s_scrollToCaretOnHandleCreated] = false; } + + RecalculateVisualStylesClientArea(); } protected override void OnHandleDestroyed(EventArgs e) { + _focusIndicatorRenderer?.Dispose(); + _focusIndicatorRenderer = null; _textBoxFlags[s_modified] = Modified; _textBoxFlags[s_setSelectionOnHandleCreated] = true; // Update text selection cached values to be restored when recreating the handle. @@ -1405,26 +1695,81 @@ protected override void OnHandleDestroyed(EventArgs e) base.OnHandleDestroyed(e); } - /// - /// Replaces the current selection in the text box with the contents of the Clipboard. - /// - public void Paste() => PInvokeCore.SendMessage(this, PInvokeCore.WM_PASTE); + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + // UpdateStyles may synchronously provoke WM_NCCALCSIZE, so it must observe a cleared latch. + _triggerNewClientSizeRequest = false; + base.OnVisualStylesModeChanged(e); + AdjustHeight(false); + _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); - protected override bool ProcessDialogKey(Keys keyData) + RecalculateVisualStylesClientArea(); + } + + /// + protected override void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) { - Keys keyCode = keyData & Keys.KeyCode; + base.OnSystemVisualSettingsChanged(e); - if (keyCode == Keys.Tab && AcceptsTab && (keyData & Keys.Control) != 0) + if ((e.Changed & (SystemVisualSettingsCategories.TextScale | SystemVisualSettingsCategories.FocusMetrics)) == 0 + || EffectiveVisualStylesMode < VisualStylesMode.Net11) { - // When this control accepts Tabs, Ctrl-Tab is treated exactly like Tab. - keyData &= ~Keys.Control; + return; } - return base.ProcessDialogKey(keyData); + CommonProperties.xClearPreferredSizeCache(this); + AdjustHeight(false); + RecalculateVisualStylesClientArea(); + + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout(parent, this, PropertyNames.SystemVisualSettings); + } } - /// - /// TextBox / RichTextBox Onpaint. + /// + /// + /// + /// and the modern non-client padding table are shared by + /// and . Consequently, switching + /// between those modes only repaints. Crossing the classic or disabled boundary changes both the preferred + /// height selection and the non-client padding model, so it requires a metrics update. + /// + /// + protected override VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + { + bool oldUsesModernMetrics = oldMode >= VisualStylesMode.Net11; + bool newUsesModernMetrics = newMode >= VisualStylesMode.Net11; + + return oldUsesModernMetrics != newUsesModernMetrics + ? VisualStylesModeChangeImpact.Metrics + : VisualStylesModeChangeImpact.Repaint; + } + + /// + /// Replaces the current selection in the text box with the contents of the Clipboard. + /// + public void Paste() + => PInvokeCore.SendMessage(this, PInvokeCore.WM_PASTE); + + protected override bool ProcessDialogKey(Keys keyData) + { + Keys keyCode = keyData & Keys.KeyCode; + + if (keyCode == Keys.Tab && AcceptsTab && (keyData & Keys.Control) != 0) + { + // When this control accepts Tabs, Ctrl-Tab is treated exactly like Tab. + keyData &= ~Keys.Control; + } + + return base.ProcessDialogKey(keyData); + } + + /// + /// TextBox / RichTextBox Onpaint. /// /// [Browsable(false)] @@ -1451,12 +1796,81 @@ protected virtual void OnBorderStyleChanged(EventArgs e) } } + protected override unsafe void OnGotFocus(EventArgs e) + { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + if (BorderStyle == BorderStyle.Fixed3D) + { + FocusIndicatorRenderer.SetFocused( + focused: true, + animate: SystemInformation.UIEffectsEnabled && !SystemInformation.HighContrast); + } + else + { + InvalidateVisualStylesFrame(); + } + } + + base.OnGotFocus(e); + } + + protected override unsafe void OnLostFocus(EventArgs e) + { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + if (BorderStyle == BorderStyle.Fixed3D) + { + FocusIndicatorRenderer.SetFocused( + focused: false, + animate: SystemInformation.UIEffectsEnabled && !SystemInformation.HighContrast); + } + else + { + InvalidateVisualStylesFrame(); + } + } + + base.OnLostFocus(e); + } + + protected override unsafe void OnSizeChanged(EventArgs e) + { + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + // Invalidate the non-client area to ensure the border chrome is drawn correctly after a resize. + PInvoke.RedrawWindow( + hWnd: this, + lprcUpdate: null, + hrgnUpdate: HRGN.Null, + flags: REDRAW_WINDOW_FLAGS.RDW_FRAME | REDRAW_WINDOW_FLAGS.RDW_INVALIDATE); + } + + base.OnSizeChanged(e); + } + protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Font); AdjustHeight(false); } + protected override void OnDpiChangedAfterParent(EventArgs e) + { + base.OnDpiChangedAfterParent(e); + + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Bounds); + AdjustHeight(false); + + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + RecalculateVisualStylesClientArea(); + } + } + protected virtual void OnHideSelectionChanged(EventArgs e) { if (Events[s_hideSelectionChangedEvent] is EventHandler eh) @@ -1513,7 +1927,16 @@ protected virtual void OnMultilineChanged(EventArgs e) protected override void OnPaddingChanged(EventArgs e) { base.OnPaddingChanged(e); + CommonProperties.xClearPreferredSizeCache(this); + LayoutTransaction.DoLayoutIf(AutoSize, ParentInternal, this, PropertyNames.Padding); AdjustHeight(false); + + // The carved modern Visual Styles padding band includes the user Padding, so a runtime change + // has to re-provoke the non-client calculation for it to be reflected in the client rectangle. + if (EffectiveVisualStylesMode >= VisualStylesMode.Net11) + { + RecalculateVisualStylesClientArea(); + } } protected virtual void OnReadOnlyChanged(EventArgs e) @@ -1594,7 +2017,8 @@ public virtual int GetCharIndexFromPosition(Point pt) /// you pass the index of a overflowed character, GetLineFromCharIndex would /// return 1 and not 0. /// - public virtual int GetLineFromCharIndex(int index) => (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_LINEFROMCHAR, (WPARAM)index); + public virtual int GetLineFromCharIndex(int index) + => (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_LINEFROMCHAR, (WPARAM)index); /// /// Returns the location of the character at the given index. @@ -1606,7 +2030,11 @@ public virtual Point GetPositionFromCharIndex(int index) return Point.Empty; } - int i = (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_POSFROMCHAR, (WPARAM)index); + int i = (int)PInvokeCore.SendMessage( + hWnd: this, + Msg: PInvokeCore.EM_POSFROMCHAR, + wParam: (WPARAM)index); + return new Point(PARAM.SignedLOWORD(i), PARAM.SignedHIWORD(i)); } @@ -1617,19 +2045,26 @@ public int GetFirstCharIndexFromLine(int lineNumber) { ArgumentOutOfRangeException.ThrowIfNegative(lineNumber); - return (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_LINEINDEX, (WPARAM)lineNumber); + return (int)PInvokeCore.SendMessage( + hWnd: this, + Msg: PInvokeCore.EM_LINEINDEX, + wParam: (WPARAM)lineNumber); } /// /// Returns the index of the first character of the line where the caret is. /// - public int GetFirstCharIndexOfCurrentLine() => (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_LINEINDEX, (WPARAM)(-1)); + public int GetFirstCharIndexOfCurrentLine() + => (int)PInvokeCore.SendMessage( + hWnd: this, + Msg: PInvokeCore.EM_LINEINDEX, + wParam: (WPARAM)(-1)); /// /// Ensures that the caret is visible in the TextBox window, by scrolling the /// TextBox control surface if necessary. /// - public unsafe void ScrollToCaret() + public void ScrollToCaret() { if (!IsHandleCreated) { @@ -1643,57 +2078,23 @@ public unsafe void ScrollToCaret() return; } - using ComScope richEdit = new(null); - - if (PInvokeCore.SendMessage(this, PInvokeCore.EM_GETOLEINTERFACE, 0, (void**)richEdit) == 0) - { - PInvokeCore.SendMessage(this, PInvokeCore.EM_SCROLLCARET); - return; - } - - using var textDocument = richEdit.TryQuery(out HRESULT hr); - - if (hr.Succeeded) - { - // When the user calls RichTextBox::ScrollToCaret we want the RichTextBox to show as much text as - // possible. Here is how we do that: - // - // 1. We scroll the RichTextBox all the way to the bottom so the last line of text is the last visible line. - // 2. We get the first visible line. - // 3. If the first visible line is smaller than the start of the selection, then we are done: - // The selection fits inside the RichTextBox display rectangle. - // 4. Otherwise, scroll the selection to the top of the RichTextBox. - - GetSelectionStartAndLength(out int selStart, out int selLength); - int selStartLine = GetLineFromCharIndex(selStart); - - using ComScope windowTextRange = new(null); - textDocument.Value->Range(WindowText.Length - 1, WindowText.Length - 1, windowTextRange).ThrowOnFailure(); - - // 1. Scroll the RichTextBox all the way to the bottom - windowTextRange.Value->ScrollIntoView((int)tomConstants.tomEnd).ThrowOnFailure(); - - // 2. Get the first visible line. - int firstVisibleLine = (int)PInvokeCore.SendMessage(this, PInvokeCore.EM_GETFIRSTVISIBLELINE); - - // 3. If the first visible line is smaller than the start of the selection, we are done. - if (firstVisibleLine <= selStartLine) - { - return; - } - else - { - // 4. Scroll the selection to the top of the RichTextBox. - using ComScope selectionTextRange = new(null); - textDocument.Value->Range(selStart, selStart + selLength, selectionTextRange).ThrowOnFailure(); - selectionTextRange.Value->ScrollIntoView((int)tomConstants.tomStart).ThrowOnFailure(); - return; - } - } - - PInvokeCore.SendMessage(this, PInvokeCore.EM_SCROLLCARET); + ScrollToCaretCore(); } + /// + /// Performs the control-specific work of scrolling the caret into view. The base implementation + /// asks the native edit control to scroll the caret into view; overrides + /// this to additionally show as much of the surrounding text as possible. + /// + /// + /// + /// Invoked by only once the handle exists and the control has text, so + /// overrides do not need to re-check those conditions. + /// + /// + private protected virtual void ScrollToCaretCore() + => PInvokeCore.SendMessage(this, PInvokeCore.EM_SCROLLCARET); + /// /// Sets the SelectionLength to 0. /// @@ -1716,14 +2117,10 @@ public void Select(int start, int length) // We shouldn't allow positive length if you're starting at the end, but // should allow negative length. long longLength = Math.Min(0, (long)length + start - textLen); - if (longLength < int.MinValue) - { - length = int.MinValue; - } - else - { - length = (int)longLength; - } + + length = longLength < int.MinValue + ? int.MinValue + : (int)longLength; start = textLen; } @@ -1742,9 +2139,22 @@ public void Select(int start, int length) private protected virtual void SelectInternal(int selectionStart, int selectionLength, int textLength) { // if our handle is created - send message... - if (IsHandleCreated) + if (!IsHandleCreated) { - AdjustSelectionStartAndEnd(selectionStart, selectionLength, out int start, out int end, textLength); + // otherwise, wait until handle is created to send this message. + // Store the indices until then... + _selectionStart = selectionStart; + _selectionLength = selectionLength; + _textBoxFlags[s_setSelectionOnHandleCreated] = true; + } + else + { + AdjustSelectionStartAndEnd( + selectionStart, + selectionLength, + out int start, + out int end, + textLength); PInvokeCore.SendMessage(this, PInvokeCore.EM_SETSEL, (WPARAM)start, (LPARAM)end); @@ -1755,14 +2165,6 @@ private protected virtual void SelectInternal(int selectionStart, int selectionL : UIA_EVENT_ID.UIA_Text_TextSelectionChangedEventId); } } - else - { - // otherwise, wait until handle is created to send this message. - // Store the indices until then... - _selectionStart = selectionStart; - _selectionLength = selectionLength; - _textBoxFlags[s_setSelectionOnHandleCreated] = true; - } } /// @@ -1792,7 +2194,8 @@ protected override void SetBoundsCore(int x, int y, int width, int height, Bound base.SetBoundsCore(x, y, width, height, specified); } - private static void Swap(ref int n1, ref int n2) => (n1, n2) = (n2, n1); + private static void Swap(ref int n1, ref int n2) + => (n1, n2) = (n2, n1); // Send in -1 if you don't have the text length cached // when calling this method. It will be computed. If not, @@ -1810,16 +2213,9 @@ internal void AdjustSelectionStartAndEnd(int selStart, int selLength, out int st } else { - int textLength; - - if (textLen >= 0) - { - textLength = textLen; - } - else - { - textLength = TextLength; - } + int textLength = textLen >= 0 + ? textLen + : TextLength; if (start > textLength) { @@ -1855,6 +2251,7 @@ internal void AdjustSelectionStartAndEnd(int selStart, int selLength, out int st internal void SetSelectionOnHandle() { Debug.Assert(IsHandleCreated, "Don't call this method until the handle is created."); + if (_textBoxFlags[s_setSelectionOnHandleCreated]) { _textBoxFlags[s_setSelectionOnHandleCreated] = false; @@ -1876,6 +2273,7 @@ private static void ToUnicodeOffsets(string str, ref int start, ref int end) byte[] bytes = e.GetBytes(str); bool swap = start > end; + if (swap) { Swap(ref start, ref end); @@ -1922,6 +2320,7 @@ internal static void ToDbcsOffsets(string str, ref int start, ref int end) Encoding e = Encoding.Default; bool swap = start > end; + if (swap) { Swap(ref start, ref end); @@ -1971,6 +2370,7 @@ public override string ToString() string s = base.ToString(); string txt = Text; + if (txt.Length > 40) { txt = $"{txt.AsSpan(0, 40)}..."; @@ -1982,7 +2382,8 @@ public override string ToString() /// /// Undoes the last edit operation in the text box. /// - public void Undo() => PInvokeCore.SendMessage(this, PInvokeCore.EM_UNDO); + public void Undo() + => PInvokeCore.SendMessage(this, PInvokeCore.EM_UNDO); internal virtual void UpdateMaxLength() { @@ -1994,6 +2395,8 @@ internal virtual void UpdateMaxLength() internal override HBRUSH InitializeDCForWmCtlColor(HDC dc, MessageId msg) { + InitializeClientArea(dc, (HWND)Handle); + if (msg == PInvokeCore.WM_CTLCOLORSTATIC && !ShouldSerializeBackColor()) { // Let the Win32 Edit control handle background colors itself. @@ -2007,26 +2410,590 @@ internal override HBRUSH InitializeDCForWmCtlColor(HDC dc, MessageId msg) } } - private void WmReflectCommand(ref Message m) + /// + /// Provokes the single non-client calc round trip that carves the modern Visual Styles padding + /// band from the client area. + /// + /// + /// + /// This runs only for values of + /// and above, and only once per handle. The latch () is + /// reset on handle recreation in . + /// + /// + private protected virtual unsafe void InitializeClientArea(HDC hDC, HWND hwnd) + { + if (EffectiveVisualStylesMode < VisualStylesMode.Net11 + || _triggerNewClientSizeRequest) + { + return; + } + + RecalculateVisualStylesClientArea(); + } + + /// + /// Sets the latch and provokes the WM_NCCALCSIZE round trip that carves the modern Visual + /// Styles padding band from the client area. Safe to call repeatedly; it re-carves against the + /// current and scrollbar state. + /// + private protected void RecalculateVisualStylesClientArea() { - if (!_textBoxFlags[s_codeUpdateText] && !_textBoxFlags[s_creatingHandle]) + if (!IsHandleCreated || EffectiveVisualStylesMode < VisualStylesMode.Net11) { - uint hiword = m.WParamInternal.HIWORD; - if (hiword == PInvoke.EN_CHANGE && CanRaiseTextChangedEvent) + return; + } + + _triggerNewClientSizeRequest = true; + + // Call SetWindowPos with the current bounds and the SWP_FRAMECHANGED flag. We do not change the + // window position/size, but this provokes the WM_NCCALCSIZE message we need to carve the client area. + PInvoke.SetWindowPos( + hWnd: this, + hWndInsertAfter: HWND.HWND_TOP, + X: 0, + Y: 0, + cx: 0, + cy: 0, + uFlags: SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED + | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOMOVE + | SET_WINDOW_POS_FLAGS.SWP_NOSIZE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + + Invalidate(true); + } + + /// + /// When , the native window reserves its own non-client metrics (border and + /// scrollbars) during WM_NCCALCSIZE, so the modern Visual Styles padding band is carved from + /// the client rectangle the default handler produces rather than from the raw proposed window + /// rectangle. The default is : the managed carve is authoritative and the + /// default handler is not invoked (a plain EDIT control keeps its scrollbars inside the + /// managed allowance). + /// + private protected virtual bool ReservesNativeNonClientArea => false; + + /// + /// Handles WM_NCCALCSIZE by carving the modern Visual Styles padding band from the client + /// rectangle. The carve is floored so the client rectangle can never invert. + /// + private unsafe void WmNcCalcSize(ref Message m) + { + // Make sure we actually kicked this off. + if (_triggerNewClientSizeRequest) + { + NCCALCSIZE_PARAMS* ncCalcSizeParams = (NCCALCSIZE_PARAMS*)(void*)m.LParamInternal; + + if (ncCalcSizeParams is not null) { - OnTextChanged(EventArgs.Empty); + // Controls whose native window reserves its own non-client metrics (RichEdit reserves its + // border and scrollbars) must run the default handler first, so we carve the modern padding + // band from the already-adjusted client rectangle rather than the raw proposed window + // rectangle. Their managed scrollbar allowance is omitted from this carve because the + // native handler already reserved it. + if (ReservesNativeNonClientArea) + { + base.WndProc(ref m); + } + + Padding padding = GetVisualStylesPadding(includeScrollbars: !ReservesNativeNonClientArea); + + ref RECT clientRect = ref ncCalcSizeParams->rgrc._0; + + // Never-invert clamp: a large Padding plus the live scrollbar allowance can drive the + // carved client rect to zero or inverted. A 0-1px client area is acceptable and intended + // (shipping multiline TextBox already collapses this way); we only prevent underflow past + // zero. This is deliberately NOT a MinimumSize and does not vary by VisualStylesMode. + int newTop = clientRect.top + padding.Top; + int newBottom = clientRect.bottom - padding.Bottom; + int newLeft = clientRect.left + padding.Left; + int newRight = clientRect.right - padding.Right; + + clientRect.top = newTop; + clientRect.bottom = Math.Max(newTop, newBottom); + clientRect.left = newLeft; + clientRect.right = Math.Max(newLeft, newRight); + + m.ResultInternal = (LRESULT)0; + + return; } - else if (hiword == PInvoke.EN_UPDATE) + } + + base.WndProc(ref m); + } + + /// + /// Handles WM_NCPAINT for modern Visual Styles by painting the custom chrome into the + /// window DC. The wParam clip region is intentionally ignored for our own paint and the + /// custom frame is repainted to avoid the offscreen-restore "dirty corners" artifact. + /// + /// + /// + /// The default (themed) non-client paint for a scrollbar-bearing edit control fills the client + /// rectangle, which erases the control's content on every non-client repaint - for example when + /// the mouse hovers over the frame. To prevent that, the default handler is invoked with an update + /// region that spans the whole window but excludes the live client rectangle, so the border and + /// scrollbars still paint while the client area is left untouched. + /// + /// + /// The custom chrome blit also excludes native scrollbars that are currently visible. This keeps + /// the native scrollbar pixels painted by the default handler from being covered by the frame. + /// + /// + private void WmNcPaint(ref Message m) + { + if (EffectiveVisualStylesMode < VisualStylesMode.Net11) + { + base.WndProc(ref m); + return; + } + + HWND hwnd = (HWND)m.HWnd; + + // A non-client update region (in screen coordinates, as WM_NCPAINT requires) that excludes the + // client rectangle. Handing this to the default handler keeps it from overpainting the client. + using RegionScope nonClientRegion = CreateNonClientClipRegion(); + WPARAM originalWParam = m.WParamInternal; + + HDC hdc = PInvokeCore.GetWindowDC(hwnd); + + // Intentional: Graphics.FromHdc does NOT own the DC, so we release the DC ourselves in the + // finally. Do not "tidy" the DC into a single using - that would be a regression. + using Graphics graphics = Graphics.FromHdc(hdc); + + try + { + if (!nonClientRegion.IsNull) { - // Force update to the Modified property, which will trigger ModifiedChanged event handlers - _ = Modified; + m.WParamInternal = (WPARAM)(nuint)(nint)nonClientRegion.Region; } + + base.WndProc(ref m); + + // Restore the original wParam so nothing downstream observes our temporary clip region. + m.WParamInternal = originalWParam; + + OnNcPaint(graphics, hdc); + } + finally + { + int result = PInvokeCore.ReleaseDC(hwnd, hdc); + Debug.Assert(result != 0); + } + } + + private void WmPrint(ref Message m) + { + base.WndProc(ref m); + + if (EffectiveVisualStylesMode < VisualStylesMode.Net11 + || ((nint)m.LParamInternal & PInvoke.PRF_NONCLIENT) == 0) + { + return; + } + + HDC hdc = (HDC)m.WParamInternal; + + if (hdc.IsNull) + { + return; + } + + using Graphics graphics = Graphics.FromHdc(hdc); + OnNcPaint(graphics, hdc); + } + + /// + /// Builds a non-client update region, in screen coordinates, that spans the whole window but + /// excludes the live client rectangle. This is handed to the default WM_NCPAINT handler so + /// it cannot overpaint (erase) the client area of scrollbar-bearing edit controls. + /// + /// + /// The non-client region scope, or a null scope when the window rectangle cannot be retrieved. + /// + private RegionScope CreateNonClientClipRegion() + { + if (!PInvokeCore.GetWindowRect(this, out RECT windowRect)) + { + return new RegionScope(HRGN.Null); + } + + PInvokeCore.GetClientRect(this, out RECT clientRect); + + Point clientTopLeft = default; + PInvoke.ClientToScreen(this, ref clientTopLeft); + + RegionScope nonClientRegion = new(windowRect.left, windowRect.top, windowRect.right, windowRect.bottom); + + using RegionScope clientRegion = new( + clientTopLeft.X, + clientTopLeft.Y, + clientTopLeft.X + clientRect.Width, + clientTopLeft.Y + clientRect.Height); + + if (PInvokeCore.CombineRgn(nonClientRegion.Region, nonClientRegion.Region, clientRegion.Region, RGN_COMBINE_MODE.RGN_DIFF) + == GDI_REGION_TYPE.RGN_ERROR) + { + nonClientRegion.Dispose(); + return new RegionScope(HRGN.Null); + } + + return nonClientRegion; + } + + /// + /// Paints the modern Visual Styles non-client chrome (border, rounded + /// lozenge, focus indicator) into a shared offscreen buffer and blits it to the supplied window DC. + /// + private protected virtual void OnNcPaint(Graphics graphics, HDC windowHdc) + { + int cornerRadius = ScaleVisualStylesMetric(ModernControlVisualStyles.FieldCornerRadius); + Size focusBorderMetrics = GetVisualStylesFocusBorderMetrics(); + int borderThickness = Math.Max(focusBorderMetrics.Width, focusBorderMetrics.Height); + int focusBandHeight = GetVisualStylesFocusBandHeight(); + + Color adornerColor = ForeColor; + + Color clientBackColor = BackColor; + Color parentBackColor = Parent?.BackColor ?? BackColor; + + using var clientBackgroundBrush = clientBackColor.GetCachedSolidBrushScope(); + using var adornerBrush = adornerColor.GetCachedSolidBrushScope(); + using var adornerPen = adornerColor.GetCachedPenScope(borderThickness); + + Rectangle bounds = new( + x: 0, + y: 0, + width: Bounds.Width, + height: Bounds.Height); + + // Repaint the outermost client pixel with the chrome background so no residual native edge can + // remain between the managed frame and edit surface. The rest of the live client stays protected. + Rectangle nativeClientBounds = Rectangle.Intersect(bounds, GetNativeClientRectangle()); + Rectangle protectedClientBounds = GetProtectedClientBounds(nativeClientBounds); + Rectangle[] scrollBarBounds = GetVisibleScrollBarRectangles(bounds); + + Rectangle deflatedBounds = bounds; + + // Making sure we never color outside the lines. + deflatedBounds.Width -= 1; + deflatedBounds.Height -= 1; + + // Keep the target clip excluded from the GDI+ drawing as well as from the explicit blits below. + using Region region = new(bounds); + graphics.Clip = region; + graphics.ExcludeClip(protectedClientBounds); + + // 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 buffer is reused when the size fits; steady-state + // allocation is zero. + BufferedGraphicsContext context = BufferedGraphicsManager.Current; + using BufferedGraphics buffer = context.Allocate(graphics, bounds); + + // Intentional: the buffer owns this Graphics - do NOT dispose buffer.Graphics separately. + Graphics offscreenGraphics = buffer.Graphics; + Rectangle bufferBounds = bounds; + + // We need anti-aliasing for the rounded chrome. + offscreenGraphics.SmoothingMode = SmoothingMode.AntiAlias; + + // AddRoundedRectangle receives the bounding size of each corner arc, so one corner size plus + // the border thickness is the minimum height that avoids overlapping curves. + bool canRenderRoundedChrome = CanRenderVisualStylesRoundedChrome( + deflatedBounds, + cornerRadius, + borderThickness); + + if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) + { + ParentBackgroundRenderer.Paint(this, offscreenGraphics, bufferBounds, parentBackColor); + } + else + { + using var parentBackgroundBrush = parentBackColor.GetCachedSolidBrushScope(); + offscreenGraphics.FillRectangle(parentBackgroundBrush, bounds); + } + + switch (BorderStyle) + { + case BorderStyle.None: + + // Just fill a rectangle. + offscreenGraphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + break; + + case BorderStyle.FixedSingle: + + offscreenGraphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + offscreenGraphics.DrawRectangle(adornerPen, deflatedBounds); + break; + + case BorderStyle.Fixed3D: + + if (canRenderRoundedChrome) + { + using GraphicsPath roundedBodyPath = new(); + roundedBodyPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + offscreenGraphics.FillPath(clientBackgroundBrush, roundedBodyPath); + offscreenGraphics.DrawPath(adornerPen, roundedBodyPath); + + // The rounded chrome is clipped with a non-antialiased region; blend the resulting + // corner artifacts into the parent by tracing the parent color just outside the border. + ParentBackgroundRenderer.PaintRoundedBorderRegionMitigation( + offscreenGraphics, + deflatedBounds, + new Size(cornerRadius, cornerRadius), + borderThickness, + parentBackColor); + } + else + { + // Chrome degradation fallback - flat render in place of the broken lozenge. + offscreenGraphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + offscreenGraphics.DrawRectangle(adornerPen, deflatedBounds); + } + + break; + } + + if (BorderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) + { + Color focusColor = GetVisualStylesFocusColor(SystemInformation.HighContrast); + FocusIndicatorRenderer.DrawRoundedFocusIndicator( + offscreenGraphics, + deflatedBounds, + cornerRadius, + borderThickness, + ScaleVisualStylesMetric(VisualStylesFocusBandHeight), + adornerColor, + focusColor); + } + else if (Focused) + { + Color focusColor = GetVisualStylesFocusColor(SystemInformation.HighContrast); + using var focusPen = focusColor.GetCachedPenScope(borderThickness); + offscreenGraphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom, + deflatedBounds.Right, + deflatedBounds.Bottom); + offscreenGraphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom - 1, + deflatedBounds.Right, + deflatedBounds.Bottom - 1); + } + + Rectangle[] nonClientBands = GetNonClientPaintBands( + bufferBounds, + protectedClientBounds, + scrollBarBounds); + IntPtr bufferHdc = offscreenGraphics.GetHdc(); + + try + { + foreach (Rectangle band in nonClientBands) + { + if (band.Width > 0 && band.Height > 0) + { + PInvokeCore.BitBlt( + hdc: windowHdc, + x: band.X, + y: band.Y, + cx: band.Width, + cy: band.Height, + hdcSrc: (HDC)bufferHdc, + x1: band.X, + y1: band.Y, + rop: ROP_CODE.SRCCOPY); + } + } + } + finally + { + offscreenGraphics.ReleaseHdcInternal(bufferHdc); + } + } + + private static Rectangle[] GetNonClientPaintBands(Rectangle bounds, Rectangle clientBounds) + => GetNonClientPaintBands(bounds, clientBounds, []); + + private static Rectangle GetProtectedClientBounds(Rectangle clientBounds) + => clientBounds.Width > 2 && clientBounds.Height > 2 + ? Rectangle.Inflate(clientBounds, -1, -1) + : clientBounds; + + private static Rectangle[] GetNonClientPaintBands( + Rectangle bounds, + Rectangle clientBounds, + Rectangle[] additionalProtectedBounds) + { + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return [Rectangle.Empty, Rectangle.Empty, Rectangle.Empty, Rectangle.Empty]; + } + + Rectangle protectedBounds = Rectangle.Intersect(bounds, clientBounds); + + Rectangle[] initialBands = protectedBounds.Width <= 0 || protectedBounds.Height <= 0 + ? [bounds, Rectangle.Empty, Rectangle.Empty, Rectangle.Empty] + : [ + Rectangle.FromLTRB(bounds.Left, bounds.Top, bounds.Right, protectedBounds.Top), + Rectangle.FromLTRB(bounds.Left, protectedBounds.Bottom, bounds.Right, bounds.Bottom), + Rectangle.FromLTRB(bounds.Left, protectedBounds.Top, protectedBounds.Left, protectedBounds.Bottom), + Rectangle.FromLTRB(protectedBounds.Right, protectedBounds.Top, bounds.Right, protectedBounds.Bottom) + ]; + + if (additionalProtectedBounds.Length == 0) + { + return initialBands; + } + + List paintBands = [.. initialBands]; + + foreach (Rectangle additionalProtectedBoundsItem in additionalProtectedBounds) + { + Rectangle clippedProtectedBounds = Rectangle.Intersect(bounds, additionalProtectedBoundsItem); + + if (clippedProtectedBounds.Width <= 0 || clippedProtectedBounds.Height <= 0) + { + continue; + } + + for (int i = paintBands.Count - 1; i >= 0; i--) + { + Rectangle band = paintBands[i]; + Rectangle intersection = Rectangle.Intersect(band, clippedProtectedBounds); + + if (intersection.Width <= 0 || intersection.Height <= 0) + { + continue; + } + + paintBands.RemoveAt(i); + AddPaintBand(Rectangle.FromLTRB(band.Left, band.Top, band.Right, intersection.Top)); + AddPaintBand(Rectangle.FromLTRB(band.Left, intersection.Bottom, band.Right, band.Bottom)); + AddPaintBand(Rectangle.FromLTRB(band.Left, intersection.Top, intersection.Left, intersection.Bottom)); + AddPaintBand(Rectangle.FromLTRB(intersection.Right, intersection.Top, band.Right, intersection.Bottom)); + } + } + + return [.. paintBands]; + + void AddPaintBand(Rectangle band) + { + if (band.Width > 0 && band.Height > 0) + { + paintBands.Add(band); + } + } + } + + private unsafe Rectangle[] GetVisibleScrollBarRectangles(Rectangle bounds) + { + if (!IsHandleCreated + || !PInvokeCore.GetWindowRect(this, out RECT windowRect)) + { + return []; + } + + List scrollBarBounds = []; + AddVisibleScrollBar(HorizontalScrollBarObjectId); + AddVisibleScrollBar(VerticalScrollBarObjectId); + + return [.. scrollBarBounds]; + + void AddVisibleScrollBar(OBJECT_IDENTIFIER objectId) + { + SCROLLBARINFO scrollBarInfo = new() + { + cbSize = (uint)sizeof(SCROLLBARINFO) + }; + + if (!PInvoke.GetScrollBarInfo((HWND)Handle, objectId, ref scrollBarInfo) + || (scrollBarInfo.rgstate[0] & StateSystemInvisible) != 0) + { + return; + } + + Rectangle scrollBarRectangle = Rectangle.FromLTRB( + scrollBarInfo.rcScrollBar.left - windowRect.left, + scrollBarInfo.rcScrollBar.top - windowRect.top, + scrollBarInfo.rcScrollBar.right - windowRect.left, + scrollBarInfo.rcScrollBar.bottom - windowRect.top); + scrollBarRectangle.Intersect(bounds); + + if (scrollBarRectangle.Width > 0 && scrollBarRectangle.Height > 0) + { + scrollBarBounds.Add(scrollBarRectangle); + } + } + } + + private Rectangle GetNativeClientRectangle() + { + if (!IsHandleCreated + || !PInvokeCore.GetWindowRect(this, out RECT windowRect)) + { + return Rectangle.Empty; + } + + PInvokeCore.GetClientRect(this, out RECT clientRect); + Point clientTopLeft = default; + PInvoke.ClientToScreen(this, ref clientTopLeft); + + return new Rectangle( + clientTopLeft.X - windowRect.left, + clientTopLeft.Y - windowRect.top, + clientRect.Width, + clientRect.Height); + } + + private AnimatedFocusIndicatorRenderer FocusIndicatorRenderer + => _focusIndicatorRenderer ??= new(this, InvalidateVisualStylesFrame); + + private unsafe void InvalidateVisualStylesFrame() + { + if (!IsHandleCreated) + { + return; + } + + PInvoke.RedrawWindow( + hWnd: this, + lprcUpdate: null, + hrgnUpdate: HRGN.Null, + flags: REDRAW_WINDOW_FLAGS.RDW_FRAME | REDRAW_WINDOW_FLAGS.RDW_INVALIDATE); + } + + private void WmReflectCommand(ref Message m) + { + if (_textBoxFlags[s_codeUpdateText] || _textBoxFlags[s_creatingHandle]) + { + return; + } + + uint hiword = m.WParamInternal.HIWORD; + + if (hiword == PInvoke.EN_CHANGE && CanRaiseTextChangedEvent) + { + OnTextChanged(EventArgs.Empty); + } + else if (hiword == PInvoke.EN_UPDATE) + { + // Force update to the Modified property, which will trigger ModifiedChanged event handlers + _ = Modified; } } private void WmSetFont(ref Message m) { base.WndProc(ref m); + if (!_textBoxFlags[s_multiline]) { PInvokeCore.SendMessage(this, PInvokeCore.EM_SETMARGINS, (WPARAM)(PInvoke.EC_LEFTMARGIN | PInvoke.EC_RIGHTMARGIN)); @@ -2036,9 +3003,11 @@ private void WmSetFont(ref Message m) private void WmGetDlgCode(ref Message m) { base.WndProc(ref m); + m.ResultInternal = AcceptsTab - ? (LRESULT)(m.ResultInternal | (int)PInvoke.DLGC_WANTTAB) - : (LRESULT)(m.ResultInternal & ~(int)(PInvoke.DLGC_WANTTAB | PInvoke.DLGC_WANTALLKEYS)); + ? (LRESULT)(nint)(m.ResultInternal | (int)PInvoke.DLGC_WANTTAB) + : (LRESULT)(nint)(m.ResultInternal & ~(int)(PInvoke.DLGC_WANTTAB + | PInvoke.DLGC_WANTALLKEYS)); } /// @@ -2081,19 +3050,34 @@ protected override void WndProc(ref Message m) { switch (m.MsgInternal) { + case PInvokeCore.WM_NCCALCSIZE: + WmNcCalcSize(ref m); + break; + + case PInvokeCore.WM_NCPAINT: + WmNcPaint(ref m); + break; + case PInvokeCore.WM_PRINT: + WmPrint(ref m); + break; + case PInvokeCore.WM_LBUTTONDBLCLK: _doubleClickFired = true; base.WndProc(ref m); break; + case MessageId.WM_REFLECT_COMMAND: WmReflectCommand(ref m); break; + case PInvokeCore.WM_GETDLGCODE: WmGetDlgCode(ref m); break; + case PInvokeCore.WM_SETFONT: WmSetFont(ref m); break; + case PInvokeCore.WM_CONTEXTMENU: if (ShortcutsEnabled) { @@ -2109,6 +3093,7 @@ protected override void WndProc(ref Message m) } break; + case PInvokeCore.WM_DESTROY: if (TryGetAccessibilityObject(out AccessibleObject? @object) && @object is TextBoxBaseAccessibleObject accessibleObject && !RecreatingHandle) @@ -2119,6 +3104,7 @@ protected override void WndProc(ref Message m) base.WndProc(ref m); break; + default: base.WndProc(ref m); break; diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ToolStrips/ToolStripComboBox.ToolStripComboBoxControl.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ToolStrips/ToolStripComboBox.ToolStripComboBoxControl.cs index 64749288164..2b25db2e1e4 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ToolStrips/ToolStripComboBox.ToolStripComboBoxControl.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ToolStrips/ToolStripComboBox.ToolStripComboBoxControl.cs @@ -44,7 +44,9 @@ protected override AccessibleObject CreateAccessibilityInstance() internal override FlatComboAdapter CreateFlatComboAdapterInstance() { - return new ToolStripComboBoxFlatComboAdapter(this); + return UsesModernComboAdapter + ? base.CreateFlatComboAdapterInstance() + : new ToolStripComboBoxFlatComboAdapter(this); } protected override bool IsInputKey(Keys keyData) diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs index 70f700fe3b7..2205ba42f9b 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/DomainUpDown.cs @@ -57,17 +57,23 @@ public DomainUpDown() : base() [Editor($"System.Windows.Forms.Design.StringCollectionEditor, {Assemblies.SystemDesign}", typeof(UITypeEditor))] public DomainUpDownItemCollection Items => _domainItems ??= new DomainUpDownItemCollection(this); - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public new Padding Padding { get => base.Padding; set => base.Padding = value; } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] + private new bool ShouldSerializePadding() + => Padding != DefaultPadding; + + private void ResetPadding() + => Padding = DefaultPadding; + + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] public new event EventHandler? PaddingChanged { add => base.PaddingChanged += value; @@ -512,7 +518,7 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) int width = LayoutUtils.OldGetLargestStringSizeInCollection(Font, Items).Width; // AdjustWindowRect with our border, since textbox is borderless. - width = SizeFromClientSizeInternal(new(width, height)).Width + _upDownButtons.Width; + width = GetPreferredWidth(width, height); return new Size(width, height) + Padding.Size; } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs index fd1695e47f6..5c717356efe 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/NumericUpDown.cs @@ -214,17 +214,23 @@ public decimal Minimum } } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public new Padding Padding { get => base.Padding; set => base.Padding = value; } - [Browsable(false)] - [EditorBrowsable(EditorBrowsableState.Never)] + private new bool ShouldSerializePadding() + => Padding != DefaultPadding; + + private void ResetPadding() + => Padding = DefaultPadding; + + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] public new event EventHandler? PaddingChanged { add => base.PaddingChanged += value; @@ -822,7 +828,7 @@ internal override Size GetPreferredSizeCore(Size proposedConstraints) } // Call AdjustWindowRect to add space for the borders - int width = SizeFromClientSizeInternal(new(textWidth, height)).Width + _upDownButtons.Width; + int width = GetPreferredWidth(textWidth, height); return new Size(width, height) + Padding.Size; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.UpDownButtonsAccessibleObject.DirectionButtonAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.UpDownButtonsAccessibleObject.DirectionButtonAccessibleObject.cs index e6ca2322105..b037005a273 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.UpDownButtonsAccessibleObject.DirectionButtonAccessibleObject.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.UpDownButtonsAccessibleObject.DirectionButtonAccessibleObject.cs @@ -33,17 +33,11 @@ public override Rectangle Bounds return Rectangle.Empty; } - // Get button bounds - Rectangle bounds = owner.Bounds; - bounds.Height /= 2; + // Get button bounds (client-relative), honoring the stacked vs side-by-side layout. + Rectangle bounds = owner.GetButtonRectangle(_up ? ButtonID.Up : ButtonID.Down); - if (!_up) - { - bounds.Y += bounds.Height; - } - - // Convert to screen coords - return owner.ParentInternal?.RectangleToScreen(bounds) ?? Rectangle.Empty; + // Convert from the buttons' client coordinates to screen coordinates. + return owner.RectangleToScreen(bounds); } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs index 57e8edf7ca4..726ae729253 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownButtons.cs @@ -54,27 +54,68 @@ public event UpDownEventHandler? UpDown remove => _upDownEventHandler -= value; } + /// + /// When the two buttons are laid out side by side (see + /// ); otherwise they are stacked vertically. + /// + internal bool UseSideBySideButtons => _parent.UseSideBySideButtons; + + /// + /// Returns the client-area rectangle occupied by the requested button. In the classic (stacked) + /// layout the up button is the top half and the down button the bottom half. In the modern + /// (side-by-side) layout the increment (up) button is on the trailing edge and the decrement + /// (down) button on the leading edge, mirrored under right-to-left. + /// + internal Rectangle GetButtonRectangle(ButtonID button) + { + Rectangle client = ClientRectangle; + + if (UseSideBySideButtons) + { + int spacing = Math.Min(_parent.ModernButtonGroupSpacing, client.Width); + int availableWidth = Math.Max(0, client.Width - spacing); + int leadingWidth = (availableWidth + 1) / 2; + Rectangle leadingRect = new(client.X, client.Y, leadingWidth, client.Height); + Rectangle trailingRect = new( + client.X + leadingWidth + spacing, + client.Y, + availableWidth - leadingWidth, + client.Height); + + bool rightToLeft = _parent.RightToLeft == RightToLeft.Yes; + Rectangle upRect = rightToLeft ? leadingRect : trailingRect; + Rectangle downRect = rightToLeft ? trailingRect : leadingRect; + + return button == ButtonID.Up ? upRect : downRect; + } + + int halfHeight = client.Height / 2; + Rectangle topRect = new(client.X, client.Y, client.Width, halfHeight); + Rectangle bottomRect = new(client.X, client.Y + halfHeight, client.Width, client.Height - halfHeight); + + return button == ButtonID.Up ? topRect : bottomRect; + } + /// /// Called when the mouse button is pressed - we need to start spinning the value of the up-down control. /// /// The mouse event arguments. private void BeginButtonPress(MouseEventArgs e) { - int half_height = Size.Height / 2; + ButtonID button = GetButtonRectangle(ButtonID.Up).Contains(e.Location) + ? ButtonID.Up + : GetButtonRectangle(ButtonID.Down).Contains(e.Location) + ? ButtonID.Down + : ButtonID.None; - if (e.Y < half_height) + if (button == ButtonID.None) { - // Up button - _pushed = _captured = ButtonID.Up; - Invalidate(); - } - else - { - // Down button - _pushed = _captured = ButtonID.Down; - Invalidate(); + return; } + _pushed = _captured = button; + Invalidate(); + // Capture the mouse Capture = true; @@ -148,14 +189,8 @@ protected override void OnMouseMove(MouseEventArgs e) if (Capture) { - // Determine button area - Rectangle rect = ClientRectangle; - rect.Height /= 2; - - if (_captured == ButtonID.Down) - { - rect.Y += rect.Height; - } + // Determine the captured button area + Rectangle rect = GetButtonRectangle(_captured); // Test if the mouse has moved outside the button area if (rect.Contains(e.X, e.Y)) @@ -188,19 +223,19 @@ protected override void OnMouseMove(MouseEventArgs e) } // Logic for seeing which button is Hot if any - Rectangle rectUp = ClientRectangle, rectDown = ClientRectangle; - rectUp.Height /= 2; - rectDown.Y += rectDown.Height / 2; + Rectangle rectUp = GetButtonRectangle(ButtonID.Up); + Rectangle rectDown = GetButtonRectangle(ButtonID.Down); // Check if the mouse is on the upper or lower button. Note that it could be in neither. - if (rectUp.Contains(e.X, e.Y)) - { - _mouseOver = ButtonID.Up; - Invalidate(); - } - else if (rectDown.Contains(e.X, e.Y)) + ButtonID mouseOver = rectUp.Contains(e.X, e.Y) + ? ButtonID.Up + : rectDown.Contains(e.X, e.Y) + ? ButtonID.Down + : ButtonID.None; + + if (_mouseOver != mouseOver) { - _mouseOver = ButtonID.Down; + _mouseOver = mouseOver; Invalidate(); } @@ -270,9 +305,48 @@ protected override void OnPaint(PaintEventArgs e) int half_height = ClientSize.Height / 2; // Draw the up and down buttons - if (Application.IsDarkModeEnabled) + if (UseSideBySideButtons) + { + // Modern side-by-side layout: decrement (down) on the leading edge, increment (up) on + // the trailing edge (mirrored under right-to-left via GetButtonRectangle). Rendered with + // the modern control-button renderer, which adapts to both light and dark modes. + bool isDarkMode = Application.IsDarkModeEnabled; + + using Graphics cachedGraphics = EnsureCachedBitmap(ClientSize.Width, ClientSize.Height); + + DrawModernControlButton( + cachedGraphics, + GetButtonRectangle(ButtonID.Down), + ModernControlButtonStyle.Down, + GetButtonState(ButtonID.Down), + isDarkMode); + + DrawModernControlButton( + cachedGraphics, + GetButtonRectangle(ButtonID.Up), + ModernControlButtonStyle.Up, + GetButtonState(ButtonID.Up), + isDarkMode); + + e.GraphicsInternal.DrawImageUnscaled(_cachedBitmap, new Point(0, 0)); + + int spacing = _parent.ModernButtonGroupSpacing; + if (spacing > 0) + { + Rectangle upBounds = GetButtonRectangle(ButtonID.Up); + Rectangle downBounds = GetButtonRectangle(ButtonID.Down); + Rectangle gap = new( + Math.Min(upBounds.Right, downBounds.Right), + 0, + spacing, + ClientSize.Height); + using var gapBrush = _parent.BackColor.GetCachedSolidBrushScope(); + e.Graphics.FillRectangle(gapBrush, gap); + } + } + else if (Application.IsDarkModeEnabled) { - Graphics cachedGraphics = EnsureCachedBitmap( + using Graphics cachedGraphics = EnsureCachedBitmap( _parent._defaultButtonsWidth, ClientSize.Height); @@ -356,7 +430,7 @@ protected override void OnPaint(PaintEventArgs e) _pushed == ButtonID.Down ? ButtonState.Pushed : (Enabled ? ButtonState.Normal : ButtonState.Inactive)); } - if (half_height != (ClientSize.Height + 1) / 2) + if (!UseSideBySideButtons && half_height != (ClientSize.Height + 1) / 2) { // When control has odd height, a line needs to be drawn below the buttons with the BackColor. Color color = _parent.BackColor; @@ -374,6 +448,27 @@ protected override void OnPaint(PaintEventArgs e) base.OnPaint(e); } + /// + /// Computes the modern rendering state for the requested button from the current enabled, + /// pushed, and hot states. + /// + private ModernControlButtonState GetButtonState(ButtonID button) + { + if (!Enabled) + { + return ModernControlButtonState.Disabled; + } + + if (_pushed == button) + { + return ModernControlButtonState.Pressed; + } + + return _mouseOver == button + ? ModernControlButtonState.Hover + : ModernControlButtonState.Normal; + } + /// /// Ensures that the Bitmap has the correct size and returns the Graphics object from that Bitmap. /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs index bfdb45c17ef..408617a9f65 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.UpDownEdit.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Drawing; using Windows.Win32.UI.Accessibility; namespace System.Windows.Forms; @@ -19,6 +20,18 @@ internal UpDownEdit(UpDownBase parent) _parent = parent; } + public override VisualStylesMode VisualStylesMode + { + get => VisualStylesMode.Classic; + set + { + } + } + + private protected override void OnNcPaint(Graphics graphics, HDC windowHdc) + { + } + [AllowNull] public override string Text { @@ -113,7 +126,9 @@ protected override void OnKeyUp(KeyEventArgs e) protected override void OnGotFocus(EventArgs e) { _parent.SetActiveControl(this); + _parent.SetModernFocusState(focused: true); _parent.InvokeGotFocus(_parent, e); + _parent.Invalidate(); if (IsAccessibilityObjectCreated) { @@ -122,6 +137,10 @@ protected override void OnGotFocus(EventArgs e) } protected override void OnLostFocus(EventArgs e) - => _parent.InvokeLostFocus(_parent, e); + { + _parent.SetModernFocusState(focused: false); + _parent.InvokeLostFocus(_parent, e); + _parent.Invalidate(); + } } } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs index 9508d76256a..ac1b12a3008 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/UpDown/UpDownBase.cs @@ -3,7 +3,10 @@ using System.ComponentModel; using System.Drawing; +using System.Drawing.Drawing2D; using System.Runtime.InteropServices; +using System.Windows.Forms.Layout; +using System.Windows.Forms.Rendering.Animation; using System.Windows.Forms.VisualStyles; using Microsoft.Win32; @@ -18,7 +21,14 @@ public abstract partial class UpDownBase : ContainerControl private const int DefaultWheelScrollLinesPerPage = 1; private const int DefaultButtonsWidth = 16; private const int DefaultControlWidth = 120; - private const int ThemedBorderWidth = 1; // width of custom border we draw when themed + + // width of custom border we draw when themed + private const int ThemedBorderWidth = 1; + + // Modern (Net11+) chrome geometry. The edit and button group share the border thickness and + // internal chrome inset used by TextBoxBase; only the gap between the two buttons is additional. + private const int ModernButtonGroupSpacingLogical = 2; + private const int ModernFocusBandHeight = 4; private const BorderStyle DefaultBorderStyle = BorderStyle.Fixed3D; private const LeftRightAlignment DefaultUpDownAlign = LeftRightAlignment.Right; private const int DefaultTimerInterval = 500; @@ -33,6 +43,7 @@ public abstract partial class UpDownBase : ContainerControl /// The current border for this edit control. /// private BorderStyle _borderStyle = DefaultBorderStyle; + private AnimatedFocusIndicatorRenderer? _focusIndicatorRenderer; // Mouse wheel movement private int _wheelDelta; @@ -47,6 +58,7 @@ public UpDownBase() _defaultButtonsWidth = LogicalToDeviceUnits(DefaultButtonsWidth); _upDownButtons = new UpDownButtons(this); + _upDownEdit = new UpDownEdit(this) { BorderStyle = BorderStyle.None, @@ -64,7 +76,10 @@ public UpDownBase() Controls.AddRange([_upDownButtons, _upDownEdit]); - SetStyle(ControlStyles.Opaque | ControlStyles.FixedHeight | ControlStyles.ResizeRedraw, true); + SetStyle(ControlStyles.Opaque + | ControlStyles.FixedHeight + | ControlStyles.ResizeRedraw, true); + SetStyle(ControlStyles.StandardClick, false); SetStyle(ControlStyles.UseTextForAccessibility, false); } @@ -220,6 +235,7 @@ protected override CreateParams CreateParams CreateParams cp = base.CreateParams; cp.Style &= ~(int)WINDOW_STYLE.WS_BORDER; + if (!Application.RenderWithVisualStyles) { switch (_borderStyle) @@ -237,6 +253,14 @@ protected override CreateParams CreateParams } } + /// + /// When , the up/down buttons are laid out side by side (decrement on the + /// leading edge, increment on the trailing edge) rather than stacked. This provides a larger, + /// more accessible click target and is used when a modern + /// ( or above) is in effect. + /// + internal bool UseSideBySideButtons => EffectiveVisualStylesMode >= VisualStylesMode.Net11; + /// /// Deriving classes can override this to configure a default size for their control. /// This is more efficient than setting the size in the control's constructor. @@ -334,19 +358,36 @@ public int PreferredHeight { get { - int height = FontHeight; - - // Adjust for the border style - if (_borderStyle != BorderStyle.None) + if (!UseSideBySideButtons) { - height += SystemInformation.BorderSize.Height * 4 + 3; + int height = FontHeight; + + // Adjust for the border style + if (_borderStyle != BorderStyle.None) + { + height += SystemInformation.BorderSize.Height * 4 + 3; + } + else + { + height += 3; + } + + return height; } - else + + int contentInset = ModernContentInset; + int preferredHeight = FontHeight + (contentInset * 2); + + if (_borderStyle == BorderStyle.Fixed3D) { - height += 3; + int roundedChromeMinimumHeight = LogicalToDeviceUnits(ModernControlVisualStyles.UpDownCornerRadius) + + LogicalToDeviceUnits(ModernControlVisualStyles.BorderThickness) + + LogicalToDeviceUnits(ModernControlVisualStyles.InternalChromeInset); + + preferredHeight = Math.Max(preferredHeight, roundedChromeMinimumHeight); } - return height; + return preferredHeight; } } @@ -450,7 +491,13 @@ public LeftRightAlignment UpDownAlign internal override Rectangle ApplyBoundsConstraints(int suggestedX, int suggestedY, int proposedWidth, int proposedHeight) { - return base.ApplyBoundsConstraints(suggestedX, suggestedY, proposedWidth, PreferredHeight); + int height = AutoSize + ? PreferredHeight + Padding.Vertical + : UseSideBySideButtons + ? proposedHeight + : PreferredHeight; + + return base.ApplyBoundsConstraints(suggestedX, suggestedY, proposedWidth, height); } internal override void ReleaseUiaProvider(HWND handle) @@ -478,6 +525,14 @@ protected override void RescaleConstantsForDpi(int deviceDpiOld, int deviceDpiNe base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew); _defaultButtonsWidth = LogicalToDeviceUnits(DefaultButtonsWidth); _upDownButtons.Width = _defaultButtonsWidth; + CommonProperties.xClearPreferredSizeCache(this); + + if (AutoSize) + { + Height = PreferredHeight; + } + + PositionControls(); } /// @@ -503,9 +558,31 @@ protected override void OnHandleCreated(EventArgs e) protected override void OnHandleDestroyed(EventArgs e) { SystemEvents.UserPreferenceChanged -= UserPreferenceChanged; + _focusIndicatorRenderer?.Dispose(); + _focusIndicatorRenderer = null; base.OnHandleDestroyed(e); } + /// + protected override void OnVisualStylesModeChanged(EventArgs e) + { + base.OnVisualStylesModeChanged(e); + _focusIndicatorRenderer?.Synchronize(Focused, invalidate: false); + CommonProperties.xClearPreferredSizeCache(this); + + if (AutoSize) + { + Height = PreferredHeight; + } + + PositionControls(); + + if (IsHandleCreated) + { + Invalidate(true); + } + } + /// /// Handles painting the buttons on the control. /// @@ -513,6 +590,14 @@ protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); + if (UseSideBySideButtons) + { + // In modern mode we draw a single frame around the whole control (edit and buttons); the + // themed/classic single-textbox border below does not apply. + DrawModernBorder(e); + return; + } + Rectangle editBounds = _upDownEdit.Bounds; Color backColor = BackColor; @@ -636,7 +721,11 @@ protected virtual void OnTextBoxLostFocus(object? source, EventArgs e) /// protected virtual void OnTextBoxResize(object? source, EventArgs e) { - Height = PreferredHeight; + if (!UseSideBySideButtons || AutoSize) + { + Height = PreferredHeight; + } + PositionControls(); } @@ -755,6 +844,7 @@ protected override void OnMouseWheel(MouseEventArgs e) // Evaluate number of bands to scroll int scrollBands = (int)(wheelScrollLines * partialNotches); + if (scrollBands != 0) { int absScrollBands; @@ -801,7 +891,11 @@ protected override void OnFontChanged(EventArgs e) // Clear the font height cache FontHeight = -1; - Height = PreferredHeight; + if (!UseSideBySideButtons || AutoSize) + { + Height = PreferredHeight; + } + PositionControls(); base.OnFontChanged(e); @@ -829,11 +923,19 @@ private void OnUpDown(object? source, UpDownEventArgs e) /// private void PositionControls() { + if (UseSideBySideButtons) + { + PositionControlsModern(); + return; + } + Rectangle upDownEditBounds = Rectangle.Empty; Rectangle upDownButtonsBounds = Rectangle.Empty; - Rectangle clientArea = new(Point.Empty, ClientSize); - int totalClientWidth = clientArea.Width; + Rectangle clientArea = LayoutUtils.DeflateRect( + new Rectangle(Point.Empty, ClientSize), + Padding); + bool themed = Application.RenderWithVisualStyles; BorderStyle borderStyle = BorderStyle; @@ -852,6 +954,7 @@ private void PositionControls() if (_upDownButtons is not null) { int borderFixup = (themed) ? 1 : 2; + if (borderStyle == BorderStyle.None) { borderFixup = 0; @@ -872,8 +975,8 @@ private void PositionControls() if (updownAlign == LeftRightAlignment.Left) { // If the buttons are aligned to the left, swap position of text box/buttons - upDownButtonsBounds.X = totalClientWidth - upDownButtonsBounds.Right; - upDownEditBounds.X = totalClientWidth - upDownEditBounds.Right; + upDownButtonsBounds.X = clientArea.Left + (clientArea.Right - upDownButtonsBounds.Right); + upDownEditBounds.X = clientArea.Left + (clientArea.Right - upDownEditBounds.Right); } // Apply locations @@ -886,10 +989,226 @@ private void PositionControls() } } + private int ModernContentInset + // Match the per-border-style internal padding a modern TextBoxBase applies (see + // ModernControlVisualStyles.GetFieldPadding): the border-padding component depends on the + // border style rather than the flat 1px border stroke. This keeps the edit/button content + // inset one pixel inside the frame for a Fixed3D border, aligning UpDownBase with the other + // modern controls (Fixed3D = 2 + 2, FixedSingle = 1 + 2, None = 1 + 2). + => LogicalToDeviceUnits( + (_borderStyle switch + { + BorderStyle.Fixed3D => ModernControlVisualStyles.Fixed3DBorderPadding, + BorderStyle.FixedSingle => ModernControlVisualStyles.FixedSingleBorderPadding, + _ => ModernControlVisualStyles.NoBorderPadding, + }) + + ModernControlVisualStyles.InternalChromeInset); + + internal int ModernButtonGroupSpacing + => LogicalToDeviceUnits(ModernButtonGroupSpacingLogical); + + internal int GetModernButtonGroupWidth() + => (_defaultButtonsWidth * 2) + ModernButtonGroupSpacing; + + internal int GetPreferredWidth(int textWidth, int height) + => UseSideBySideButtons + ? textWidth + (ModernContentInset * 2) + GetModernButtonGroupWidth() + : SizeFromClientSizeInternal(new(textWidth, height)).Width + _upDownButtons.Width; + + /// + /// Calculates the size and position of the upDownEdit control and the side-by-side updown buttons + /// when a modern is in effect. Both the edit and the buttons are + /// inset by so they sit inside the frame drawn by + /// and clear its rounded corners. + /// + private void PositionControlsModern() + { + Rectangle clientArea = LayoutUtils.DeflateRect( + new Rectangle(Point.Empty, ClientSize), + Padding); + + int pad = ModernContentInset; + int buttonsWidth = Math.Min(GetModernButtonGroupWidth(), Math.Max(0, clientArea.Width - (pad * 2))); + + Rectangle inner = clientArea; + inner.Inflate(-pad, -pad); + + if (inner.Width < 0 || inner.Height < 0) + { + inner = new Rectangle( + x: Math.Min(pad, clientArea.Width), + y: Math.Min(pad, clientArea.Height), + width: 0, + height: 0); + } + + Rectangle upDownEditBounds = inner; + upDownEditBounds.Width = Math.Max(0, inner.Width - buttonsWidth); + + Rectangle upDownButtonsBounds = new( + x: inner.Right - buttonsWidth, + y: inner.Top, + width: buttonsWidth, + height: inner.Height); + + // Left/right updown align translation (also honors RTL). + if (RtlTranslateLeftRight(UpDownAlign) == LeftRightAlignment.Left) + { + upDownButtonsBounds.X = clientArea.Left + (clientArea.Right - upDownButtonsBounds.Right); + upDownEditBounds.X = clientArea.Left + (clientArea.Right - upDownEditBounds.Right); + } + + _upDownEdit?.Bounds = upDownEditBounds; + + if (_upDownButtons is not null) + { + _upDownButtons.Bounds = upDownButtonsBounds; + _upDownButtons.Invalidate(); + } + } + + /// + /// Draws the modern frame around the whole control (edit and side-by-side buttons) using the same + /// rounded/flat chrome a stand-alone modern TextBox paints in its non-client area. The corners are + /// filled with the parent's back color so the rounded frame blends against it. + /// + private void DrawModernBorder(PaintEventArgs e) + { + Rectangle bounds = ClientRectangle; + + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + int cornerRadius = LogicalToDeviceUnits(ModernControlVisualStyles.UpDownCornerRadius); + int borderThickness = LogicalToDeviceUnits(ModernControlVisualStyles.BorderThickness); + + // The adorner (border) color matches the modern TextBox chrome, which uses the fore color. + Color adornerColor = ForeColor; + Color parentBackColor = Parent?.BackColor ?? BackColor; + Color clientBackColor = BackColor; + + using var clientBackgroundBrush = clientBackColor.GetCachedSolidBrushScope(); + using var adornerPen = adornerColor.GetCachedPenScope(borderThickness); + + Rectangle deflatedBounds = bounds; + deflatedBounds.Width -= 1; + deflatedBounds.Height -= 1; + + Graphics graphics = e.Graphics; + using GraphicsStateScope graphicsState = new(graphics); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + // AddRoundedRectangle receives the bounding size of each corner arc, so one corner size plus + // the border thickness is the minimum height that avoids overlapping curves. + bool canRenderRoundedChrome = TextBoxBase.CanRenderVisualStylesRoundedChrome( + deflatedBounds, + cornerRadius, + borderThickness); + + ParentBackgroundRenderer.Paint(this, graphics, bounds, parentBackColor); + + switch (_borderStyle) + { + case BorderStyle.None: + graphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + break; + + case BorderStyle.FixedSingle: + graphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + graphics.DrawRectangle(adornerPen, deflatedBounds); + break; + + case BorderStyle.Fixed3D: + if (canRenderRoundedChrome) + { + using GraphicsPath bodyPath = new(); + bodyPath.AddRoundedRectangle(deflatedBounds, new Size(cornerRadius, cornerRadius)); + graphics.FillPath(clientBackgroundBrush, bodyPath); + graphics.DrawPath(adornerPen, bodyPath); + + // The rounded chrome is clipped with a non-antialiased region; blend the resulting + // corner artifacts into the parent by tracing the parent color just outside the border. + ParentBackgroundRenderer.PaintRoundedBorderRegionMitigation( + graphics, + deflatedBounds, + new Size(cornerRadius, cornerRadius), + borderThickness, + parentBackColor); + } + else + { + graphics.FillRectangle(clientBackgroundBrush, deflatedBounds); + graphics.DrawRectangle(adornerPen, deflatedBounds); + } + + break; + } + + if (_borderStyle == BorderStyle.Fixed3D && canRenderRoundedChrome) + { + Color focusColor = ModernFocusColor; + FocusIndicatorRenderer.DrawRoundedFocusIndicator( + graphics, + deflatedBounds, + cornerRadius, + borderThickness, + LogicalToDeviceUnits(ModernFocusBandHeight), + adornerColor, + focusColor); + } + else if (Focused && _borderStyle == BorderStyle.Fixed3D) + { + Color focusColor = ModernFocusColor; + using var focusPen = focusColor.GetCachedPenScope(borderThickness); + graphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom, + deflatedBounds.Right, + deflatedBounds.Bottom); + graphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom - 1, + deflatedBounds.Right, + deflatedBounds.Bottom - 1); + } + } + + private AnimatedFocusIndicatorRenderer FocusIndicatorRenderer + => _focusIndicatorRenderer ??= new(this, InvalidateModernFocusIndicator); + + private static Color ModernFocusColor + => TextBoxBase.GetVisualStylesFocusColor(SystemInformation.HighContrast); + + private void SetModernFocusState(bool focused) + { + if (!UseSideBySideButtons || _borderStyle != BorderStyle.Fixed3D) + { + return; + } + + FocusIndicatorRenderer.SetFocused( + focused, + animate: SystemInformation.UIEffectsEnabled && !SystemInformation.HighContrast); + } + + private void InvalidateModernFocusIndicator() + { + int focusBandHeight = LogicalToDeviceUnits(ModernFocusBandHeight); + Rectangle focusBounds = ClientRectangle; + focusBounds.Y = Math.Max(focusBounds.Top, focusBounds.Bottom - focusBandHeight); + focusBounds.Height = Math.Min(focusBandHeight, focusBounds.Height); + Invalidate(focusBounds); + } + /// /// Selects a range of text in the up-down control. /// - public void Select(int start, int length) => _upDownEdit.Select(start, length); + public void Select(int start, int length) + => _upDownEdit.Select(start, length); /// /// Create a new with the points translated from the diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index c3900d09c6b..596c9c9b45f 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -4230,6 +4230,35 @@ protected override void OnHandleDestroyed(EventArgs e) } } + // Snapshot of the Windows High Contrast state, used to detect a transition in OnSystemColorsChanged. + private bool _lastHighContrast = SystemInformation.HighContrast; + + /// + protected override void OnSystemColorsChanged(EventArgs e) + { + base.OnSystemColorsChanged(e); + + // Windows High Contrast forces Classic rendering (see Control.EffectiveVisualStylesMode), which changes + // CreateParams for this form and its children. When the High Contrast state actually toggles we must + // rebuild the handle so those CreateParams are re-read. Recreating the top-level Form destroys and + // rebuilds the entire child HWND tree, so the rebuild itself propagates the new mode to every child - + // no per-child notification is needed. Doing this only on Form (not Control) avoids recreating each + // child a second time as Control.OnSystemColorsChanged walks down the tree. + bool highContrast = SystemInformation.HighContrast; + + if (highContrast != _lastHighContrast) + { + _lastHighContrast = highContrast; + + // If visual styles are explicitly disabled they stay disabled regardless of High Contrast, so + // nothing about CreateParams changes and there is no need to recreate. + if (VisualStylesMode is not VisualStylesMode.Disabled) + { + RecreateHandle(); + } + } + } + /// /// Handles the event that a helpButton is clicked /// diff --git a/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs b/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs index 98779fafb7c..8c5f5165d51 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs @@ -79,6 +79,7 @@ internal static class PropertyNames public const string TextImageRelation = "TextImageRelation"; public const string UseCompatibleTextRendering = "UseCompatibleTextRendering"; public const string Visible = "Visible"; + public const string VisualStylesMode = "VisualStylesMode"; public const string WordWrap = "WordWrap"; public const string WrapContents = "WrapContents"; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs new file mode 100644 index 00000000000..9ca7c0ceb6f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace System.Windows.Forms; + +internal static class ParentBackgroundRenderer +{ + internal static void Paint( + Control control, + Graphics graphics, + Rectangle bounds, + Color fallbackColor) + { + ArgumentNullException.ThrowIfNull(control); + ArgumentNullException.ThrowIfNull(graphics); + + using GraphicsStateScope state = new(graphics); + using Region paintRegion = new(bounds); + + // Keep an existing clip (for example, the native TextBox client area) in effect while the + // parent background is painted beneath the complete antialiased control body. + using Region currentClip = graphics.Clip; + paintRegion.Intersect(currentClip); + + Control? parent = control.ParentInternal; + if (parent is null || parent.IsDisposed) + { + using var fallbackBrush = fallbackColor.GetCachedSolidBrushScope(); + graphics.FillRegion(fallbackBrush, paintRegion); + return; + } + + using PaintEventArgs paintEventArgs = new(graphics, bounds); + control.PaintTransparentBackground(paintEventArgs, bounds, paintRegion); + } + + /// + /// Draws a two-pixel line in the parent background color immediately outside the rounded + /// border described by and . + /// + /// + /// + /// The modern rounded chrome masks its corners with a , which cannot be + /// anti-aliased. That leaves jagged pixels just outside the anti-aliased border where the + /// region-clipped parent fill meets the control body. Tracing a short, anti-aliased line in + /// the parent's background color over that band blends the artifact into the parent. + /// + /// + internal static void PaintRoundedBorderRegionMitigation( + Graphics graphics, + Rectangle borderBounds, + Size cornerRadius, + int borderThickness, + Color parentBackColor) + { + ArgumentNullException.ThrowIfNull(graphics); + + if (borderBounds.Width <= 0 || borderBounds.Height <= 0) + { + return; + } + + const int MitigationThickness = 2; + + // Offset the outline so the two-pixel stroke sits just outside the border's outer edge. + int outset = (borderThickness / 2) + 1; + + Rectangle outerBounds = borderBounds; + outerBounds.Inflate(outset, outset); + + Size outerRadius = new( + cornerRadius.Width + outset, + cornerRadius.Height + outset); + + using GraphicsStateScope state = new(graphics); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + using GraphicsPath outline = new(); + outline.AddRoundedRectangle(outerBounds, outerRadius); + + using var pen = parentBackColor.GetCachedPenScope(MitigationThickness); + graphics.DrawPath(pen, outline); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs new file mode 100644 index 00000000000..7c2f754c514 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms.Rendering.Animation; + +/// +/// Represents an abstract base class for animated control renderers. +/// +/// The control associated with the renderer. +internal abstract class AnimatedControlRenderer(Control control) : IDisposable +{ + private const float InteractionShadeAmount = 0.08f; + + private Color _accentColor; + private bool _accentColorInitialized; + private bool _disposedValue; + protected float AnimationProgress = 1; + + /// + /// Callback for the animation progress. This method is called by the animation manager on each + /// frame tick delivered by HighPrecisionTimer. + /// + /// A fraction between 0 and 1 representing the animation progress. + public virtual void AnimationProc(float animationProgress) + { + AnimationProgress = animationProgress; + } + + /// + /// Called when the control needs to be painted. + /// + /// The to paint the control. + public abstract void RenderControl(Graphics graphics); + + /// + /// Invalidates the control, causing it to be redrawn, which in turns triggers + /// . + /// + public void Invalidate() => control.Invalidate(); + + /// + /// Starts the animation and gets the animation parameters. + /// + public void StartAnimation() + { + if (IsRunning) + { + return; + } + + // Get the animation parameters. + (int animationDuration, AnimationCycle animationCycle) = OnAnimationStarted(); + + // Register the renderer with the animation manager. + AnimationManager.RegisterOrUpdateAnimationRenderer( + this, + animationDuration, + animationCycle); + + IsRunning = true; + } + + internal void StopAnimationInternal() => IsRunning = false; + + public void RestartAnimation() + { + if (IsRunning) + { + StopAnimation(); + } + + StartAnimation(); + } + + /// + /// Called in a derived class when the animation starts. The derived class returns the animation duration and cycle type. + /// + /// + /// Tuple containing the animation duration and cycle type. + /// + protected abstract (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted(); + + /// + /// Called by the animation manager when the animation ends. + /// + internal void EndAnimation() + { + OnAnimationEnded(); + } + + /// + /// Called in a derived class when the animation ends. + /// The derived class can perform any cleanup or state change operations. + /// + protected abstract void OnAnimationEnded(); + + /// + /// Can be called by an implementing control, when the animation needs to be stopped or restarted. + /// + public void StopAnimation() + { + AnimationManager.Suspend(this); + OnAnimationStopped(); + } + + /// + /// Called in the derived class when the animation is stopped. + /// The derived class can perform any cleanup or state change operations. + /// + protected abstract void OnAnimationStopped(); + + /// + /// Gets a value indicating whether the animation is running. + /// + public bool IsRunning { get; private set; } + + /// + /// Gets the control associated with the renderer. + /// + protected Control Control => control; + + protected Color WindowsAccentColor + { + get + { + if (!_accentColorInitialized) + { + _accentColor = Application.GetWindowsAccentColor(); + _accentColorInitialized = true; + } + + return _accentColor; + } + } + + internal bool IsAccentColorCached => _accentColorInitialized; + + internal void InvalidateAccentColor() + => _accentColorInitialized = false; + + internal static Color ApplyInteractionShade(Color color, float progress) + { + Color interactionColor = PopupButtonColorMath.TowardsContrast(color, InteractionShadeAmount); + return PopupButtonColorMath.Blend(color, interactionColor, progress); + } + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// to release both managed and unmanaged resources; to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + // Remove the renderer from the animation manager. + AnimationManager.UnregisterAnimationRenderer(this); + } + + _disposedValue = true; + } + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method. + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedFocusIndicatorRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedFocusIndicatorRenderer.cs new file mode 100644 index 00000000000..0ef0c90c736 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedFocusIndicatorRenderer.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms.Rendering.Animation; + +/// +/// Animates a rounded focus indicator between a control's border and focus colors. +/// +internal sealed class AnimatedFocusIndicatorRenderer : AnimatedControlRenderer +{ + private const int AnimationDurationMilliseconds = 200; + + private readonly Action _invalidate; + private float _focusCurrent; + private float _focusStart; + private float _focusTarget; + private bool _initialized; + + public AnimatedFocusIndicatorRenderer(Control control, Action invalidate) + : base(control) + { + ArgumentNullException.ThrowIfNull(invalidate); + _invalidate = invalidate; + } + + internal float FocusAmount + { + get + { + EnsureInitialized(Control.Focused ? 1f : 0f); + return _focusCurrent; + } + } + + internal Color GetCurrentColor(Color borderColor, Color focusColor) + => PopupButtonColorMath.Blend(borderColor, focusColor, FocusAmount); + + internal void SetFocused(bool focused, bool animate) + { + float target = focused ? 1f : 0f; + EnsureInitialized(1f - target); + + if (_focusTarget == target && (!IsRunning || animate)) + { + return; + } + + _focusStart = _focusCurrent; + _focusTarget = target; + + if (!animate) + { + Synchronize(focused, invalidate: true); + return; + } + + RestartAnimation(); + _invalidate(); + } + + internal void Synchronize(bool focused, bool invalidate) + { + if (IsRunning) + { + StopAnimation(); + } + + _focusCurrent = focused ? 1f : 0f; + _focusStart = _focusCurrent; + _focusTarget = _focusCurrent; + _initialized = true; + AnimationProgress = 1f; + + if (invalidate) + { + _invalidate(); + } + } + + internal void DrawRoundedFocusIndicator( + Graphics graphics, + Rectangle bounds, + int cornerSize, + int borderThickness, + int focusBandHeight, + Color borderColor, + Color focusColor) + { + if (bounds.Width <= 0 + || bounds.Height <= 0 + || cornerSize <= 0 + || borderThickness <= 0 + || focusBandHeight <= 0 + || FocusAmount <= 0f) + { + return; + } + + int focusBandTop = Math.Max(bounds.Top, bounds.Bottom - focusBandHeight + 1); + Rectangle focusClip = Rectangle.FromLTRB( + bounds.Left, + focusBandTop, + bounds.Right + 1, + bounds.Bottom + 1); + + using GraphicsStateScope state = new(graphics); + graphics.SetClip(focusClip, CombineMode.Intersect); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + using GraphicsPath focusPath = new(); + focusPath.AddRoundedRectangle(bounds, new Size(cornerSize, cornerSize)); + + Color color = GetCurrentColor(borderColor, focusColor); + using var focusPen = color.GetCachedPenScope(borderThickness); + graphics.DrawPath(focusPen, focusPath); + } + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + float easedProgress = EaseOut(animationProgress); + _focusCurrent = Lerp(_focusStart, _focusTarget, easedProgress); + _invalidate(); + } + + public override void RenderControl(Graphics graphics) + { + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + AnimationProgress = 0f; + return (AnimationDurationMilliseconds, AnimationCycle.Once); + } + + protected override void OnAnimationStopped() + { + } + + protected override void OnAnimationEnded() + { + StopAnimation(); + _focusCurrent = _focusTarget; + _focusStart = _focusCurrent; + AnimationProgress = 1f; + _invalidate(); + } + + private void EnsureInitialized(float initialValue) + { + if (_initialized) + { + return; + } + + _focusCurrent = initialValue; + _focusStart = initialValue; + _focusTarget = initialValue; + _initialized = true; + } + + private static float EaseOut(float progress) + => 1f - ((1f - progress) * (1f - progress)); + + private static float Lerp(float start, float end, float amount) + => start + ((end - start) * Math.Clamp(amount, 0f, 1f)); +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs new file mode 100644 index 00000000000..0dfac5f12f6 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationCycle.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Animation; + +internal enum AnimationCycle +{ + Once, + Loop, + Bounce +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.cs new file mode 100644 index 00000000000..8a199cdb53e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.AnimationRendererItem.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.Rendering.Animation; + +internal partial class AnimationManager +{ + /// + /// Stores the timeline state for one animated renderer. + /// + private sealed class AnimationRendererItem + { + public AnimationRendererItem(AnimatedControlRenderer renderer, int animationDuration, AnimationCycle animationCycle) + { + Renderer = renderer; + AnimationDuration = animationDuration; + AnimationCycle = animationCycle; + } + + public AnimatedControlRenderer Renderer { get; } + public int AnimationDuration { get; set; } + public int FrameCount { get; set; } + public AnimationCycle AnimationCycle { get; set; } + public int FrameOffset { get; set; } = 1; + public TimeSpan? TargetTimestamp { get; set; } + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs new file mode 100644 index 00000000000..5f5a19e74f7 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs @@ -0,0 +1,262 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.Diagnostics.Tracing; +using System.Windows.Forms.Animation; + +namespace System.Windows.Forms.Rendering.Animation; + +/// +/// Per-UI-thread dispatcher that drives instances from a single +/// HighPrecisionTimer registration. +/// +/// +/// +/// A manager is created on demand for each UI thread. The timer captures that thread's +/// , so per-frame work can invalidate controls directly without routing +/// animations through another message loop. +/// +/// +internal partial class AnimationManager +{ + private readonly HighPrecisionTimer.TimerRegistration _timerRegistration; + private readonly EventHandler _threadExitHandler; + private readonly int _threadId; + + private readonly ConcurrentDictionary _renderer = []; + + private bool _hasTickTimestamp; + private int _disposed; + private TimeSpan _lastTickTimestamp; + + [ThreadStatic] + private static AnimationManager? s_instance; + + private static AnimationManager Instance + => s_instance ??= new AnimationManager(); + + private AnimationManager() + { + _threadId = Environment.CurrentManagedThreadId; + _threadExitHandler = OnThreadExit; + _timerRegistration = HighPrecisionTimer.Register(OnFrameTickAsync); + Application.ThreadExit += _threadExitHandler; + } + + /// + /// Disposes the animation renderers and releases the timer registration. + /// + private void DisposeRenderer() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _timerRegistration.Dispose(); + Application.ThreadExit -= _threadExitHandler; + + foreach (AnimatedControlRenderer renderer in _renderer.Keys) + { + _ = _renderer.TryRemove(renderer, out _); + renderer.Dispose(); + } + + if (ReferenceEquals(s_instance, this)) + { + s_instance = null; + } + } + + private void OnThreadExit(object? sender, EventArgs e) + { + if (Environment.CurrentManagedThreadId == _threadId) + { + DisposeRenderer(); + } + } + + /// + /// Registers an animation renderer. + /// + /// The animation renderer to register. + /// The duration of the animation. + /// The animation cycle. + public static void RegisterOrUpdateAnimationRenderer( + AnimatedControlRenderer animationRenderer, + int animationDuration, + AnimationCycle animationCycle) + { + AnimationManager manager = Instance; + + // If the renderer is already registered, update the animation parameters. + if (manager._renderer.TryGetValue(animationRenderer, out AnimationRendererItem? renderItem)) + { + manager.UpdateAnimationParameters(renderItem, animationDuration, animationCycle); + + return; + } + + renderItem = new AnimationRendererItem(animationRenderer, animationDuration, animationCycle) + { + TargetTimestamp = manager.GetTargetTimestamp(animationDuration) + }; + + _ = manager._renderer.TryAdd(animationRenderer, renderItem); + } + + /// + /// Unregisters an animation renderer. + /// + /// The animation renderer to unregister. + internal static void UnregisterAnimationRenderer(AnimatedControlRenderer animationRenderer) + { + if (s_instance is AnimationManager manager) + { + _ = manager._renderer.TryRemove(animationRenderer, out _); + } + } + + internal static void Suspend(AnimatedControlRenderer animatedControlRenderer) + { + if (s_instance is AnimationManager manager + && manager._renderer.TryGetValue(animatedControlRenderer, out AnimationRendererItem? renderItem)) + { + renderItem.Renderer.StopAnimationInternal(); + } + } + + /// + /// Handles a single frame tick delivered by HighPrecisionTimer (on the UI thread). + /// + private ValueTask OnFrameTickAsync(HighPrecisionTimerTick tick, CancellationToken cancellationToken) + { + if (Volatile.Read(ref _disposed) != 0) + { + return ValueTask.CompletedTask; + } + + _lastTickTimestamp = tick.Timestamp; + _hasTickTimestamp = true; + + foreach (AnimationRendererItem item in _renderer.Values) + { + try + { + ProcessRenderer(item, tick.Timestamp); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + if (AnimationManagerEventSource.s_log.IsEnabled()) + { + AnimationManagerEventSource.s_log.RendererFault( + item.Renderer.GetType().FullName ?? item.Renderer.GetType().Name, + ex.GetType().FullName ?? ex.GetType().Name); + } + + QuarantineRenderer(item); + } + } + + return ValueTask.CompletedTask; + } + + private static void ProcessRenderer(AnimationRendererItem item, TimeSpan timestamp) + { + if (!item.Renderer.IsRunning) + { + return; + } + + item.TargetTimestamp ??= timestamp + TimeSpan.FromMilliseconds(item.AnimationDuration); + TimeSpan targetTimestamp = item.TargetTimestamp.Value; + TimeSpan remaining = targetTimestamp - timestamp; + + item.FrameCount += item.FrameOffset; + + if (timestamp >= targetTimestamp) + { + switch (item.AnimationCycle) + { + case AnimationCycle.Once: + item.Renderer.EndAnimation(); + break; + + case AnimationCycle.Loop: + item.FrameCount = 0; + item.TargetTimestamp = timestamp + TimeSpan.FromMilliseconds(item.AnimationDuration); + item.Renderer.RestartAnimation(); + break; + + case AnimationCycle.Bounce: + item.FrameOffset = -item.FrameOffset; + item.TargetTimestamp = timestamp + TimeSpan.FromMilliseconds(item.AnimationDuration); + item.Renderer.RestartAnimation(); + break; + } + + return; + } + + float progress = 1 - (float)(remaining.TotalMilliseconds / item.AnimationDuration); + + // We are already on the UI thread (HighPrecisionTimer marshalled us here), so invoke directly. + item.Renderer.AnimationProc(progress); + } + + private void QuarantineRenderer(AnimationRendererItem item) + { + _ = _renderer.TryRemove(item.Renderer, out _); + item.Renderer.StopAnimationInternal(); + } + + private TimeSpan? GetTargetTimestamp(int animationDuration) + => _hasTickTimestamp + ? _lastTickTimestamp + TimeSpan.FromMilliseconds(animationDuration) + : null; + + private void UpdateAnimationParameters( + AnimationRendererItem item, + int animationDuration, + AnimationCycle animationCycle) + { + item.AnimationDuration = animationDuration; + item.AnimationCycle = animationCycle; + item.TargetTimestamp = GetTargetTimestamp(animationDuration); + } + + /// + /// Gets the manager associated with the calling UI thread for focused tests. + /// + internal static AnimationManager GetCurrentForTesting() => Instance; + + /// + /// Disposes the manager associated with the calling UI thread for focused tests. + /// + internal static void DisposeCurrentForTesting() => s_instance?.DisposeRenderer(); + + /// + /// Processes a timer tick synchronously for focused tests. + /// + internal ValueTask ProcessTickForTesting(HighPrecisionTimerTick tick) + => OnFrameTickAsync(tick, CancellationToken.None); + + /// + /// Gets the number of renderers currently managed by this UI-thread instance for focused tests. + /// + internal int RendererCountForTesting => _renderer.Count; + + /// + /// Emits diagnostics when an individual renderer is quarantined. + /// + [EventSource(Name = "System.Windows.Forms.AnimationManager")] + private sealed class AnimationManagerEventSource : EventSource + { + public static readonly AnimationManagerEventSource s_log = new(); + + [Event(1, Level = EventLevel.Error)] + public void RendererFault(string rendererType, string exceptionType) + => WriteEvent(1, rendererType, exceptionType); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs new file mode 100644 index 00000000000..5a3967073c4 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/AnimatedPopupButtonRenderer.cs @@ -0,0 +1,260 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Windows.Forms.ButtonInternal; +using System.Windows.Forms.Rendering.Animation; +using PushButtonState = System.Windows.Forms.VisualStyles.PushButtonState; + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Drives and renders a whose is +/// when modern visual styles or dark mode are active, using the concave key-cap +/// look of . +/// +/// +/// +/// The renderer owns two interpolated animation channels (hover and press) that are advanced by the shared +/// on each high-precision timer tick. When settled it simply renders the +/// current channel values, so the same path serves both the animating and the resting button. +/// +/// +internal sealed class AnimatedPopupButtonRenderer : AnimatedControlRenderer +{ + private const int AnimationDurationMilliseconds = 160; + private const float CustomHoverShadeAmount = 0.05f; + private const float CustomPressedShadeAmount = 0.12f; + + private float _hoverCurrent; + private float _hoverStart; + private float _hoverTarget; + + private float _pressCurrent; + private float _pressStart; + private float _pressTarget; + private readonly ModernButtonDarkModeRenderer _baseColorRenderer = new(); + private readonly ButtonDarkModeAdapter _layoutAdapter; + + public AnimatedPopupButtonRenderer(Forms.ButtonBase button) + : base(button) + { + _layoutAdapter = new ButtonDarkModeAdapter(button); + } + + private Forms.ButtonBase Button => (Forms.ButtonBase)Control; + + /// + /// Updates the hover and press targets from the current interaction state and, if anything changed, + /// (re)starts the interpolation from the current channel values towards the new targets. + /// + public void SetInteractionState(bool hovered, bool pressed, bool selected) + { + float hoverTarget = hovered ? 1f : 0f; + float pressTarget = pressed || selected ? 1f : 0f; + + if (hoverTarget == _hoverTarget && pressTarget == _pressTarget) + { + return; + } + + _hoverStart = _hoverCurrent; + _pressStart = _pressCurrent; + _hoverTarget = hoverTarget; + _pressTarget = pressTarget; + + RestartAnimation(); + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + => (AnimationDurationMilliseconds, AnimationCycle.Once); + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + + _hoverCurrent = Lerp(_hoverStart, _hoverTarget, PopupButtonEasing.EaseOutCubic(animationProgress)); + _pressCurrent = Lerp(_pressStart, _pressTarget, PopupButtonEasing.EaseInOutQuad(animationProgress)); + + Invalidate(); + } + + protected override void OnAnimationEnded() + { + _hoverCurrent = _hoverTarget; + _pressCurrent = _pressTarget; + _hoverStart = _hoverCurrent; + _pressStart = _pressCurrent; + + // The animation has reached its target; stop ticking so the settled button is not repainted every frame. + // A later interaction change restarts a fresh interpolation via SetInteractionState. + StopAnimation(); + + Invalidate(); + } + + protected override void OnAnimationStopped() + { + // Keep the current channel values so a restart interpolates smoothly from where we are. + } + + /// + /// Called from the button's OnPaint. Works both while the animation is running (driven by + /// ) and when it is settled. + /// + public override void RenderControl(Graphics graphics) + { + Forms.ButtonBase button = Button; + + FlatButtonAppearance flatAppearance = button.FlatAppearance; + _baseColorRenderer.DeviceDpi = button.DeviceDpi; + _baseColorRenderer.FlatAppearance = flatAppearance; + + PushButtonState state = _pressTarget > 0f + ? PushButtonState.Pressed + : _hoverTarget > 0f + ? PushButtonState.Hot + : PushButtonState.Normal; + bool highContrast = SystemInformation.HighContrast; + Color faceColor; + Color foreColor; + Color borderColor; + + if (highContrast) + { + bool highlighted = button.Enabled + && (button.Focused || button.MouseIsOver || button.IsDefault); + faceColor = highlighted ? SystemColors.Highlight : SystemColors.Control; + foreColor = !button.Enabled + ? SystemColors.GrayText + : highlighted + ? SystemColors.HighlightText + : SystemColors.ControlText; + borderColor = button.Enabled ? SystemColors.ControlText : SystemColors.GrayText; + } + else + { + (Color baseColor, Color hoverColor, Color pressedColor) = GetStateColors(); + + faceColor = PopupButtonColorMath.Blend(baseColor, hoverColor, _hoverCurrent); + faceColor = PopupButtonColorMath.Blend(faceColor, pressedColor, _pressCurrent); + bool useAutomaticForeColor = button.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + ? !button.ShouldSerializeForeColor() + : button.ForeColor == Forms.Control.DefaultForeColor; + foreColor = !useAutomaticForeColor + ? button.ForeColor + : _baseColorRenderer.GetTextColor(state, button.IsDefault, faceColor); + borderColor = flatAppearance.BorderColor.IsEmpty + ? PopupButtonColorMath.TowardsContrast(faceColor, 0.35f) + : flatAppearance.BorderColor; + } + + PopupButtonRenderContext context = new() + { + Bounds = button.ClientRectangle, + Text = button.Text, + Font = button.Font, + BackColor = faceColor, + ForeColor = foreColor, + SurfaceColor = button.Parent?.BackColor ?? button.BackColor, + UseAutomaticForeColor = button.EffectiveVisualStylesModeInternal >= VisualStylesMode.Net11 + ? !button.ShouldSerializeForeColor() + : button.ForeColor == Forms.Control.DefaultForeColor, + BorderColor = borderColor, + BorderWidth = flatAppearance.BorderSize, + Enabled = button.Enabled, + Focused = button.Focused && button.ShowFocusCues, + Pressed = button.MouseIsDown || _pressTarget > 0f, + IsDefault = button.IsDefault, + IsDarkMode = Application.IsDarkModeEnabled, + AnimationState = new PopupButtonAnimationState(_hoverCurrent, _pressCurrent), + TextAlign = button.TextAlign, + ImageSize = button.Image?.Size ?? Size.Empty, + ImageAlign = button.ImageAlign, + TextImageRelation = button.TextImageRelation, + RightToLeft = button.RightToLeft, + Padding = button.Padding, + DeviceDpi = button.DeviceDpi, + ShowKeyboardCues = button.ShowKeyboardCues, + HighContrast = highContrast + }; + + if (context.HighContrast || context.Bounds.Width < 8 || context.Bounds.Height < 8) + { + using PaintEventArgs paintEventArgs = new(graphics, button.ClientRectangle); + button.PaintBackground(paintEventArgs, button.ClientRectangle); + } + else + { + ParentBackgroundRenderer.Paint( + button, + graphics, + button.ClientRectangle, + button.Parent?.BackColor ?? button.BackColor); + } + + if (context.Bounds.Width < 8 || context.Bounds.Height < 8) + { + PopupButtonKeyCapRenderer.Render(graphics, context); + return; + } + + Image? image = button.Image; + Rectangle contentBounds = PopupButtonKeyCapRenderer.GetContentBounds(context); + ButtonBaseAdapter.LayoutData layout = _layoutAdapter.GetLayoutData(contentBounds); + Action? paintBackgroundImage = null; + Action? paintImage = null; + + if (button.BackgroundImage is not null) + { + paintBackgroundImage = bounds => + { + using PaintEventArgs paintEventArgs = new(graphics, button.ClientRectangle); + _layoutAdapter.PaintBackgroundImage(paintEventArgs, bounds); + }; + } + + if (image is not null) + { + paintImage = _ => + { + using PaintEventArgs paintEventArgs = new(graphics, button.ClientRectangle); + _layoutAdapter.PaintImage(paintEventArgs, layout); + }; + } + + PopupButtonKeyCapRenderer.Render( + graphics, + context, + paintImage, + (layout.TextBounds, layout.ImageBounds), + paintBackgroundImage); + } + + internal (Color BaseColor, Color HoverColor, Color PressedColor) GetStateColors() + { + Forms.ButtonBase button = Button; + FlatButtonAppearance flatAppearance = button.FlatAppearance; + _baseColorRenderer.DeviceDpi = button.DeviceDpi; + _baseColorRenderer.FlatAppearance = flatAppearance; + + bool hasCustomBackColor = button.BackColor != Forms.Control.DefaultBackColor; + Color baseColor = hasCustomBackColor + ? button.BackColor + : _baseColorRenderer.GetBackgroundColor(PushButtonState.Normal, isDefault: false); + Color hoverColor = !flatAppearance.MouseOverBackColorCore.IsEmpty + ? flatAppearance.MouseOverBackColorCore + : hasCustomBackColor + ? PopupButtonColorMath.TowardsContrast(baseColor, CustomHoverShadeAmount) + : _baseColorRenderer.GetBackgroundColor(PushButtonState.Hot, isDefault: false); + Color pressedColor = !flatAppearance.MouseDownBackColorCore.IsEmpty + ? flatAppearance.MouseDownBackColorCore + : hasCustomBackColor + ? PopupButtonColorMath.TowardsContrast(baseColor, CustomPressedShadeAmount) + : _baseColorRenderer.GetBackgroundColor(PushButtonState.Pressed, isDefault: false); + + return (baseColor, hoverColor, pressedColor); + } + + private static float Lerp(float start, float end, float amount) => start + ((end - start) * amount); +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonAnimationState.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonAnimationState.cs new file mode 100644 index 00000000000..f59ab86609a --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonAnimationState.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Immutable snapshot of the animation progress of a key-cap button. +/// +/// +/// +/// The control owns the animation timing (driven by the shared animation manager and its +/// high-precision timer); the renderer only consumes the resulting progress values. This keeps the +/// renderer usable outside a live control (designer surfaces, preview bitmaps) where no timer exists — +/// simply pass fixed progress values. +/// +/// +/// Hover progress, 0 (not hovered) to 1 (fully hovered). +/// Press progress, 0 (released) to 1 (fully pressed). +internal readonly struct PopupButtonAnimationState(float hoverProgress, float pressProgress) +{ + /// + /// Gets a state with no hover and no press applied. + /// + public static PopupButtonAnimationState None => default; + + /// + /// Gets the hover progress in the range 0-1. + /// + public float HoverProgress { get; } = Math.Clamp(hoverProgress, 0f, 1f); + + /// + /// Gets the press progress in the range 0-1. + /// + public float PressProgress { get; } = Math.Clamp(pressProgress, 0f, 1f); +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonColorMath.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonColorMath.cs new file mode 100644 index 00000000000..88ce9322ed3 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonColorMath.cs @@ -0,0 +1,202 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Color utilities used by the key-cap renderer to derive material shading +/// colors from arbitrary effective colors. +/// +/// +/// +/// Nothing in here assumes a light or dark base color; every derivation is relative to the input, which is +/// what makes the material effect work with saturated, neutral, light, dark and (dark-mode remapped) system +/// colors alike. +/// +/// +internal static class PopupButtonColorMath +{ + public const float MinimumReadableContrastRatio = 4.5f; + + /// + /// Gets the relative luminance of a color in the range 0-1. + /// + public static float GetLuminance(Color color) + => ((0.299f * color.R) + (0.587f * color.G) + (0.114f * color.B)) / 255f; + + /// + /// Gets the WCAG relative luminance of a color, including the required sRGB linearization. + /// + public static float GetRelativeLuminance(Color color) + => (0.2126f * Linearize(color.R)) + + (0.7152f * Linearize(color.G)) + + (0.0722f * Linearize(color.B)); + + /// + /// Gets the WCAG contrast ratio between two colors. + /// + public static float GetContrastRatio(Color first, Color second) + { + float firstLuminance = GetRelativeLuminance(first); + float secondLuminance = GetRelativeLuminance(second); + float lighter = Math.Max(firstLuminance, secondLuminance); + float darker = Math.Min(firstLuminance, secondLuminance); + + return (lighter + 0.05f) / (darker + 0.05f); + } + + /// + /// Chooses black or white, whichever has the greater WCAG contrast ratio against the background. + /// + public static Color GetReadableForeColor(Color backColor) + => GetContrastRatio(Color.Black, backColor) >= GetContrastRatio(Color.White, backColor) + ? Color.Black + : Color.White; + + /// + /// Chooses black or white by its worst contrast ratio across the darkest and lightest rendered surfaces. + /// + public static Color GetReadableForeColor(Color darkestBackColor, Color lightestBackColor) + { + float blackContrast = Math.Min( + GetContrastRatio(Color.Black, darkestBackColor), + GetContrastRatio(Color.Black, lightestBackColor)); + float whiteContrast = Math.Min( + GetContrastRatio(Color.White, darkestBackColor), + GetContrastRatio(Color.White, lightestBackColor)); + + return blackContrast >= whiteContrast ? Color.Black : Color.White; + } + + /// + /// Composites a foreground color over a background color. + /// + public static Color Composite(Color foreground, Color background) + { + float foregroundAlpha = foreground.A / 255f; + float backgroundAlpha = background.A / 255f; + float alpha = foregroundAlpha + (backgroundAlpha * (1f - foregroundAlpha)); + + if (alpha <= 0f) + { + return Color.Transparent; + } + + return Color.FromArgb( + (int)MathF.Round(alpha * 255f), + CompositeChannel(foreground.R, background.R), + CompositeChannel(foreground.G, background.G), + CompositeChannel(foreground.B, background.B)); + + int CompositeChannel(byte foregroundChannel, byte backgroundChannel) + => (int)MathF.Round( + ((foregroundChannel * foregroundAlpha) + + (backgroundChannel * backgroundAlpha * (1f - foregroundAlpha))) + / alpha); + } + + /// + /// Linearly blends towards . + /// + /// The starting color. + /// The color to blend towards. + /// Blend amount, 0 (base) to 1 (target). + public static Color Blend(Color baseColor, Color target, float amount) + { + amount = Math.Clamp(amount, 0f, 1f); + + int r = (int)MathF.Round(baseColor.R + ((target.R - baseColor.R) * amount)); + int g = (int)MathF.Round(baseColor.G + ((target.G - baseColor.G) * amount)); + int b = (int)MathF.Round(baseColor.B + ((target.B - baseColor.B) * amount)); + + return Color.FromArgb(baseColor.A, r, g, b); + } + + /// + /// Blends the color towards white by the given amount. + /// + public static Color Lighten(Color color, float amount) + => Blend(color, Color.White, amount); + + /// + /// Blends the color towards black by the given amount. + /// + public static Color Darken(Color color, float amount) + => Blend(color, Color.Black, amount); + + /// + /// Blends the color towards the pole (black or white) that increases contrast against itself - lightens + /// dark colors, darkens light colors. + /// + /// + /// + /// Used for borders and cues that must remain visible on both light and dark surfaces. + /// + /// + public static Color TowardsContrast(Color color, float amount) + => GetLuminance(color) > 0.5f + ? Darken(color, amount) + : Lighten(color, amount); + + /// + /// Desaturates the color towards a gray of the same luminance and slightly pulls it towards a mid tone. + /// Used for disabled surfaces. + /// + public static Color Mute(Color color, float amount) + { + int gray = (int)MathF.Round(GetLuminance(color) * 255f); + Color desaturated = Blend(color, Color.FromArgb(color.A, gray, gray, gray), amount); + + return Blend(desaturated, Color.FromArgb(color.A, 128, 128, 128), amount * 0.35f); + } + + /// + /// Ensures a minimum luminance difference between and + /// by pushing away if needed. + /// + /// The color to adjust. + /// The surface it must stay readable on. + /// Minimum luminance delta, 0-1. + public static Color EnsureContrast(Color color, Color against, float minDelta) + { + float delta = MathF.Abs(GetLuminance(color) - GetLuminance(against)); + + if (delta >= minDelta) + { + return color; + } + + float push = minDelta - delta; + + return GetLuminance(against) > 0.5f + ? Darken(color, push) + : Lighten(color, push); + } + + /// + /// Derives an opaque highlight color for text relief on the given surface. + /// + /// + /// + /// Opaque blended colors are used instead of alpha because GDI text rendering via + /// ignores the alpha channel. + /// + /// + public static Color GetTextHighlight(Color surface, float strength) + => Lighten(surface, 0.28f * strength); + + /// + /// Derives an opaque shadow color for text relief on the given surface. + /// + public static Color GetTextShadow(Color surface, float strength) + => Darken(surface, 0.34f * strength); + + private static float Linearize(byte channel) + { + float value = channel / 255f; + + return value <= 0.04045f + ? value / 12.92f + : MathF.Pow((value + 0.055f) / 1.055f, 2.4f); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonEasing.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonEasing.cs new file mode 100644 index 00000000000..d574992ace9 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonEasing.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Easing functions for the key-cap state-change animations. +/// +internal static class PopupButtonEasing +{ + /// + /// Cubic ease-out. Natural for hover enter/leave - fast start, gentle settle. + /// + public static float EaseOutCubic(float t) + { + t = Math.Clamp(t, 0f, 1f); + float inv = 1f - t; + + return 1f - (inv * inv * inv); + } + + /// + /// Quadratic ease-in/ease-out. Snappy for press and release. + /// + public static float EaseInOutQuad(float t) + { + t = Math.Clamp(t, 0f, 1f); + + return t < 0.5f + ? 2f * t * t + : 1f - (MathF.Pow((-2f * t) + 2f, 2f) / 2f); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs new file mode 100644 index 00000000000..6657f0bb82e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonKeyCapRenderer.cs @@ -0,0 +1,957 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Layout; + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Renders a concave mechanical key top - the classic table-calculator/cash-register key with a raised rim +/// and a shallow bowl - used for the button visual style for arbitrary colors, +/// DPI values and interaction states. +/// +/// +/// +/// The renderer is completely stateless with respect to any control: every input arrives via +/// . It can therefore render previews, designer adornments or key +/// visuals inside other controls without a live control instance. +/// +/// +/// Visual layers, back to front: ambient drop shadow, key body (rim surface), concave bowl (path gradient +/// plus inner top-shadow and bottom-light overlays), bowl lip stroke, border, default/focus cues, the +/// optional image, and finally the caption with a raised or engraved relief. +/// +/// +/// In high-contrast accessibility modes the renderer falls back to a flat, high-contrast style without any +/// material emulation. +/// +/// +internal static class PopupButtonKeyCapRenderer +{ + /// + /// Renders the key into the given . + /// + /// The target graphics. + /// The complete render context. + /// + /// Optional callback used to paint an image onto the key surface. It is invoked after the key chrome and + /// before the caption, and receives the bowl (content) rectangle. + /// + public static void Render( + Graphics graphics, + PopupButtonRenderContext context, + Action? paintImage = null, + (Rectangle TextBounds, Rectangle ImageBounds)? contentLayout = null, + Action? paintBackgroundImage = null) + { + ArgumentNullException.ThrowIfNull(graphics); + ArgumentNullException.ThrowIfNull(context); + + Rectangle bounds = context.Bounds; + Color surfaceBackColor = GetSurfaceBackColor(context); + + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + // Degenerate bounds: just fill, never throw. + if (bounds.Width < 8 || bounds.Height < 8) + { + using SolidBrush tinyBrush = new(surfaceBackColor); + graphics.FillRectangle(tinyBrush, bounds); + + return; + } + + if (context.HighContrast) + { + RenderHighContrast( + graphics, + context, + surfaceBackColor, + paintImage, + contentLayout, + paintBackgroundImage); + + return; + } + + Metrics metrics = Metrics.Create(context); + Palette palette = Palette.Create(context, metrics, surfaceBackColor); + (Rectangle textBounds, Rectangle imageBounds) = contentLayout + ?? CreateContentLayout( + context, + metrics.BowlRect, + applySurfaceInset: false); + + GraphicsState state = graphics.Save(); + + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + DrawAmbientShadow(graphics, metrics, palette); + DrawKeyBody(graphics, metrics, palette); + DrawBowl(graphics, metrics, palette); + PaintBackgroundImage( + graphics, + metrics, + paintBackgroundImage); + DrawBorder(graphics, metrics, palette); + DrawStateCues(graphics, context, metrics, palette); + + if (imageBounds.Width > 0 && imageBounds.Height > 0) + { + paintImage?.Invoke(imageBounds); + } + } + finally + { + graphics.Restore(state); + } + + DrawText(graphics, context, metrics, palette, textBounds); + } + + internal static Rectangle GetContentBounds(PopupButtonRenderContext context) + { + if (!context.HighContrast) + { + return Metrics.Create(context).BowlRect; + } + + float scale = context.DeviceDpi / 96f; + Rectangle contentBounds = Rectangle.Inflate( + context.Bounds, + -(int)(4 * scale), + -(int)(4 * scale)); + + if (context.Pressed) + { + contentBounds.Offset((int)scale, (int)scale); + } + + return contentBounds; + } + + internal static Size GetPreferredSizeChrome(int deviceDpi, int borderWidth) + { + float scale = Math.Max(0.5f, deviceDpi / 96f); + int sideClearance = Math.Max(1, (int)MathF.Round(Metrics.SideClearanceDip * scale)); + int pressTravel = Math.Max(1, (int)MathF.Round(Metrics.PressTravelDip * scale)); + int defaultBorderIncrease = Math.Max(1, (int)MathF.Round(scale)); + int stableBorderWidth = Math.Max(0, borderWidth + defaultBorderIncrease); + int rim = Math.Max(2, (int)MathF.Round(3f * scale)); + int bowlInset = stableBorderWidth + rim; + + return new Size( + 2 * (sideClearance + bowlInset), + (2 * sideClearance) + pressTravel + (2 * bowlInset)); + } + + private static void DrawAmbientShadow(Graphics graphics, Metrics metrics, Palette palette) + { + // The key visually lifts off the surface; pressing it reduces the drop shadow. + int drop = Math.Max(1, metrics.Ambient - metrics.PressOffset); + + Rectangle outer = metrics.KeyRect; + outer.Offset(drop / 2, drop); + outer.Inflate(1, 1); + + Rectangle inner = metrics.KeyRect; + inner.Offset(drop / 2, drop); + + using (GraphicsPath outerPath = CreateRoundedPath(outer, metrics.CornerRadius + 1f)) + using (SolidBrush softBrush = new(Color.FromArgb(palette.AmbientAlpha / 3, Color.Black))) + { + graphics.FillPath(softBrush, outerPath); + } + + using GraphicsPath innerPath = CreateRoundedPath(inner, metrics.CornerRadius); + using SolidBrush coreBrush = new(Color.FromArgb(palette.AmbientAlpha, Color.Black)); + graphics.FillPath(coreBrush, innerPath); + } + + private static void DrawKeyBody(Graphics graphics, Metrics metrics, Palette palette) + { + // The rim surface: lit from the upper left, falling into shadow at the lower right. + using GraphicsPath bodyPath = CreateRoundedPath(metrics.KeyRect, metrics.CornerRadius); + using LinearGradientBrush bodyBrush = new( + InflateForGradient(metrics.KeyRect), + palette.BodyLight, + palette.BodyDark, + LinearGradientMode.ForwardDiagonal); + + graphics.FillPath(bodyBrush, bodyPath); + } + + private static void DrawBowl(Graphics graphics, Metrics metrics, Palette palette) + { + Rectangle bowl = metrics.BowlRect; + + if (bowl.Width < 2 || bowl.Height < 2) + { + return; + } + + using GraphicsPath bowlPath = CreateRoundedPath(bowl, metrics.BowlRadius); + + // 1. Radial shading: edges catch light, the center sits lower and darker - the signature concave read. + // The dark center is biased towards the upper left, where a top-left light source cannot reach into + // a bowl. + using (PathGradientBrush bowlBrush = new(bowlPath)) + { + bowlBrush.CenterColor = palette.BowlCenter; + bowlBrush.SurroundColors = [palette.BowlEdge]; + bowlBrush.CenterPoint = new PointF( + bowl.Left + (bowl.Width * 0.40f), + bowl.Top + (bowl.Height * 0.36f)); + bowlBrush.FocusScales = new PointF(0.28f, 0.22f); + + graphics.FillPath(bowlBrush, bowlPath); + } + + // 2. Inner top shadow and inner bottom light, clipped to the bowl. + GraphicsState clipState = graphics.Save(); + + try + { + graphics.SetClip(bowlPath, CombineMode.Intersect); + + int topHeight = Math.Max(2, (int)(bowl.Height * 0.42f)); + Rectangle topRect = bowl with { Height = topHeight }; + + using (LinearGradientBrush topShadow = new( + InflateForGradient(topRect), + Color.FromArgb(palette.InnerShadowAlpha, Color.Black), + Color.FromArgb(0, Color.Black), + LinearGradientMode.Vertical)) + { + graphics.FillRectangle(topShadow, topRect); + } + + int bottomHeight = Math.Max(2, (int)(bowl.Height * 0.30f)); + Rectangle bottomRect = new(bowl.Left, bowl.Bottom - bottomHeight, bowl.Width, bottomHeight); + + using LinearGradientBrush bottomLight = new( + InflateForGradient(bottomRect), + Color.FromArgb(0, Color.White), + Color.FromArgb(palette.InnerLightAlpha, Color.White), + LinearGradientMode.Vertical); + + graphics.FillRectangle(bottomLight, bottomRect); + } + finally + { + graphics.Restore(clipState); + } + + // 3. The lip where rim and bowl meet: light on the upper left, shadow lower right - the raised edge of + // the surrounding rim. + using LinearGradientBrush lipBrush = new( + InflateForGradient(Rectangle.Inflate(bowl, 1, 1)), + palette.LipLight, + palette.LipDark, + LinearGradientMode.ForwardDiagonal); + using Pen lipPen = new(lipBrush, Math.Max(1f, metrics.Scale)); + + graphics.DrawPath(lipPen, bowlPath); + } + + private static void DrawBorder(Graphics graphics, Metrics metrics, Palette palette) + { + if (metrics.BorderWidth <= 0) + { + return; + } + + float half = metrics.BorderWidth / 2f; + RectangleF borderRect = metrics.KeyRect; + borderRect.Inflate(-half, -half); + + if (borderRect.Width < 1f || borderRect.Height < 1f) + { + return; + } + + using GraphicsPath borderPath = CreateRoundedPath( + Rectangle.Round(borderRect), + Math.Max(1f, metrics.CornerRadius - half)); + using Pen borderPen = new(palette.Border, metrics.BorderWidth); + + graphics.DrawPath(borderPen, borderPath); + } + + private static void DrawStateCues( + Graphics graphics, + PopupButtonRenderContext context, + Metrics metrics, + Palette palette) + { + if (context.Focused) + { + int inset = metrics.BorderWidth + Math.Max(2, (int)MathF.Round(2f * metrics.Scale)); + Rectangle focusRect = Rectangle.Inflate(metrics.KeyRect, -inset, -inset); + + if (focusRect.Width > 4 && focusRect.Height > 4) + { + using GraphicsPath focusPath = CreateRoundedPath( + focusRect, + Math.Max(1f, metrics.CornerRadius - inset)); + using Pen focusPen = new(palette.Focus, Math.Max(1f, metrics.Scale * 0.75f)) + { + DashStyle = DashStyle.Dot + }; + + graphics.DrawPath(focusPen, focusPath); + } + } + } + + private static void DrawText( + Graphics graphics, + PopupButtonRenderContext context, + Metrics metrics, + Palette palette, + Rectangle textRect) + { + string? text = context.Text; + + if (string.IsNullOrEmpty(text)) + { + return; + } + + if (textRect.Width <= 0 || textRect.Height <= 0) + { + return; + } + + TextFormatFlags flags = GetTextFormatFlags(context); + int reliefOffset = metrics.TextReliefOffset; + + PopupButtonTextEffect effect = GetTextEffect(context); + + if (!palette.TextOutline.IsEmpty) + { + for (int y = -reliefOffset; y <= reliefOffset; y += reliefOffset) + { + for (int x = -reliefOffset; x <= reliefOffset; x += reliefOffset) + { + if (x == 0 && y == 0) + { + continue; + } + + Rectangle outlineRect = textRect; + outlineRect.Offset(x, y); + TextRenderer.DrawText( + graphics, + text, + context.Font, + outlineRect, + palette.TextOutline, + flags); + } + } + } + + if (effect is not PopupButtonTextEffect.Flat) + { + // GDI text ignores alpha, so the relief colors are opaque blends against the bowl center - see + // PopupButtonColorMath.GetTextHighlight/GetTextShadow. + (Color reliefColor, Point reliefShift) = effect switch + { + // Raised: light from the upper left casts the glyph's shadow downwards. + PopupButtonTextEffect.Raised => (palette.TextShadow, new Point(0, reliefOffset)), + + // Engraved (letterpress): the recess's lower edge catches the light. + _ => (palette.TextHighlight, new Point(0, reliefOffset)) + }; + + Rectangle reliefRect = textRect; + reliefRect.Offset(reliefShift); + TextRenderer.DrawText(graphics, text, context.Font, reliefRect, reliefColor, flags); + + if (effect is PopupButtonTextEffect.Engraved) + { + // A faint dark edge above completes the engraving. + Rectangle upperRect = textRect; + upperRect.Offset(0, -reliefOffset); + Color upperShadow = PopupButtonColorMath.Blend(palette.TextShadow, palette.BowlCenter, 0.55f); + TextRenderer.DrawText(graphics, text, context.Font, upperRect, upperShadow, flags); + } + } + + TextRenderer.DrawText(graphics, text, context.Font, textRect, palette.Text, flags); + } + + internal static PopupButtonTextEffect GetTextEffect( + PopupButtonRenderContext context) + => context.Enabled + ? context.TextEffect + : PopupButtonTextEffect.Flat; + + private static void PaintBackgroundImage( + Graphics graphics, + Metrics metrics, + Action? paintBackgroundImage) + { + if (paintBackgroundImage is null) + { + return; + } + + int inset = Math.Max( + 1, + (int)MathF.Ceiling(metrics.Scale)); + Rectangle backgroundBounds = Rectangle.Inflate( + metrics.BowlRect, + -inset, + -inset); + if (backgroundBounds.Width <= 0 + || backgroundBounds.Height <= 0) + { + return; + } + + using GraphicsStateScope state = new(graphics); + using GraphicsPath clipPath = CreateRoundedPath( + backgroundBounds, + Math.Max(1f, metrics.BowlRadius - inset)); + graphics.SetClip(clipPath, CombineMode.Intersect); + paintBackgroundImage(backgroundBounds); + } + + private static void RenderHighContrast( + Graphics graphics, + PopupButtonRenderContext context, + Color surfaceBackColor, + Action? paintImage, + (Rectangle TextBounds, Rectangle ImageBounds)? contentLayout, + Action? paintBackgroundImage) + { + Rectangle bounds = context.Bounds; + bool pressed = context.Pressed; + float scale = context.DeviceDpi / 96f; + + Color back = surfaceBackColor; + Color fore = context.Enabled ? context.ForeColor : SystemColors.GrayText; + Color border = context.Enabled ? context.ForeColor : SystemColors.GrayText; + Rectangle contentRect = GetContentBounds(context); + + using (SolidBrush backBrush = new(back)) + { + graphics.FillRectangle(backBrush, bounds); + } + + paintBackgroundImage?.Invoke(contentRect); + + int defaultBorderIncrease = context.IsDefault && context.Enabled + ? Math.Max(1, (int)MathF.Round(scale)) + : 0; + int borderWidth = Math.Max( + Math.Max(1, context.BorderWidth + defaultBorderIncrease), + pressed ? (int)(2 * scale) : 1); + Rectangle borderRect = bounds; + borderRect.Width -= 1; + borderRect.Height -= 1; + + using (Pen borderPen = new(border, borderWidth) { Alignment = PenAlignment.Inset }) + { + graphics.DrawRectangle(borderPen, borderRect); + } + + (Rectangle textRect, Rectangle imageRect) = contentLayout + ?? CreateContentLayout( + context, + contentRect, + applySurfaceInset: false); + if (imageRect.Width > 0 && imageRect.Height > 0) + { + paintImage?.Invoke(imageRect); + } + + if (textRect.Width > 0 && textRect.Height > 0) + { + TextRenderer.DrawText(graphics, context.Text, context.Font, textRect, fore, GetTextFormatFlags(context)); + } + + if (context.Focused) + { + Rectangle focusRect = Rectangle.Inflate(bounds, -(int)(3 * scale), -(int)(3 * scale)); + ControlPaint.DrawFocusRectangle(graphics, focusRect, fore, back); + } + } + + private static Color GetSurfaceBackColor(PopupButtonRenderContext context) + { + if (context.HighContrast || !context.IsDefault || !context.Enabled) + { + return context.BackColor; + } + + return context.IsDarkMode + ? PopupButtonColorMath.Lighten(context.BackColor, 0.1f) + : PopupButtonColorMath.Darken(context.BackColor, 0.1f); + } + + private static (Rectangle TextBounds, Rectangle ImageBounds) CreateContentLayout( + PopupButtonRenderContext context, + Rectangle contentBounds, + bool applySurfaceInset) + { + if (applySurfaceInset) + { + int inset = Math.Max(1, (int)MathF.Round(1.5f * context.DeviceDpi / 96f)); + contentBounds = Rectangle.Inflate(contentBounds, -inset, -inset); + } + + contentBounds = ApplyPadding(contentBounds, context.Padding); + bool hasText = !string.IsNullOrEmpty(context.Text); + bool hasImage = !context.ImageSize.IsEmpty; + ContentAlignment imageAlign = context.RightToLeft == RightToLeft.Yes + ? MirrorAlignment(context.ImageAlign) + : context.ImageAlign; + + if (!hasImage) + { + return (hasText ? contentBounds : Rectangle.Empty, Rectangle.Empty); + } + + Size imageSize = new( + Math.Min(contentBounds.Width, context.ImageSize.Width), + Math.Min(contentBounds.Height, context.ImageSize.Height)); + + if (!hasText || context.TextImageRelation == TextImageRelation.Overlay) + { + return ( + hasText ? contentBounds : Rectangle.Empty, + AlignInRectangle(contentBounds, imageSize, imageAlign)); + } + + TextImageRelation relation = context.RightToLeft == RightToLeft.Yes + ? LayoutUtils.GetOppositeTextImageRelation(context.TextImageRelation) + : context.TextImageRelation; + int gap = Math.Max(2, (int)MathF.Round(4f * context.DeviceDpi / 96f)); + + return relation switch + { + TextImageRelation.ImageBeforeText => CreateHorizontalLayout(imageFirst: true), + TextImageRelation.TextBeforeImage => CreateHorizontalLayout(imageFirst: false), + TextImageRelation.ImageAboveText => CreateVerticalLayout(imageFirst: true), + TextImageRelation.TextAboveImage => CreateVerticalLayout(imageFirst: false), + _ => (contentBounds, AlignInRectangle(contentBounds, imageSize, imageAlign)) + }; + + (Rectangle TextBounds, Rectangle ImageBounds) CreateHorizontalLayout(bool imageFirst) + { + int imageWidth = Math.Min(imageSize.Width, Math.Max(0, contentBounds.Width - gap)); + Rectangle imageSlot = imageFirst + ? new Rectangle(contentBounds.Left, contentBounds.Top, imageWidth, contentBounds.Height) + : new Rectangle(contentBounds.Right - imageWidth, contentBounds.Top, imageWidth, contentBounds.Height); + Rectangle textBounds = imageFirst + ? Rectangle.FromLTRB(imageSlot.Right + gap, contentBounds.Top, contentBounds.Right, contentBounds.Bottom) + : Rectangle.FromLTRB(contentBounds.Left, contentBounds.Top, imageSlot.Left - gap, contentBounds.Bottom); + + return ( + textBounds, + AlignInRectangle(imageSlot, imageSize with { Width = imageWidth }, imageAlign)); + } + + (Rectangle TextBounds, Rectangle ImageBounds) CreateVerticalLayout(bool imageFirst) + { + int imageHeight = Math.Min(imageSize.Height, Math.Max(0, contentBounds.Height - gap)); + Rectangle imageSlot = imageFirst + ? new Rectangle(contentBounds.Left, contentBounds.Top, contentBounds.Width, imageHeight) + : new Rectangle(contentBounds.Left, contentBounds.Bottom - imageHeight, contentBounds.Width, imageHeight); + Rectangle textBounds = imageFirst + ? Rectangle.FromLTRB(contentBounds.Left, imageSlot.Bottom + gap, contentBounds.Right, contentBounds.Bottom) + : Rectangle.FromLTRB(contentBounds.Left, contentBounds.Top, contentBounds.Right, imageSlot.Top - gap); + + return ( + textBounds, + AlignInRectangle(imageSlot, imageSize with { Height = imageHeight }, imageAlign)); + } + } + + private static Rectangle AlignInRectangle( + Rectangle container, + Size size, + ContentAlignment alignment) + { + int x = alignment switch + { + ContentAlignment.TopLeft or ContentAlignment.MiddleLeft or ContentAlignment.BottomLeft => container.Left, + ContentAlignment.TopRight or ContentAlignment.MiddleRight or ContentAlignment.BottomRight + => container.Right - size.Width, + _ => container.Left + ((container.Width - size.Width) / 2) + }; + + int y = alignment switch + { + ContentAlignment.TopLeft or ContentAlignment.TopCenter or ContentAlignment.TopRight => container.Top, + ContentAlignment.BottomLeft or ContentAlignment.BottomCenter or ContentAlignment.BottomRight + => container.Bottom - size.Height, + _ => container.Top + ((container.Height - size.Height) / 2) + }; + + return new Rectangle(x, y, size.Width, size.Height); + } + + /// + /// Creates a rounded-rectangle path; degrades to a plain rectangle for tiny radii and clamps the radius so + /// it never exceeds half of the smaller side. + /// + private static GraphicsPath CreateRoundedPath(Rectangle rect, float radius) + { + GraphicsPath path = new(); + + radius = Math.Min(radius, Math.Min(rect.Width, rect.Height) / 2f); + + if (radius < 1f || rect.Width < 2 || rect.Height < 2) + { + path.AddRectangle(rect); + + return path; + } + + float diameter = radius * 2f; + RectangleF arc = new(rect.X, rect.Y, diameter, diameter); + + path.AddArc(arc, 180f, 90f); + arc.X = rect.Right - diameter; + path.AddArc(arc, 270f, 90f); + arc.Y = rect.Bottom - diameter; + path.AddArc(arc, 0f, 90f); + arc.X = rect.X; + path.AddArc(arc, 90f, 90f); + path.CloseFigure(); + + return path; + } + + private static Rectangle ApplyPadding(Rectangle rect, Padding padding) + => new( + rect.X + padding.Left, + rect.Y + padding.Top, + Math.Max(0, rect.Width - padding.Horizontal), + Math.Max(0, rect.Height - padding.Vertical)); + + /// + /// Grows a gradient rectangle by one pixel to avoid GDI+ edge-seam artifacts and to guarantee non-zero + /// dimensions. + /// + private static Rectangle InflateForGradient(Rectangle rect) + { + Rectangle result = Rectangle.Inflate(rect, 1, 1); + + if (result.Width < 2) + { + result.Width = 2; + } + + if (result.Height < 2) + { + result.Height = 2; + } + + return result; + } + + private static TextFormatFlags GetTextFormatFlags(PopupButtonRenderContext context) + { + bool rtl = context.RightToLeft == RightToLeft.Yes; + ContentAlignment align = rtl ? MirrorAlignment(context.TextAlign) : context.TextAlign; + + TextFormatFlags flags = align switch + { + ContentAlignment.TopLeft => TextFormatFlags.Top | TextFormatFlags.Left, + ContentAlignment.TopCenter => TextFormatFlags.Top | TextFormatFlags.HorizontalCenter, + ContentAlignment.TopRight => TextFormatFlags.Top | TextFormatFlags.Right, + ContentAlignment.MiddleLeft => TextFormatFlags.VerticalCenter | TextFormatFlags.Left, + ContentAlignment.MiddleRight => TextFormatFlags.VerticalCenter | TextFormatFlags.Right, + ContentAlignment.BottomLeft => TextFormatFlags.Bottom | TextFormatFlags.Left, + ContentAlignment.BottomCenter => TextFormatFlags.Bottom | TextFormatFlags.HorizontalCenter, + ContentAlignment.BottomRight => TextFormatFlags.Bottom | TextFormatFlags.Right, + _ => TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter + }; + + flags |= TextFormatFlags.EndEllipsis; + + if (context.Text is not null && !context.Text.Contains('\n')) + { + flags |= TextFormatFlags.SingleLine; + } + + if (rtl) + { + flags |= TextFormatFlags.RightToLeft; + } + + if (!context.ShowKeyboardCues) + { + flags |= TextFormatFlags.HidePrefix; + } + + return flags; + } + + private static ContentAlignment MirrorAlignment(ContentAlignment alignment) + => alignment switch + { + ContentAlignment.TopLeft => ContentAlignment.TopRight, + ContentAlignment.TopRight => ContentAlignment.TopLeft, + ContentAlignment.MiddleLeft => ContentAlignment.MiddleRight, + ContentAlignment.MiddleRight => ContentAlignment.MiddleLeft, + ContentAlignment.BottomLeft => ContentAlignment.BottomRight, + ContentAlignment.BottomRight => ContentAlignment.BottomLeft, + _ => alignment + }; + + /// + /// Device-resolved geometry and animation-modulated shading amounts for one render pass. + /// + private readonly struct Metrics + { + internal const float SideClearanceDip = 1f; + internal const float PressTravelDip = 1.5f; + + public float Scale { get; init; } + public int Ambient { get; init; } + public int BorderWidth { get; init; } + public int Rim { get; init; } + public float CornerRadius { get; init; } + public Rectangle KeyRect { get; init; } + public Rectangle BowlRect { get; init; } + public float BowlRadius { get; init; } + public int PressOffset { get; init; } + public float Hover { get; init; } + public float Press { get; init; } + public float HighlightAmount { get; init; } + public float ShadowAmount { get; init; } + public int TextReliefOffset { get; init; } + + public static Metrics Create(PopupButtonRenderContext context) + { + PopupButtonRenderOptions options = context.Options; + Rectangle bounds = context.Bounds; + + float scale = Math.Max(0.5f, context.DeviceDpi / 96f); + float hover = context.AnimationState.HoverProgress; + float press = context.AnimationState.PressProgress; + + int sideClearance = Math.Max(1, (int)MathF.Round(SideClearanceDip * scale)); + int pressTravel = Math.Max(1, (int)MathF.Round(PressTravelDip * scale)); + int ambient = sideClearance + pressTravel; + int maxBorder = Math.Max(0, (Math.Min(bounds.Width, bounds.Height) / 4) - 1); + int defaultBorderIncrease = context.IsDefault && context.Enabled + ? Math.Max(1, (int)MathF.Round(scale)) + : 0; + int borderWidth = Math.Clamp(context.BorderWidth + defaultBorderIncrease, 0, maxBorder); + + Rectangle keyRect = new( + bounds.X + sideClearance, + bounds.Y + sideClearance, + Math.Max(1, bounds.Width - (2 * sideClearance)), + Math.Max(1, bounds.Height - sideClearance - ambient)); + + // Pressing translates the complete key top into the space released by its shortening shadow. + // Keeping the key height constant avoids moving the bowl, border, and content independently. + int pressOffset = Math.Min( + (int)MathF.Round(press * PressTravelDip * scale), + Math.Max(0, bounds.Bottom - sideClearance - keyRect.Bottom)); + keyRect.Offset(0, pressOffset); + + int rim = Math.Max(2, (int)MathF.Round(3f * scale)); + int bowlInset = borderWidth + rim; + + if (keyRect.Width - (2 * bowlInset) < 8 || keyRect.Height - (2 * bowlInset) < 8) + { + rim = Math.Max(1, rim / 2); + bowlInset = borderWidth + rim; + } + + Rectangle bowlRect = Rectangle.Inflate(keyRect, -bowlInset, -bowlInset); + + if (bowlRect.Width < 2 || bowlRect.Height < 2) + { + bowlRect = Rectangle.Inflate(keyRect, -1, -1); + } + + float cornerRadius = Math.Clamp( + options.GetCornerRadiusDip() * scale, + 1f, + Math.Min(keyRect.Width, keyRect.Height) / 2f); + float bowlRadius = Math.Max(1f, cornerRadius - (rim * 0.6f)); + + // Pressing deepens the bowl; hovering flattens it a touch, as if the key rises to meet the finger. + // Highlights brighten on hover, shadows deepen on press. + float depth = options.GetConcavityDepth() * (1f + (press * 0.7f) - (hover * 0.12f)); + float highlight = Math.Clamp( + depth * options.GetHighlightMultiplier() * (1f + (hover * 0.55f)), + 0.02f, + 0.6f); + float shadow = Math.Clamp( + depth * options.GetShadowMultiplier() * (1f + (press * 0.35f)), + 0.02f, + 0.6f); + + return new Metrics + { + Scale = scale, + Ambient = ambient, + BorderWidth = borderWidth, + Rim = rim, + CornerRadius = cornerRadius, + KeyRect = keyRect, + BowlRect = bowlRect, + BowlRadius = bowlRadius, + PressOffset = pressOffset, + Hover = hover, + Press = press, + HighlightAmount = highlight, + ShadowAmount = shadow, + TextReliefOffset = Math.Max(1, (int)MathF.Round(0.8f * scale)) + }; + } + } + + /// + /// All colors of one render pass, derived from the effective context colors so the material effect adapts + /// to any BackColor/ForeColor combination. + /// + private readonly struct Palette + { + public Color BodyLight { get; init; } + public Color BodyDark { get; init; } + public Color BowlEdge { get; init; } + public Color BowlCenter { get; init; } + public Color DarkestTextBackground { get; init; } + public Color LightestTextBackground { get; init; } + public Color LipLight { get; init; } + public Color LipDark { get; init; } + public Color Border { get; init; } + public Color Text { get; init; } + public Color TextOutline { get; init; } + public Color TextHighlight { get; init; } + public Color TextShadow { get; init; } + public Color Focus { get; init; } + public int AmbientAlpha { get; init; } + public int InnerShadowAlpha { get; init; } + public int InnerLightAlpha { get; init; } + + public static Palette Create(PopupButtonRenderContext context, Metrics metrics, Color surfaceBackColor) + { + bool enabled = context.Enabled; + + // Disabled keys mute the material but keep the concave form readable - reduced contrast rather than + // flat gray. + Color back = enabled ? surfaceBackColor : PopupButtonColorMath.Mute(surfaceBackColor, 0.55f); + float contrast = enabled ? 1f : 0.35f; + float luminance = PopupButtonColorMath.GetLuminance(back); + + Color border = enabled + ? context.BorderColor + : PopupButtonColorMath.Blend(context.BorderColor, back, 0.45f); + + // Keep the border visible even if the user picked one too close to the face color. + border = PopupButtonColorMath.EnsureContrast(border, back, 0.08f); + + float reliefStrength = (enabled ? 1f : 0.4f) + (metrics.Press * 0.25f); + Color bodyLight = PopupButtonColorMath.Lighten(back, metrics.HighlightAmount * 0.9f * contrast); + Color bodyDark = PopupButtonColorMath.Darken(back, metrics.ShadowAmount * 0.9f * contrast); + Color bowlCenter = PopupButtonColorMath.Darken(back, (metrics.ShadowAmount * 0.75f * contrast) + (enabled ? 0f : 0.02f)); + Color bowlEdge = PopupButtonColorMath.Lighten(back, metrics.HighlightAmount * 0.55f * contrast); + int innerShadowAlpha = Math.Clamp( + (int)((30f + (metrics.ShadowAmount * 380f)) * contrast), 0, 120); + int innerLightAlpha = Math.Clamp( + (int)((20f + (metrics.HighlightAmount * 300f)) * contrast), 0, 100); + Color darkestBowl = Color.White; + Color lightestBowl = Color.Black; + float darkestLuminance = 1f; + float lightestLuminance = 0f; + EvaluateBowlExtremes(bodyLight, bowlCenter); + EvaluateBowlExtremes(bodyLight, bowlEdge); + EvaluateBowlExtremes(bodyDark, bowlCenter); + EvaluateBowlExtremes(bodyDark, bowlEdge); + Color text = enabled + ? context.UseAutomaticForeColor + ? PopupButtonColorMath.GetReadableForeColor(darkestBowl, lightestBowl) + : context.ForeColor + : ModernControlColorMath.GetDisabledTextColor( + context.ForeColor, + darkestBowl, + lightestBowl); + float textContrast = Math.Min( + PopupButtonColorMath.GetContrastRatio(text, darkestBowl), + PopupButtonColorMath.GetContrastRatio(text, lightestBowl)); + Color textOutline = enabled + && context.UseAutomaticForeColor + && textContrast < PopupButtonColorMath.MinimumReadableContrastRatio + ? text == Color.Black ? Color.White : Color.Black + : Color.Empty; + + return new Palette + { + BodyLight = bodyLight, + BodyDark = bodyDark, + BowlEdge = bowlEdge, + BowlCenter = bowlCenter, + DarkestTextBackground = darkestBowl, + LightestTextBackground = lightestBowl, + LipLight = PopupButtonColorMath.Lighten(back, metrics.HighlightAmount * 1.3f * contrast), + LipDark = PopupButtonColorMath.Darken(back, metrics.ShadowAmount * 1.2f * contrast), + Border = border, + Text = text, + TextOutline = textOutline, + TextHighlight = PopupButtonColorMath.GetTextHighlight(bowlCenter, reliefStrength), + TextShadow = PopupButtonColorMath.GetTextShadow(bowlCenter, reliefStrength), + Focus = PopupButtonColorMath.EnsureContrast( + PopupButtonColorMath.TowardsContrast(back, 0.55f), + back, + 0.45f), + AmbientAlpha = (int)((luminance > 0.5f ? 55f : 85f) + * (1f - (metrics.Press * 0.6f)) + * (enabled ? 1f : 0.5f)), + InnerShadowAlpha = innerShadowAlpha, + InnerLightAlpha = innerLightAlpha + }; + + void EvaluateBowlExtremes(Color bodyColor, Color bowlColor) + { + Color renderedBody = PopupButtonColorMath.Composite(bodyColor, context.SurfaceColor); + Color renderedBowl = PopupButtonColorMath.Composite(bowlColor, renderedBody); + EvaluateLuminance(renderedBowl); + EvaluateLuminance( + PopupButtonColorMath.Composite( + Color.FromArgb(innerShadowAlpha, Color.Black), + renderedBowl)); + EvaluateLuminance( + PopupButtonColorMath.Composite( + Color.FromArgb(innerLightAlpha, Color.White), + renderedBowl)); + } + + void EvaluateLuminance(Color color) + { + float luminance = PopupButtonColorMath.GetRelativeLuminance(color); + if (luminance < darkestLuminance) + { + darkestBowl = color; + darkestLuminance = luminance; + } + + if (luminance > lightestLuminance) + { + lightestBowl = color; + lightestLuminance = luminance; + } + } + } + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonMetric.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonMetric.cs new file mode 100644 index 00000000000..443770afb7c --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonMetric.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// A DPI-neutral, generic magnitude used to tune the key-cap renderer +/// (corner radius, concavity depth, highlight/shadow strength). +/// +/// +/// +/// Using generic magnitudes instead of pixel values allows the renderer to resolve the actual device +/// values per call, which keeps high-DPI scenarios trivially correct. +/// +/// +internal enum PopupButtonMetric +{ + /// + /// A small magnitude. + /// + Small = 0, + + /// + /// A medium magnitude. This is the default. + /// + Medium = 1, + + /// + /// A large magnitude. + /// + Large = 2 +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderContext.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderContext.cs new file mode 100644 index 00000000000..eadb9f557fc --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderContext.cs @@ -0,0 +1,156 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Carries every piece of information the key-cap renderer needs, without a +/// dependency on a live control instance. +/// +/// +/// +/// Because the renderer receives all state through this context, it can be driven from a control, a designer +/// surface or a preview-image generator. A caller only needs a target and this context. +/// +/// +internal sealed class PopupButtonRenderContext +{ + /// + /// Gets the bounds to render into, in device pixels of the target . + /// + public required Rectangle Bounds { get; init; } + + /// + /// Gets the caption text. May be or empty. + /// + public string? Text { get; init; } + + /// + /// Gets the font used for the caption. + /// + public required Font Font { get; init; } + + /// + /// Gets the effective key face color. + /// + public Color BackColor { get; init; } = SystemColors.Control; + + /// + /// Gets the effective caption color. + /// + public Color ForeColor { get; init; } = SystemColors.ControlText; + + /// + /// Gets the color behind the rendered key, used to resolve translucent automatic-color surfaces. + /// + public Color SurfaceColor { get; init; } = SystemColors.Control; + + /// + /// Gets a value indicating whether the renderer should select a readable caption color from the final bowl color. + /// + public bool UseAutomaticForeColor { get; init; } + + /// + /// Gets the border color of the key body. + /// + public Color BorderColor { get; init; } = SystemColors.ControlDark; + + /// + /// Gets the border width in device pixels. 0 renders no border. + /// + public int BorderWidth { get; init; } = 1; + + /// + /// Gets a value indicating whether the key is enabled. + /// + public bool Enabled { get; init; } = true; + + /// + /// Gets a value indicating whether the key has keyboard focus and should show a focus cue. + /// + public bool Focused { get; init; } + + /// + /// Gets a value indicating whether the key is currently pressed. Used for the high-contrast fallback, + /// where continuous animation is not applied. + /// + public bool Pressed { get; init; } + + /// + /// Gets a value indicating whether the key is the default button of its dialog. + /// + public bool IsDefault { get; init; } + + /// + /// Gets a value indicating whether the application is using its dark color scheme. + /// + public bool IsDarkMode { get; init; } + + /// + /// Gets the animation progress snapshot. + /// + public PopupButtonAnimationState AnimationState { get; init; } + + /// + /// Gets the caption alignment within the key top. + /// + public ContentAlignment TextAlign { get; init; } = ContentAlignment.MiddleCenter; + + /// + /// Gets the size of the image rendered with the caption. + /// + public Size ImageSize { get; init; } + + /// + /// Gets the alignment of the image within the key surface. + /// + public ContentAlignment ImageAlign { get; init; } = ContentAlignment.MiddleCenter; + + /// + /// Gets the positional relationship between the image and caption. + /// + public TextImageRelation TextImageRelation { get; init; } = TextImageRelation.Overlay; + + /// + /// Gets the right-to-left setting for text rendering. + /// + public RightToLeft RightToLeft { get; init; } = RightToLeft.No; + + /// + /// Gets the padding applied around the caption inside the bowl. + /// + public Padding Padding { get; init; } + + /// + /// Gets the DPI of the target device. Used to scale all chrome metrics. + /// + public int DeviceDpi { get; init; } = 96; + + /// + /// Gets the caption relief effect. + /// + public PopupButtonTextEffect TextEffect { get; init; } = PopupButtonTextEffect.Raised; + + /// + /// Gets a value indicating whether keyboard cues (mnemonic underlines) should be shown. + /// + public bool ShowKeyboardCues { get; init; } = true; + + /// + /// Gets a value indicating whether a high-contrast accessibility theme is active. + /// + /// + /// + /// When , the renderer falls back to a flat, higher-contrast style without material + /// emulation. + /// + /// + public bool HighContrast { get; init; } = SystemInformation.HighContrast; + + /// + /// Gets the rendering options to use. Defaults to the process-wide shared options. + /// + public PopupButtonRenderOptions Options { get; init; } = PopupButtonRenderOptions.Shared; +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderOptions.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderOptions.cs new file mode 100644 index 00000000000..c1864d18ad2 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonRenderOptions.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Tunable rendering options for the key-cap renderer. +/// +/// +/// +/// All geometric magnitudes are expressed as values and only resolved to +/// device pixels inside the renderer, based on the DPI carried by the render context. +/// is the process-wide instance used to tune the best all-purpose defaults. Border width and color are not +/// part of these options; they are read from the button's . +/// +/// +internal sealed class PopupButtonRenderOptions +{ + /// + /// Gets the process-wide shared options instance. + /// + public static PopupButtonRenderOptions Shared { get; } = new(); + + /// + /// Gets or sets the corner radius magnitude of the key body. + /// + public PopupButtonMetric CornerRadius { get; set; } = PopupButtonMetric.Medium; + + /// + /// Gets or sets how deep the concave bowl of the key top appears. + /// + public PopupButtonMetric ConcavityDepth { get; set; } = PopupButtonMetric.Medium; + + /// + /// Gets or sets the strength of edge highlights. + /// + public PopupButtonMetric HighlightStrength { get; set; } = PopupButtonMetric.Medium; + + /// + /// Gets or sets the strength of edge and bowl shadows. + /// + public PopupButtonMetric ShadowStrength { get; set; } = PopupButtonMetric.Medium; + + /// + /// Gets or sets the base duration of state-change animations. + /// + public TimeSpan AnimationDuration { get; set; } = TimeSpan.FromMilliseconds(160); + + /// + /// Resolves to device-independent pixels (96 DPI). + /// + internal float GetCornerRadiusDip() + => CornerRadius switch + { + PopupButtonMetric.Small => 4f, + PopupButtonMetric.Large => 9f, + _ => 6f + }; + + /// + /// Resolves to a fractional shading depth. + /// + internal float GetConcavityDepth() + => ConcavityDepth switch + { + PopupButtonMetric.Small => 0.07f, + PopupButtonMetric.Large => 0.17f, + _ => 0.11f + }; + + /// + /// Resolves to a multiplier. + /// + internal float GetHighlightMultiplier() + => HighlightStrength switch + { + PopupButtonMetric.Small => 0.6f, + PopupButtonMetric.Large => 1.5f, + _ => 1f + }; + + /// + /// Resolves to a multiplier. + /// + internal float GetShadowMultiplier() + => ShadowStrength switch + { + PopupButtonMetric.Small => 0.6f, + PopupButtonMetric.Large => 1.5f, + _ => 1f + }; +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonTextEffect.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonTextEffect.cs new file mode 100644 index 00000000000..42d10cd8d5f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Button/PopupButtonTextEffect.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.Button; + +/// +/// Determines how the caption of a key-cap button is physically integrated +/// into the key surface. +/// +internal enum PopupButtonTextEffect +{ + /// + /// The caption appears slightly raised above the key surface (embossed). + /// + Raised = 0, + + /// + /// The caption appears slightly recessed into the key surface (engraved/letterpress). + /// + Engraved = 1, + + /// + /// The caption is drawn flat, without any relief effect. + /// + Flat = 2 +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedCheckGlyphRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedCheckGlyphRenderer.cs new file mode 100644 index 00000000000..0abf778390f --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedCheckGlyphRenderer.cs @@ -0,0 +1,294 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Animation; + +namespace System.Windows.Forms.Rendering.CheckBox; + +/// +/// Animates and draws the modern normal-appearance CheckBox glyph. +/// +internal sealed class AnimatedCheckGlyphRenderer : AnimatedControlRenderer +{ + private const int AnimationDuration = 220; + + private float _checkAlphaCurrent; + private float _checkAlphaStart; + private float _checkAlphaTarget; + private float _dashAlphaCurrent; + private float _dashAlphaStart; + private float _dashAlphaTarget; + private float _fillCurrent; + private float _fillStart; + private float _fillTarget; + private float _interactionCurrent; + private float _interactionStart; + private float _interactionTarget; + private bool _interactionInitialized; + private bool _stateInitialized; + + public AnimatedCheckGlyphRenderer(Control control) : base(control) + { + } + + internal void NotifyCheckStateChanged(CheckState newState) + { + (float fill, float checkAlpha, float dashAlpha) = GetStateTargets(newState); + if (!_stateInitialized) + { + _stateInitialized = true; + _fillCurrent = _fillStart = _fillTarget = fill; + _checkAlphaCurrent = _checkAlphaStart = _checkAlphaTarget = checkAlpha; + _dashAlphaCurrent = _dashAlphaStart = _dashAlphaTarget = dashAlpha; + return; + } + + if (_fillTarget == fill + && _checkAlphaTarget == checkAlpha + && _dashAlphaTarget == dashAlpha) + { + return; + } + + _fillTarget = fill; + _checkAlphaTarget = checkAlpha; + _dashAlphaTarget = dashAlpha; + RestartAnimation(); + } + + internal void SetInteractionState(bool hovered, bool focused) + { + float interactionTarget = hovered || focused ? 1f : 0f; + if (!_interactionInitialized) + { + _interactionInitialized = true; + _interactionCurrent = _interactionStart = _interactionTarget = interactionTarget; + return; + } + + if (_interactionTarget == interactionTarget) + { + return; + } + + _interactionTarget = interactionTarget; + RestartAnimation(); + } + + internal void DrawGlyph( + Graphics graphics, + Rectangle bounds, + FlatStyle flatStyle, + bool enabled, + bool hovered, + bool focused, + Color? customOnColor, + Color? customBorderColor) + { + SetInteractionState(hovered, focused); + + bool isDark = Application.IsDarkModeEnabled; + bool highContrast = SystemInformation.HighContrast; + Color onColor = highContrast + ? SystemColors.Highlight + : customOnColor ?? WindowsAccentColor; + + Color offBorderColor = highContrast + ? SystemColors.WindowText + : customBorderColor + ?? (isDark + ? Color.FromArgb(0x9B, 0x9B, 0x9B) + : SystemColors.ControlDark); + + Color offBackColor = highContrast + ? SystemColors.Window + : isDark + ? Color.FromArgb(0x2D, 0x2D, 0x2D) + : Color.White; + + if (!enabled) + { + onColor = highContrast + ? SystemColors.GrayText + : isDark + ? Color.FromArgb(0x55, 0x55, 0x55) + : Color.FromArgb(0xC0, 0xC0, 0xC0); + offBorderColor = highContrast + ? SystemColors.GrayText + : isDark + ? Color.FromArgb(0x45, 0x45, 0x45) + : Color.FromArgb(0xD0, 0xD0, 0xD0); + } + + Color backColor = LerpColor(offBackColor, onColor, _fillCurrent); + Color borderColor = LerpColor(offBorderColor, onColor, _fillCurrent); + float interaction = enabled && !highContrast ? _interactionCurrent : 0f; + backColor = ApplyInteractionShade(backColor, interaction); + borderColor = ApplyInteractionShade(borderColor, interaction); + + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + using GraphicsPath path = CreateBoxPath(bounds, flatStyle); + + using (var brush = backColor.GetCachedSolidBrushScope()) + { + graphics.FillPath(brush, path); + } + + int borderThickness = Math.Max( + 1, + Control.LogicalToDeviceUnits(flatStyle == FlatStyle.Popup ? 2 : 1)); + + using (var pen = new Pen(borderColor, borderThickness) { Alignment = PenAlignment.Inset }) + { + graphics.DrawPath(pen, path); + } + + Color glyphColor = enabled + ? highContrast + ? SystemColors.HighlightText + : ModernButtonColorMath.GetReadableForeColor(onColor) + : offBackColor; + + if (_checkAlphaCurrent > 0) + { + DrawCheckmark(graphics, bounds, glyphColor, _checkAlphaCurrent); + } + + if (_dashAlphaCurrent > 0) + { + DrawDash(graphics, bounds, glyphColor, _dashAlphaCurrent); + } + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + } + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + float easedProgress = EaseOut(animationProgress); + _fillCurrent = Lerp(_fillStart, _fillTarget, easedProgress); + _checkAlphaCurrent = Lerp(_checkAlphaStart, _checkAlphaTarget, easedProgress); + _dashAlphaCurrent = Lerp(_dashAlphaStart, _dashAlphaTarget, easedProgress); + _interactionCurrent = Lerp(_interactionStart, _interactionTarget, easedProgress); + Invalidate(); + } + + public override void RenderControl(Graphics graphics) + { + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + _fillStart = _fillCurrent; + _checkAlphaStart = _checkAlphaCurrent; + _dashAlphaStart = _dashAlphaCurrent; + _interactionStart = _interactionCurrent; + AnimationProgress = 0; + return (AnimationDuration, AnimationCycle.Once); + } + + protected override void OnAnimationStopped() + { + } + + protected override void OnAnimationEnded() + { + StopAnimation(); + _fillCurrent = _fillTarget; + _checkAlphaCurrent = _checkAlphaTarget; + _dashAlphaCurrent = _dashAlphaTarget; + _interactionCurrent = _interactionTarget; + AnimationProgress = 1; + Invalidate(); + } + + private static (float Fill, float CheckAlpha, float DashAlpha) GetStateTargets(CheckState state) + => state switch + { + CheckState.Checked => (1f, 1f, 0f), + CheckState.Indeterminate => (1f, 0f, 1f), + _ => (0f, 0f, 0f) + }; + + private static float Lerp(float from, float to, float progress) + => from + ((to - from) * Math.Clamp(progress, 0f, 1f)); + + private static Color LerpColor(Color from, Color to, float progress) + { + progress = Math.Clamp(progress, 0f, 1f); + + return Color.FromArgb( + LerpChannel(from.A, to.A, progress), + LerpChannel(from.R, to.R, progress), + LerpChannel(from.G, to.G, progress), + LerpChannel(from.B, to.B, progress)); + + static int LerpChannel(int from, int to, float progress) + => from + (int)((to - from) * progress); + } + + private static float EaseOut(float progress) + => 1 - ((1 - progress) * (1 - progress)); + + private static GraphicsPath CreateBoxPath(Rectangle bounds, FlatStyle flatStyle) + { + GraphicsPath path = new(); + if (flatStyle == FlatStyle.Flat) + { + path.AddRectangle(bounds); + return path; + } + + double radiusFactor = flatStyle == FlatStyle.Popup ? 0.3 : 0.2; + int radius = Math.Max(1, (int)(Math.Min(bounds.Width, bounds.Height) * radiusFactor)); + path.AddRoundedRectangle(bounds, new Size(radius, radius)); + return path; + } + + private static void DrawCheckmark(Graphics graphics, Rectangle bounds, Color color, float alpha) + { + using var pen = new Pen( + Color.FromArgb((int)(alpha * 255), color), + Math.Max(1.5f, bounds.Width * 0.12f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round, + LineJoin = LineJoin.Round + }; + + PointF first = new(bounds.Left + (bounds.Width * 0.20f), bounds.Top + (bounds.Height * 0.52f)); + PointF second = new(bounds.Left + (bounds.Width * 0.42f), bounds.Top + (bounds.Height * 0.74f)); + PointF third = new(bounds.Left + (bounds.Width * 0.82f), bounds.Top + (bounds.Height * 0.28f)); + graphics.DrawLines(pen, [first, second, third]); + } + + private static void DrawDash(Graphics graphics, Rectangle bounds, Color color, float alpha) + { + using var pen = new Pen( + Color.FromArgb((int)(alpha * 255), color), + Math.Max(1.5f, bounds.Height * 0.14f)) + { + StartCap = LineCap.Round, + EndCap = LineCap.Round + }; + + float y = bounds.Top + (bounds.Height * 0.5f); + graphics.DrawLine( + pen, + bounds.Left + (bounds.Width * 0.2f), + y, + bounds.Right - (bounds.Width * 0.2f), + y); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs new file mode 100644 index 00000000000..42a4443bf63 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/AnimatedToggleSwitchRenderer.cs @@ -0,0 +1,444 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Animation; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms.Rendering.CheckBox; + +/// +/// Renders and animates a or +/// in mode. Used when +/// is or later. +/// +internal sealed class AnimatedToggleSwitchRenderer : AnimatedControlRenderer +{ + private const int AnimationDuration = 300; + + private readonly ModernCheckBoxStyle _switchStyle; + private float _focusCurrent; + private float _focusStart; + private float _focusTarget; + private float _hoverCurrent; + private float _hoverStart; + private float _hoverTarget; + private bool _interactionInitialized; + private float _onAmountCurrent; + private float _onAmountStart; + private float _onAmountTarget; + private bool _positionInitialized; + private float _positionCurrent; + private float _positionStart; + private float _positionTarget; + + public AnimatedToggleSwitchRenderer(Control control, ModernCheckBoxStyle switchStyle) + : base(control) + { + _switchStyle = switchStyle; + } + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + float easedProgress = EaseOut(animationProgress); + _positionCurrent = Lerp(_positionStart, _positionTarget, easedProgress); + _onAmountCurrent = Lerp(_onAmountStart, _onAmountTarget, easedProgress); + _focusCurrent = Lerp(_focusStart, _focusTarget, easedProgress); + _hoverCurrent = Lerp(_hoverStart, _hoverTarget, easedProgress); + Invalidate(); + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + EnsurePositionInitialized(); + _positionStart = _positionCurrent; + _positionTarget = IsChecked ? 1f : 0f; + _onAmountStart = _onAmountCurrent; + _onAmountTarget = IsChecked ? 1f : 0f; + _focusStart = _focusCurrent; + _hoverStart = _hoverCurrent; + AnimationProgress = 0; + + return (AnimationDuration, AnimationCycle.Once); + } + + /// + /// Called from the control's OnPaint. Works both while the animation is running (driven by + /// ) and when it is settled (progress is 1). + /// + /// The graphics object to render into. + public override void RenderControl(Graphics graphics) + { + EnsurePositionInitialized(); + SetInteractionState( + hovered: MouseIsOver, + focused: Control.Focused && ShowFocusCues); + + ToggleSwitchMetrics metrics = ToggleSwitchMetrics.Create(Control); + Size textSize = TextRenderer.MeasureText(Control.Text, Control.Font); + Rectangle contentBounds = ToggleSwitchMetrics.GetContentBounds(Control); + int totalHeight = Math.Max(textSize.Height, metrics.SwitchHeight); + int contentTop = contentBounds.Top + Math.Max(0, (contentBounds.Height - totalHeight) / 2); + int textY = contentTop + ((totalHeight - textSize.Height) / 2); + Rectangle switchBounds = GetSwitchBounds( + Control, + RtlTranslatedCheckAlign, + metrics, + textSize); + + graphics.Clear(Control.BackColor); + + if (contentBounds.Width <= 0 || contentBounds.Height <= 0) + { + return; + } + + if (IsSwitchOnRight(RtlTranslatedCheckAlign)) + { + int textX = Math.Max(contentBounds.Left, switchBounds.Left - metrics.TextGap - textSize.Width); + RenderSwitch(graphics, switchBounds, metrics); + RenderText(graphics, new Point(textX, textY)); + } + else + { + RenderSwitch(graphics, switchBounds, metrics); + RenderText(graphics, new Point(contentBounds.Left + metrics.SwitchWidth + metrics.TextGap, textY)); + } + + if (Control.Focused && ShowFocusCues) + { + Rectangle focusBounds = Rectangle.Inflate(Control.ClientRectangle, -1, -1); + ControlPaint.DrawFocusRectangle( + graphics, + focusBounds, + Control.ForeColor, + Control.BackColor); + } + } + + internal void SetInteractionState(bool hovered, bool focused) + { + float hoverTarget = hovered ? 1f : 0f; + float focusTarget = focused ? 1f : 0f; + if (!_interactionInitialized) + { + _interactionInitialized = true; + _hoverCurrent = _hoverStart = _hoverTarget = hoverTarget; + _focusCurrent = _focusStart = _focusTarget = focusTarget; + return; + } + + if (_hoverTarget == hoverTarget && _focusTarget == focusTarget) + { + return; + } + + _hoverTarget = hoverTarget; + _focusTarget = focusTarget; + RestartAnimation(); + } + + private static bool IsSwitchOnRight(ContentAlignment checkAlign) => checkAlign is + ContentAlignment.TopRight or + ContentAlignment.MiddleRight or + ContentAlignment.BottomRight; + + internal static Rectangle GetSwitchBounds( + Control control, + ContentAlignment checkAlign, + ToggleSwitchMetrics metrics) + => GetSwitchBounds( + control, + checkAlign, + metrics, + TextRenderer.MeasureText(control.Text, control.Font)); + + private static Rectangle GetSwitchBounds( + Control control, + ContentAlignment checkAlign, + ToggleSwitchMetrics metrics, + Size textSize) + { + Rectangle contentBounds = ToggleSwitchMetrics.GetContentBounds(control); + int totalHeight = Math.Max(textSize.Height, metrics.SwitchHeight); + int contentTop = contentBounds.Top + Math.Max(0, (contentBounds.Height - totalHeight) / 2); + int switchY = contentTop + ((totalHeight - metrics.SwitchHeight) / 2); + int switchX = IsSwitchOnRight(checkAlign) + ? Math.Max(contentBounds.Left, contentBounds.Right - metrics.SwitchWidth) + : contentBounds.Left; + + return new Rectangle(switchX, switchY, metrics.SwitchWidth, metrics.SwitchHeight); + } + + private void RenderText(Graphics graphics, Point position) + { + TextRenderer.DrawText( + graphics, + Control.Text, + Control.Font, + position, + GetTextColor()); + } + + internal Color GetTextColor() + => Control.Enabled + ? Control.ForeColor + : ModernControlColorMath.GetDisabledTextColor( + Control.ForeColor, + Control.BackColor); + + private void RenderSwitch(Graphics graphics, Rectangle rect, ToggleSwitchMetrics metrics) + { + if (rect.Width <= 0 || rect.Height <= 0) + { + return; + } + + bool highContrast = SystemInformation.HighContrast; + Color onColor = highContrast + ? SystemColors.Highlight + : WindowsAccentColor; + Color offColor = highContrast ? SystemColors.Window : SystemColors.ControlDark; + Color backgroundColor = Control.Enabled + ? PopupButtonColorMath.Blend(offColor, onColor, _onAmountCurrent) + : SystemColors.Control; + Color highContrastForeground = PopupButtonColorMath.Blend( + SystemColors.WindowText, + SystemColors.HighlightText, + _onAmountCurrent); + Color borderColor = Control.Enabled + ? highContrast + ? highContrastForeground + : SystemColors.WindowFrame + : SystemColors.GrayText; + float focus = Control.Enabled && !highContrast ? _focusCurrent : 0f; + backgroundColor = ApplyInteractionShade(backgroundColor, focus); + Color circleColor = Control.Enabled + ? highContrast + ? highContrastForeground + : PopupButtonColorMath.GetReadableForeColor(offColor, onColor) + : SystemColors.GrayText; + circleColor = ApplyInteractionShade(circleColor, focus); + borderColor = ApplyInteractionShade(borderColor, focus); + + float thumbDiameter = Lerp( + metrics.ThumbDiameter, + metrics.HoverThumbDiameter, + Control.Enabled ? _hoverCurrent : 0f); + float circlePosition = (rect.Width - thumbDiameter) * _positionCurrent; + + using var backgroundBrush = backgroundColor.GetCachedSolidBrushScope(); + using var circleBrush = circleColor.GetCachedSolidBrushScope(); + using var backgroundPen = borderColor.GetCachedPenScope(metrics.BorderThickness); + + SmoothingMode previousSmoothingMode = graphics.SmoothingMode; + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + if (_switchStyle == ModernCheckBoxStyle.Rounded) + { + float radius = Math.Min(rect.Width, rect.Height) / 2f; + + using GraphicsPath path = new(); + path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); + path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); + path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseFigure(); + + graphics.FillPath(backgroundBrush, path); + graphics.DrawPath(backgroundPen, path); + } + else + { + graphics.FillRectangle(backgroundBrush, rect); + graphics.DrawRectangle(backgroundPen, rect); + } + + float circleTop = rect.Y + ((rect.Height - thumbDiameter) / 2f); + graphics.FillEllipse( + circleBrush, + rect.X + circlePosition, + circleTop, + thumbDiameter, + thumbDiameter); + } + finally + { + graphics.SmoothingMode = previousSmoothingMode; + } + } + + internal void PrepareStateChange() + { + EnsurePositionInitialized(); + StopAnimation(); + } + + internal void SynchronizeState() + { + StopAnimation(); + _positionCurrent = IsChecked ? 1f : 0f; + _positionStart = _positionCurrent; + _positionTarget = _positionCurrent; + _onAmountCurrent = IsChecked ? 1f : 0f; + _onAmountStart = _onAmountCurrent; + _onAmountTarget = _onAmountCurrent; + _focusCurrent = _focusStart = _focusTarget; + _hoverCurrent = _hoverStart = _hoverTarget; + _interactionInitialized = true; + _positionInitialized = true; + AnimationProgress = 1; + } + + protected override void OnAnimationStopped() + { + } + + protected override void OnAnimationEnded() + { + StopAnimation(); + _positionCurrent = _positionTarget; + _positionStart = _positionCurrent; + _onAmountCurrent = _onAmountTarget; + _onAmountStart = _onAmountCurrent; + _focusCurrent = _focusTarget; + _focusStart = _focusCurrent; + _hoverCurrent = _hoverTarget; + _hoverStart = _hoverCurrent; + AnimationProgress = 1; + Invalidate(); + } + + private void EnsurePositionInitialized() + { + if (_positionInitialized) + { + return; + } + + _positionCurrent = IsChecked ? 1f : 0f; + _positionStart = _positionCurrent; + _positionTarget = _positionCurrent; + _onAmountCurrent = IsChecked ? 1f : 0f; + _onAmountStart = _onAmountCurrent; + _onAmountTarget = _onAmountCurrent; + _positionInitialized = true; + } + + private static float EaseOut(float value) + => 1 - ((1 - value) * (1 - value)); + + private static float Lerp(float start, float end, float amount) + => start + ((end - start) * amount); + + private bool IsChecked => Control switch + { + Forms.CheckBox checkBox => checkBox.Checked, + Forms.RadioButton radioButton => radioButton.Checked, + _ => false + }; + + private ContentAlignment RtlTranslatedCheckAlign => Control switch + { + Forms.CheckBox checkBox => checkBox.RtlTranslatedCheckAlign, + Forms.RadioButton radioButton => radioButton.RtlTranslatedCheckAlign, + _ => ContentAlignment.MiddleLeft + }; + + private bool ShowFocusCues => Control switch + { + Forms.CheckBox checkBox => checkBox.ShowFocusCuesInternal, + Forms.RadioButton radioButton => radioButton.ShowFocusCuesInternal, + _ => false + }; + + private bool MouseIsOver => Control switch + { + Forms.CheckBox checkBox => checkBox.MouseIsOver, + Forms.RadioButton radioButton => radioButton.MouseIsOver, + _ => false + }; +} + +/// +/// Provides the geometry shared by toggle-switch rendering and preferred-size calculations. +/// +internal readonly struct ToggleSwitchMetrics +{ + private ToggleSwitchMetrics( + int switchWidth, + int switchHeight, + int thumbDiameter, + int hoverThumbDiameter, + int borderThickness, + int textGap) + { + SwitchWidth = switchWidth; + SwitchHeight = switchHeight; + ThumbDiameter = thumbDiameter; + HoverThumbDiameter = hoverThumbDiameter; + BorderThickness = borderThickness; + TextGap = textGap; + } + + internal int SwitchWidth { get; } + + internal int SwitchHeight { get; } + + internal int ThumbDiameter { get; } + + internal int HoverThumbDiameter { get; } + + internal int BorderThickness { get; } + + internal int TextGap { get; } + + internal static ToggleSwitchMetrics Create(Control control) + { + int switchHeight = Math.Max( + control.LogicalToDeviceUnits(13), + (int)(control.Font.Height * 0.9f)); + int minimumMetric = Math.Max(1, control.LogicalToDeviceUnits(1)); + int borderThickness = Math.Max(minimumMetric, switchHeight / 12); + int maximumThumbDiameter = Math.Max(1, switchHeight - (2 * borderThickness)); + int thumbDiameter = Math.Max( + 1, + Math.Min( + switchHeight - (2 * (borderThickness + minimumMetric)), + (int)Math.Floor(maximumThumbDiameter / 1.1f))); + int hoverThumbDiameter = Math.Min( + maximumThumbDiameter, + Math.Max(thumbDiameter, (int)Math.Ceiling(thumbDiameter * 1.1f))); + int textGap = Math.Max(2 * minimumMetric, switchHeight / 3); + + return new( + switchWidth: 2 * switchHeight, + switchHeight: switchHeight, + thumbDiameter: thumbDiameter, + hoverThumbDiameter: hoverThumbDiameter, + borderThickness: borderThickness, + textGap: textGap); + } + + internal static Rectangle GetContentBounds(Control control) + { + Padding padding = control.Padding; + return new Rectangle( + padding.Left, + padding.Top, + Math.Max(0, control.ClientSize.Width - padding.Horizontal), + Math.Max(0, control.ClientSize.Height - padding.Vertical)); + } + + internal Size GetPreferredSize(Control control) + { + Size textSize = TextRenderer.MeasureText(control.Text, control.Font); + return new Size( + SwitchWidth + TextGap + textSize.Width + control.Padding.Horizontal, + Math.Max(SwitchHeight, textSize.Height) + control.Padding.Vertical); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs new file mode 100644 index 00000000000..32f35518ed3 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/CheckBox/ModernCheckBoxStyle.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms.Rendering.CheckBox; + +internal enum ModernCheckBoxStyle +{ + Rectangular, + Rounded +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs new file mode 100644 index 00000000000..89b95acaf14 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlColorMath.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms; + +/// +/// Provides shared color calculations for modern control renderers. +/// +internal static class ModernControlColorMath +{ + internal const float MinimumDisabledTextContrastRatio = 3f; + + private const float DisabledMuteAmount = 0.45f; + private const int ContrastSearchIterations = 10; + + internal static Color GetDisabledTextColor( + Color preferredForeColor, + Color backColor) + => GetDisabledTextColor( + preferredForeColor, + backColor, + backColor); + + internal static Color GetDisabledTextColor( + Color preferredForeColor, + Color firstBackColor, + Color secondBackColor) + { + if (SystemInformation.HighContrast) + { + return SystemColors.GrayText; + } + + firstBackColor = ResolveOpaqueColor(firstBackColor); + secondBackColor = ResolveOpaqueColor(secondBackColor); + + Color muteColor = PopupButtonColorMath.Blend( + firstBackColor, + secondBackColor, + 0.5f); + Color mutedForeColor = PopupButtonColorMath.Blend( + preferredForeColor, + muteColor, + DisabledMuteAmount); + + if (HasMinimumContrast( + mutedForeColor, + firstBackColor, + secondBackColor)) + { + return mutedForeColor; + } + + Color contrastColor = PopupButtonColorMath.GetReadableForeColor( + firstBackColor, + secondBackColor); + Color result = contrastColor; + float low = 0f; + float high = 1f; + + for (int i = 0; i < ContrastSearchIterations; i++) + { + float amount = (low + high) / 2f; + Color candidate = PopupButtonColorMath.Blend( + mutedForeColor, + contrastColor, + amount); + + if (HasMinimumContrast( + candidate, + firstBackColor, + secondBackColor)) + { + result = candidate; + high = amount; + } + else + { + low = amount; + } + } + + return result; + } + + private static bool HasMinimumContrast( + Color foreColor, + Color firstBackColor, + Color secondBackColor) + => PopupButtonColorMath.GetContrastRatio( + foreColor, + firstBackColor) >= MinimumDisabledTextContrastRatio + && PopupButtonColorMath.GetContrastRatio( + foreColor, + secondBackColor) >= MinimumDisabledTextContrastRatio; + + private static Color ResolveOpaqueColor(Color color) + => color.A == byte.MaxValue + ? color + : PopupButtonColorMath.Composite(color, SystemColors.Control); +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlVisualStyles.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlVisualStyles.cs new file mode 100644 index 00000000000..6edd43a12f9 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlVisualStyles.cs @@ -0,0 +1,183 @@ +// 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; + +/// +/// Defines shared device-independent metrics for modern control chrome. All values are logical +/// (96-DPI) pixels and are scaled to the target device DPI at the point of use. +/// +internal static class ModernControlVisualStyles +{ + /// Stroke thickness of the modern rounded control border. + internal const int BorderThickness = 1; + + /// Extra width added to the modern ComboBox drop-down button beyond the native metric. + internal const int ComboBoxButtonExtraWidth = 4; + + /// Horizontal clearance so the flat native edit child's square corners clear the field's rounded arcs. + internal const int ComboBoxFieldArcClearance = 2; + + /// Minimal modern ComboBox field chrome inset (excludes classic 3D-border metrics). + internal const int ComboBoxStyleInset = 1; + + /// Corner radius of a modern text field's rounded frame. + internal const int FieldCornerRadius = 15; + + /// Height of the animated focus underline band drawn beneath a focused modern field. + internal const int FocusBandHeight = 4; + + /// Scale factor applied to the GroupBox caption font in modern mode. + internal const float GroupBoxCaptionFontScale = 1.15f; + + /// Gap between the GroupBox caption text and the surrounding frame line. + internal const int GroupBoxCaptionGap = 4; + + /// Inset from the GroupBox bottom frame to its content area. + internal const int GroupBoxContentBottomInset = 4; + + /// Inset from the GroupBox left/right frame to its content area. + internal const int GroupBoxContentHorizontalInset = 8; + + /// Inset from the GroupBox top frame (below the caption) to its content area. + internal const int GroupBoxContentTopInset = 8; + + /// Corner radius of the GroupBox rounded frame. + internal const int GroupBoxCornerRadius = 8; + + /// Horizontal padding around the GroupBox header text. + internal const int GroupBoxHeaderHorizontalPadding = 10; + + /// Vertical padding around the GroupBox header text. + internal const int GroupBoxHeaderVerticalPadding = 5; + + /// + /// Inset between a control's border and its content, shared by modern text fields and the up-down + /// control. Added on top of the border-padding component (see ). + /// + internal const int InternalChromeInset = 2; + + /// Border-padding component for a border. + internal const int Fixed3DBorderPadding = 2; + + /// Border-padding component for a border. + internal const int FixedSingleBorderPadding = 1; + + /// Border-padding component for a control with . + internal const int NoBorderPadding = 1; + + /// Corner radius of the up-down control's rounded frame. + internal const int UpDownCornerRadius = 14; + + internal static Padding GetFieldPadding( + BorderStyle borderStyle, + Padding userPadding, + Size focusBorderMetrics, + float textScaleFactor, + int deviceDpi) + { + Size scaledFocusBorderMetrics = GetFocusBorderMetrics( + focusBorderMetrics, + textScaleFactor, + deviceDpi); + + int horizontalOffset = scaledFocusBorderMetrics.Width; + int verticalOffset = scaledFocusBorderMetrics.Height; + + Padding borderPadding = borderStyle switch + { + BorderStyle.Fixed3D => new Padding( + left: ScaleToDpi(Fixed3DBorderPadding, deviceDpi) + horizontalOffset, + top: ScaleToDpi(Fixed3DBorderPadding, deviceDpi) + verticalOffset, + right: ScaleToDpi(Fixed3DBorderPadding, deviceDpi) + horizontalOffset, + bottom: ScaleToDpi(Fixed3DBorderPadding, deviceDpi) + verticalOffset), + + BorderStyle.FixedSingle => new Padding( + left: ScaleToDpi(FixedSingleBorderPadding, deviceDpi) + horizontalOffset, + top: ScaleToDpi(FixedSingleBorderPadding, deviceDpi) + verticalOffset, + right: ScaleToDpi(FixedSingleBorderPadding, deviceDpi) + horizontalOffset, + bottom: ScaleToDpi(FixedSingleBorderPadding, deviceDpi) + verticalOffset), + + BorderStyle.None => new Padding( + left: ScaleToDpi(NoBorderPadding, deviceDpi), + top: ScaleToDpi(NoBorderPadding, deviceDpi), + right: ScaleToDpi(NoBorderPadding, deviceDpi) + horizontalOffset, + bottom: ScaleToDpi(NoBorderPadding, deviceDpi) + verticalOffset), + _ => Padding.Empty + }; + + return borderPadding + + new Padding(ScaleToDpi(InternalChromeInset, deviceDpi)) + + userPadding; + } + + internal static Size GetFocusBorderMetrics( + Size focusBorderMetrics, + float textScaleFactor, + int deviceDpi) + => new( + ScaleFocusMetric( + focusBorderMetrics.Width, + textScaleFactor, + deviceDpi), + ScaleFocusMetric( + focusBorderMetrics.Height, + textScaleFactor, + deviceDpi)); + + internal static int GetFocusBandHeight( + Size focusBorderMetrics, + float textScaleFactor, + int deviceDpi) + { + Size scaledFocusBorderMetrics = GetFocusBorderMetrics( + focusBorderMetrics, + textScaleFactor, + deviceDpi); + int scaledFocusBandHeight = ScaleFocusMetric( + FocusBandHeight, + textScaleFactor, + deviceDpi); + + return Math.Max( + scaledFocusBandHeight, + scaledFocusBorderMetrics.Height); + } + + internal static int GetPreferredFieldHeight( + int fontHeight, + Padding fieldPadding, + int deviceDpi) + { + int preferredHeight = fontHeight + fieldPadding.Vertical; + + int roundedChromeMinimumHeight = ScaleToDpi( + FieldCornerRadius, + deviceDpi) + + ScaleToDpi(BorderThickness, deviceDpi) + + ScaleToDpi(InternalChromeInset, deviceDpi); + + return Math.Max(preferredHeight, roundedChromeMinimumHeight); + } + + private static int ScaleFocusMetric( + int metric, + float textScaleFactor, + int deviceDpi) + { + float scale = Math.Clamp(textScaleFactor, 1f, 2.25f); + + int dpiScaledMetric = ScaleToDpi( + Math.Max(metric, BorderThickness), + deviceDpi); + + return Math.Max( + ScaleToDpi(BorderThickness, deviceDpi), + (int)Math.Ceiling(dpiScaledMetric * scale)); + } + + private static int ScaleToDpi(int value, int deviceDpi) + => ScaleHelper.ScaleToDpi(value, deviceDpi); +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Rendering/RadioButton/AnimatedRadioGlyphRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/Rendering/RadioButton/AnimatedRadioGlyphRenderer.cs new file mode 100644 index 00000000000..2582400678c --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/RadioButton/AnimatedRadioGlyphRenderer.cs @@ -0,0 +1,240 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms.Rendering.Animation; +using System.Windows.Forms.Rendering.Button; + +namespace System.Windows.Forms.Rendering.RadioButton; + +/// +/// Animates and draws the modern normal-appearance RadioButton glyph. +/// +internal sealed class AnimatedRadioGlyphRenderer : AnimatedControlRenderer +{ + private const int AnimationDuration = 200; + private const float HoverGrowth = 1.1f; + + private float _dotScaleCurrent; + private float _dotScaleStart; + private float _dotScaleTarget; + private float _focusCurrent; + private float _focusStart; + private float _focusTarget; + private float _hoverCurrent; + private float _hoverStart; + private float _hoverTarget; + private bool _interactionInitialized; + private bool _stateInitialized; + + public AnimatedRadioGlyphRenderer(Control control) : base(control) + { + } + + internal void NotifyCheckedChanged(bool newChecked) + { + float dotScaleTarget = newChecked ? 1f : 0f; + if (!_stateInitialized) + { + _stateInitialized = true; + _dotScaleCurrent = _dotScaleStart = _dotScaleTarget = dotScaleTarget; + return; + } + + if (_dotScaleTarget == dotScaleTarget) + { + return; + } + + _dotScaleTarget = dotScaleTarget; + RestartAnimation(); + } + + internal void SetInteractionState(bool hovered, bool focused) + { + float hoverTarget = hovered ? 1f : 0f; + float focusTarget = focused ? 1f : 0f; + if (!_interactionInitialized) + { + _interactionInitialized = true; + _hoverCurrent = _hoverStart = _hoverTarget = hoverTarget; + _focusCurrent = _focusStart = _focusTarget = focusTarget; + return; + } + + if (_hoverTarget == hoverTarget && _focusTarget == focusTarget) + { + return; + } + + _hoverTarget = hoverTarget; + _focusTarget = focusTarget; + RestartAnimation(); + } + + internal void DrawGlyph( + Graphics graphics, + Rectangle bounds, + FlatStyle flatStyle, + bool enabled, + bool hovered, + bool focused, + Color? customOnColor, + Color? customBorderColor) + { + SetInteractionState(hovered, focused); + + bool isDark = Application.IsDarkModeEnabled; + bool highContrast = SystemInformation.HighContrast; + Color onColor = highContrast + ? SystemColors.Highlight + : customOnColor ?? WindowsAccentColor; + + Color borderColor = highContrast + ? SystemColors.WindowText + : customBorderColor + ?? (isDark + ? Color.FromArgb(0x9B, 0x9B, 0x9B) + : SystemColors.ControlDark); + + Color backColor = highContrast + ? SystemColors.Window + : isDark + ? Color.FromArgb(0x2D, 0x2D, 0x2D) + : Color.White; + + if (!enabled) + { + onColor = highContrast + ? SystemColors.GrayText + : isDark + ? Color.FromArgb(0x55, 0x55, 0x55) + : Color.FromArgb(0xC0, 0xC0, 0xC0); + borderColor = highContrast + ? SystemColors.GrayText + : isDark + ? Color.FromArgb(0x45, 0x45, 0x45) + : Color.FromArgb(0xD0, 0xD0, 0xD0); + } + + float focus = enabled && !highContrast ? _focusCurrent : 0f; + onColor = ApplyInteractionShade(onColor, focus); + borderColor = ApplyInteractionShade(borderColor, focus); + backColor = ApplyInteractionShade(backColor, focus); + + float normalOuterScale = 1f / HoverGrowth; + float outerScale = Lerp(normalOuterScale, 1f, enabled ? _hoverCurrent : 0f); + RectangleF outerBounds = ScaleFromCenter(bounds, outerScale); + RectangleF normalBounds = ScaleFromCenter(bounds, normalOuterScale); + + GraphicsState? saved = graphics.Save(); + try + { + graphics.SmoothingMode = SmoothingMode.AntiAlias; + + using (var brush = backColor.GetCachedSolidBrushScope()) + { + graphics.FillEllipse(brush, outerBounds); + } + + int borderThickness = Math.Max( + 1, + Control.LogicalToDeviceUnits(flatStyle == FlatStyle.Popup ? 2 : 1)); + + using (var pen = new Pen(borderColor, borderThickness)) + { + graphics.DrawEllipse(pen, outerBounds); + } + + if (_dotScaleCurrent > 0.001f) + { + float dotDiameter = normalBounds.Width * 0.5f * _dotScaleCurrent; + RectangleF dotRectangle = new( + normalBounds.X + ((normalBounds.Width - dotDiameter) / 2f), + normalBounds.Y + ((normalBounds.Height - dotDiameter) / 2f), + dotDiameter, + dotDiameter); + + Color dotOutlineColor = highContrast + ? SystemColors.HighlightText + : PopupButtonColorMath.GetReadableForeColor(onColor, backColor); + int outlineThickness = Math.Max(1, Control.LogicalToDeviceUnits(1)); + using var outlineBrush = dotOutlineColor.GetCachedSolidBrushScope(); + graphics.FillEllipse(outlineBrush, dotRectangle); + + RectangleF accentRectangle = RectangleF.Inflate( + dotRectangle, + -outlineThickness, + -outlineThickness); + if (accentRectangle.Width > 0 && accentRectangle.Height > 0) + { + using var dotBrush = onColor.GetCachedSolidBrushScope(); + graphics.FillEllipse(dotBrush, accentRectangle); + } + } + } + finally + { + if (saved is not null) + { + graphics.Restore(saved); + } + } + } + + public override void AnimationProc(float animationProgress) + { + base.AnimationProc(animationProgress); + float easedProgress = EaseOut(animationProgress); + _dotScaleCurrent = Lerp(_dotScaleStart, _dotScaleTarget, easedProgress); + _focusCurrent = Lerp(_focusStart, _focusTarget, easedProgress); + _hoverCurrent = Lerp(_hoverStart, _hoverTarget, easedProgress); + Invalidate(); + } + + public override void RenderControl(Graphics graphics) + { + } + + protected override (int animationDuration, AnimationCycle animationCycle) OnAnimationStarted() + { + _dotScaleStart = _dotScaleCurrent; + _focusStart = _focusCurrent; + _hoverStart = _hoverCurrent; + AnimationProgress = 0; + return (AnimationDuration, AnimationCycle.Once); + } + + protected override void OnAnimationStopped() + { + } + + protected override void OnAnimationEnded() + { + StopAnimation(); + _dotScaleCurrent = _dotScaleTarget; + _focusCurrent = _focusTarget; + _hoverCurrent = _hoverTarget; + AnimationProgress = 1; + Invalidate(); + } + + private static float EaseOut(float progress) + => 1 - ((1 - progress) * (1 - progress)); + + private static float Lerp(float from, float to, float progress) + => from + ((to - from) * Math.Clamp(progress, 0f, 1f)); + + private static RectangleF ScaleFromCenter(Rectangle bounds, float scale) + { + float width = bounds.Width * scale; + float height = bounds.Height * scale; + + return new( + bounds.X + ((bounds.Width - width) / 2f), + bounds.Y + ((bounds.Height - height) / 2f), + width, + height); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs b/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs new file mode 100644 index 00000000000..e38e19d9d9b --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +/// +/// Represents the version of the visual renderer that a control or the application uses. +/// +/// +/// +/// The visual styles version controls how a control renders its adorners, borders, and layout. +/// Newer versions can adjust minimum sizes, padding, and margins to satisfy current accessibility +/// requirements without changing the behavior of applications that target an earlier version. +/// +/// +/// A visual styles version defines the latest rendering behavior a control may use; it does not require +/// every control to change. Each control determines which visual features and appearance values it supports +/// for a given version. +/// +/// +public enum VisualStylesMode : short +{ + /// + /// The control inherits its from its parent, or, for a + /// top-level control, from . This is the ambient default + /// returned by when no local mode is set. + /// + /// + /// + /// This value is the ambient sentinel: assigning it to + /// clears any local override so the value is inherited again. It is not valid as the application + /// default and is rejected by . + /// + /// + Inherit = -1, + + /// + /// The classic version of the visual renderer (.NET 8 and earlier), based on version 6 of the + /// common controls library. + /// + Classic = 0, + + /// + /// Visual renderers are not in use - see . + /// Controls are based on version 5 of the common controls library. + /// + Disabled = 1, + + /// + /// The .NET 11 version of the visual renderer. Controls are rendered using the latest version + /// of the common controls library, and the adorner rendering or the layout of specific controls + /// has been improved based on the latest accessibility requirements. + /// + /// + /// + /// Controls opt into the .NET 11 rendering changes they support. For example, support for + /// depends on the control and its current state. + /// + /// + Net11 = 2, + + /// + /// The latest stable version of the visual renderer available in the running framework. + /// + Latest = short.MaxValue +} diff --git a/src/test/integration/UIIntegrationTests/ComboBoxTests.cs b/src/test/integration/UIIntegrationTests/ComboBoxTests.cs index 49b940381d1..e0f807af878 100644 --- a/src/test/integration/UIIntegrationTests/ComboBoxTests.cs +++ b/src/test/integration/UIIntegrationTests/ComboBoxTests.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; +using System.ComponentModel.Design; +using System.Drawing; + namespace System.Windows.Forms.UITests; public class ComboBoxTests : ControlTestBase @@ -32,3 +36,200 @@ await RunSingleControlTestAsync((form, comboBox) => }); } } + +/// +/// Exercises native ComboBox layout without input-simulation infrastructure. +/// +public class ComboBoxNativeLayoutTests +{ + [WinFormsFact] + public void ComboBox_ModernVisualStyles_TableLayoutRoundTrips() + { + using Form form = new() + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + VisualStylesMode = VisualStylesMode.Classic + }; + using TableLayoutPanel table = new() + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 1 + }; + table.ColumnStyles.Add( + new ColumnStyle(SizeType.AutoSize)); + table.RowStyles.Add( + new RowStyle(SizeType.AutoSize)); + using ComboBox comboBox = new() + { + Anchor = AnchorStyles.Left + | AnchorStyles.Top + | AnchorStyles.Bottom, + FlatStyle = FlatStyle.Standard, + Size = new Size(180, 40) + }; + table.Controls.Add(comboBox, 0, 0); + form.Controls.Add(table); + form.CreateControl(); + table.CreateControl(); + comboBox.CreateControl(); + IntPtr handle = comboBox.Handle; + + form.VisualStylesMode = VisualStylesMode.Net11; + form.PerformLayout(); + form.VisualStylesMode = VisualStylesMode.Classic; + form.PerformLayout(); + var classicState = GetNativeComboState(comboBox); + + for (int i = 0; i < 10; i++) + { + form.VisualStylesMode = VisualStylesMode.Net11; + form.PerformLayout(); + Rectangle modernButtonBounds = GetButtonBounds(comboBox); + Assert.True(modernButtonBounds.Width > 0); + Assert.True(modernButtonBounds.Height > 0); + using Bitmap bitmap = new( + comboBox.Width, + comboBox.Height); + comboBox.DrawToBitmap( + bitmap, + new Rectangle(Point.Empty, comboBox.Size)); + + form.VisualStylesMode = VisualStylesMode.Classic; + form.PerformLayout(); + Assert.Equal(classicState, GetNativeComboState(comboBox)); + Assert.Equal(handle, comboBox.Handle); + } + } + + [WinFormsFact] + public void ComboBox_ModernVisualStyles_DesignSurfacePropertyOrderConverges() + { + ServiceContainer services = new(); + using DesignSurface designSurface = new(services); + bool loaded = false; + designSurface.Loaded += (sender, e) => + loaded = true; + designSurface.BeginLoad( + new DesignBehaviorsTests.SampleDesignerLoader()); + Assert.True(loaded); + + var designerHost = (IDesignerHost)designSurface.GetService( + typeof(IDesignerHost))!; + var designedForm = (Form)designerHost.RootComponent; + designedForm.Size = new Size(400, 220); + designedForm.VisualStylesMode = VisualStylesMode.Classic; + var comboBox = (ComboBox)designerHost.CreateComponent( + typeof(ComboBox)); + comboBox.Size = new Size(180, 40); + designedForm.Controls.Add(comboBox); + + var rootView = (Control)designSurface.View; + rootView.CreateControl(); + comboBox.CreateControl(); + + using Font font = new( + Control.DefaultFont.FontFamily, + 11f); + PropertyDescriptorCollection properties = + TypeDescriptor.GetProperties(comboBox); + properties[nameof(ComboBox.FlatStyle)]!.SetValue( + comboBox, + FlatStyle.Popup); + properties[nameof(ComboBox.Font)]!.SetValue( + comboBox, + font); + properties[nameof(ComboBox.Padding)]!.SetValue( + comboBox, + new Padding(2, 3, 4, 5)); + properties[nameof(ComboBox.VisualStylesMode)]!.SetValue( + comboBox, + VisualStylesMode.Net11); + var expectedModernState = GetNativeComboState(comboBox); + + properties[nameof(ComboBox.VisualStylesMode)]!.SetValue( + comboBox, + VisualStylesMode.Classic); + properties[nameof(ComboBox.Padding)]!.SetValue( + comboBox, + Padding.Empty); + properties[nameof(ComboBox.VisualStylesMode)]!.SetValue( + comboBox, + VisualStylesMode.Net11); + properties[nameof(ComboBox.Padding)]!.SetValue( + comboBox, + new Padding(2, 3, 4, 5)); + properties[nameof(ComboBox.Font)]!.SetValue( + comboBox, + font); + properties[nameof(ComboBox.FlatStyle)]!.SetValue( + comboBox, + FlatStyle.Popup); + + Assert.Equal( + expectedModernState, + GetNativeComboState(comboBox)); + } + + private static ( + Size size, + int selectionHeight, + nint margins, + Rectangle editBounds, + Rectangle itemBounds, + Rectangle buttonBounds) GetNativeComboState( + ComboBox comboBox) + { + COMBOBOXINFO comboBoxInfo = GetComboBoxInfo(comboBox); + PInvokeCore.GetWindowRect( + comboBoxInfo.hwndItem, + out RECT editBounds); + Point editTopLeft = comboBox.PointToClient( + new Point(editBounds.left, editBounds.top)); + + return ( + comboBox.Size, + (int)PInvokeCore.SendMessage( + comboBox, + PInvoke.CB_GETITEMHEIGHT, + (WPARAM)(-1)), + PInvokeCore.SendMessage( + comboBoxInfo.hwndItem, + PInvokeCore.EM_GETMARGINS), + new Rectangle( + editTopLeft, + new Size(editBounds.Width, editBounds.Height)), + new Rectangle( + comboBoxInfo.rcItem.left, + comboBoxInfo.rcItem.top, + comboBoxInfo.rcItem.Width, + comboBoxInfo.rcItem.Height), + GetButtonBounds(comboBoxInfo)); + } + + private static Rectangle GetButtonBounds( + ComboBox comboBox) + => GetButtonBounds(GetComboBoxInfo(comboBox)); + + private static Rectangle GetButtonBounds( + COMBOBOXINFO comboBoxInfo) + => new( + comboBoxInfo.rcButton.left, + comboBoxInfo.rcButton.top, + comboBoxInfo.rcButton.Width, + comboBoxInfo.rcButton.Height); + + private static unsafe COMBOBOXINFO GetComboBoxInfo( + ComboBox comboBox) + { + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + Assert.True(PInvoke.GetComboBoxInfo( + comboBox.HWND, + ref comboBoxInfo)); + + return comboBoxInfo; + } +} diff --git a/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs b/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs index f835831c3f0..1d0fac899fa 100644 --- a/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs +++ b/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs @@ -27,4 +27,25 @@ await RunSingleControlTestAsync(async (form, control) => Assert.NotNull(focused); }); } + + [WinFormsFact] + public async Task NumericUpDown_ModernChrome_UsesInsetEditAndSideBySideButtonsAsync() + { + await RunSingleControlTestAsync(async (form, control) => + { + control.VisualStylesMode = VisualStylesMode.Net11; + control.AutoSize = true; + form.PerformLayout(); + + if (!control.UseSideBySideButtons) + { + return; + } + + Assert.True(control.Height >= control.LogicalToDeviceUnits(15) * 2); + Assert.Equal(control.LogicalToDeviceUnits(3), control.TextBox.Left); + Assert.Equal(control.LogicalToDeviceUnits(3), control.UpDownButtonsInternal.Top); + Assert.True(control.UpDownButtonsInternal.Bounds.Left >= control.TextBox.Bounds.Right); + }); + } } diff --git a/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs new file mode 100644 index 00000000000..aa534e49060 --- /dev/null +++ b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs @@ -0,0 +1,230 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace System.Windows.Forms.UITests; + +public class VisualStylesCrossFeatureTests : ControlTestBase +{ + public VisualStylesCrossFeatureTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + [WinFormsFact] + public async Task VisualStyles_InheritedControls_RenderAgainstPatternedParentAcrossModesAndDpiAsync() + { + List samples = []; + + await RunFormWithoutControlAsync( + () => + { + Form form = new() + { + AutoScaleMode = AutoScaleMode.Dpi, + RightToLeft = RightToLeft.Yes, + RightToLeftLayout = true, + Size = new Size(900, 600) + }; + + PatternedGradientPanel parent = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(8), + RightToLeft = RightToLeft.Yes, + VisualStylesMode = VisualStylesMode.Classic + }; + form.Controls.Add(parent); + + TableLayoutPanel table = new() + { + AutoScroll = true, + BackColor = Color.Transparent, + ColumnCount = 3, + Dock = DockStyle.Fill, + RowCount = 11 + }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 32)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + parent.Controls.Add(table); + + table.Controls.Add(new Label { Text = "Control / font", AutoSize = true }, 0, 0); + table.Controls.Add(new Label { Text = "AutoSize", AutoSize = true }, 1, 0); + table.Controls.Add(new Label { Text = "Fixed small", AutoSize = true }, 2, 0); + + string[] controlKinds = ["TextBox", "MaskedTextBox", "RichTextBox", "NumericUpDown", "DomainUpDown"]; + int row = 1; + foreach (float fontSize in new[] { 9f, 11f }) + { + foreach (string controlKind in controlKinds) + { + table.Controls.Add(new Label { Text = $"{controlKind} ({fontSize:0} pt)", AutoSize = true }, 0, row); + Control autoSized = CreateSampleControl(controlKind, fontSize, autoSize: true); + Control fixedSmall = CreateSampleControl(controlKind, fontSize, autoSize: false); + samples.Add(autoSized); + samples.Add(fixedSmall); + table.Controls.Add(autoSized, 1, row); + table.Controls.Add(fixedSmall, 2, row); + row++; + } + } + + return form; + }, + async form => + { + Panel parent = (Panel)form.Controls[0]; + Assert.Equal(form.DeviceDpi, parent.DeviceDpi); + Assert.All(samples, sample => Assert.Equal(RightToLeft.Yes, sample.RightToLeft)); + + foreach (Control sample in samples) + { + Assert.Equal(VisualStylesMode.Inherit, sample.VisualStylesMode); + Assert.True(sample.Width > 0); + Assert.True(sample.Height > 0); + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Net11; + form.PerformLayout(); + Assert.All( + samples, + sample => Assert.Equal(VisualStylesMode.Inherit, sample.VisualStylesMode)); + + foreach (Control sample in samples) + { + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Classic; + await Task.Yield(); + Assert.All( + samples, + sample => Assert.Equal(VisualStylesMode.Inherit, sample.VisualStylesMode)); + }); + } + + [WinFormsFact] + public async Task VisualStyles_StandardSystemAndPopup_RenderFocusedDefaultAndPressedStatesAsync() + { + await RunFormWithoutControlAsync( + () => + { + Form form = new() { Size = new Size(500, 220) }; + FlowLayoutPanel panel = new() + { + Dock = DockStyle.Fill, + RightToLeft = RightToLeft.Yes, + FlowDirection = FlowDirection.LeftToRight + }; + form.Controls.Add(panel); + form.RightToLeft = RightToLeft.Yes; + form.RightToLeftLayout = true; + form.AutoScaleMode = AutoScaleMode.Dpi; + + Button standard = CreateButton(FlatStyle.Standard, "Standard"); + standard.NotifyDefault(true); + form.AcceptButton = standard; + panel.Controls.Add(standard); + foreach (FlatStyle style in new[] { FlatStyle.System, FlatStyle.Popup }) + { + Button button = CreateButton(style, style.ToString()); + button.NotifyDefault(true); + panel.Controls.Add(button); + } + + return form; + }, + async form => + { + FlowLayoutPanel panel = (FlowLayoutPanel)form.Controls[0]; + foreach (Button button in panel.Controls.OfType