diff --git a/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md b/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md new file mode 100644 index 00000000000..470d3584b5b --- /dev/null +++ b/.github/Feature-Prompts/Net11/01-B05-HighPrecisionTimer-Rework-WorkOrder.md @@ -0,0 +1,157 @@ +# Work Order: HighPrecisionTimer Rework (Animation Timing) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Files:** +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimer.cs` +- `src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Animation/HighPrecisionTimerTick.cs` +- `src/System.Windows.Forms.Primitives/tests/UnitTests/System/Windows/Forms/Animation/HighPrecisionTimerTests.cs` +- Consumer: `src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs` + +This work order is self-contained; it results from a code review of the current implementation. +Read the current sources first, verify each finding against the code as it stands (the branch may have +moved), then implement. + +**Keep as-is (explicitly not up for redesign):** the registration model — per-registration +`SynchronizationContext` capture, `InFlight` CAS-based frame coalescing with a `DroppedFrames` counter +surfaced in `HighPrecisionTimerTick`, and the id-based `TimerRegistration` disposable struct +(double-dispose safe, `default` safe). The *clock/pacing core* is what gets replaced. + +--- + +## Finding 1 (critical): fixed-cadence pacer + spin causes a sawtooth burning ~50% of a core + +Current design: `PeriodicTimer` at 14 ms (60 Hz path), then `SpinToTarget` spins to the 16.667 ms +frame target with `SpinOnce(sleep1Threshold: -1)` (never sleeps). + +`PeriodicTimer` fires on its own **fixed** cadence (14, 28, 42, 56, …) and does not re-phase per +`WaitForNextTickAsync` call, while frame targets are 16.67, 33.33, 50, 66.67, … The phase slips +2.67 ms per frame, so spin duration grows every frame — wake 28 → spin 5.3 ms; wake 42 → spin 8 ms; +wake 56 → spin 10.7 ms — until phases wrap and the sawtooth restarts. Average spin ≈ half a frame +≈ 8 ms of every 16.67 ms ⇒ ~50% of one core, continuously, for as long as **any** animation is +registered. Infinite cycles (e.g. a pulsing focus indicator) make this permanent. The 30 Hz path +(30 ms tick vs 33.33 ms frame) has the identical slip. + +**Required fix (architecture, not constant-tuning):** absolute schedule. Due times are +`epoch + frameIndex * period` against a single clock; wait on a mechanism that can hit them +(Finding 3), with at most a sub-millisecond residual spin. Overshoot must be amortized against the +absolute schedule (no `lastTick + period` relative scheduling — that accumulates and runs slow). + +## Finding 2: integer-millisecond arithmetic throughout + +`stopwatch.ElapsedMilliseconds` (truncated `long`) feeds `lastTickTimestamp`, `elapsed`, the spin +target, drift detection, and the `Timestamp`/`Elapsed` values delivered to consumers — ±1 ms +quantization (6% of a 16.667 ms budget) plus systematic truncation bias. Use `ElapsedTicks` / +`Elapsed.TotalMilliseconds` (double) end to end; `HighPrecisionTimerTick` fields stay `TimeSpan`, +constructed from ticks. + +## Finding 3: replace `timeBeginPeriod(1)` with a high-resolution waitable timer + +The code gates on `windows10.0.17134` — which `timeBeginPeriod` (ancient) does not need, but which is +exactly the build (1803) that introduced `CreateWaitableTimerExW` with +`CREATE_WAITABLE_TIMER_HIGH_RESOLUTION`. Use it: + +- absolute due times (negative-relative or absolute FILETIME) with sub-ms accuracy, +- no process-wide timer-resolution raise (current code holds 1 ms resolution for the entire lifetime + of any animation — a documented power/battery anti-pattern, and post-Win11 the effective resolution + changes for occluded windows, silently shifting the timing floor), +- composes directly with Finding 1's absolute schedule and eliminates the spin loop almost entirely. + +Fallback below 17134: keep a coarse path (30 Hz, plain waits, no `timeBeginPeriod`) — document that +sub-frame precision is not attempted there. Remove `TimeBeginPeriod`/`TimeEndPeriod` P/Invokes if no +longer referenced. + +## Finding 4: `Register`/`Unregister` vs `StopTimer` race strands registrations + +`Unregister` does `TryRemove` → `IsEmpty?` → `StopTimer()` without coordinating with `Register`. +Interleaving: A removes the last entry and observes empty; B adds a registration and `EnsureRunning` +sees `s_loopTask != null` (still running) and returns; A stops the timer. Result: live registration, +dead timer — animation frozen until an unrelated `Register` restarts the loop. +**Fix:** perform the emptiness check + stop decision under `s_lock` together with loop-state +transitions, or introduce a generation counter that `StopTimer` validates before actually stopping. + +## Finding 5: per-frame, per-registration closure allocations on the hot path + +`SyncContext.Post(_ => _ = InvokeCallbackAsync(registration, tick, cancellationToken), null)` +allocates closure + delegate per registration per frame (60 Hz × N renderers of steady GC pressure). +**Fix:** one cached `static SendOrPostCallback`; pass state via a per-registration state object — +`InFlight` guarantees exclusivity, so tick data can be written into a reusable per-registration slot +before posting. Target: zero allocations per frame in steady state. + +## Finding 6: drift `Debug.Assert` is an assert storm + +Drift >20% for 10 frames is normal under a debugger, breakpoints, or CI load. Once tripped, +`consecutiveDriftFrames` keeps incrementing, so the assert fires **every subsequent frame**. +**Fix:** replace with tracing/EventSource counters (drift, dropped frames, spin time). If any assert +remains, reset the counter after firing once. + +## Finding 7: lifecycle edges + +- `StopTimer` never observes `s_loopTask`; a stop/start pair can transiently run two loops, and a + stopping loop can dispatch one final frame with a canceled token. Decide and document: either join + the old loop (bounded) or make late dispatch provably benign. +- Post-unregister ticks can still be in flight toward a disposed consumer; `AnimationManager` / + `AnimatedControlRenderer` must tolerate late callbacks — add a test. +- `Reset()` (test hook) mutates state without locking — document the serialization requirement or + lock it. +- Dead code: `Registration.Id` is never read. +- `InvokeCallbackAsync` swallows non-OCE exceptions with `Debug.Fail` and keeps invoking the same + callback forever; consider auto-unregistering a registration after N consecutive faults. + +## Finding 8: single-SyncContext funnel in `AnimationManager` (design note) + +`HighPrecisionTimer` correctly captures a `SynchronizationContext` **per registration**, but +`AnimationManager` is a process-wide singleton with a single registration, so every animation in a +multi-message-loop application marshals to the first UI thread — and dies with it. Minimum: document +the constraint. Better: make the manager per-UI-thread (e.g. `[ThreadStatic]` instance keyed off the +message-loop thread), preserving one timer-registration-per-UI-thread. Also note the duplicate +timeline: the manager keeps its own `Stopwatch` instead of deriving progress from +`HighPrecisionTimerTick.Timestamp/Elapsed`; animation progress should use the tick's timeline so frame +coalescing (`DroppedFrames`) is accounted for consistently. + +## Finding 9: 60 Hz is a settled ceiling; the period is a pacer-owned runtime value for *downshift* + +**Decision (do not relitigate):** the timer targets a hard 60 Hz ceiling (30 Hz fallback). Do not +add refresh-rate matching or raise the cadence. Rationale, for the record: + +- Under DWM, windowed GDI/GDI+ apps do not tear (composition is tear-free from the redirection + surface); the only artifact of an unsynced 60 Hz timer is judder, which is imperceptible for this + content class (focus pulses, hover fades, toggle transitions — not motion/scrolling). +- GDI+ raster cost scales linearly with rate; driving N animated controls at 120–144 Hz multiplies + compute for no perceptible gain (e.g. batched MVVM-driven updates across many controls). +- A process-wide timer cannot refresh-match on mixed-rate multi-monitor setups ("the" refresh rate + is ill-defined), and `DwmFlush`-style vblank pacing blocks per frame, binds to one monitor, and + misbehaves under RDP — unfit for a process-wide UI timer by construction. + +**Required now (structural):** the frame period must be a runtime value owned by the pacer +(queryable/settable internally), not compile-time constants woven through the loop. The 30 Hz +fallback already makes the period variable; the forward-looking motivation is **downshifting**, not +matching: future power-driven reductions (30 Hz or full pause for occluded/minimized windows — where +Windows 11 timer coalescing already alters the effective cadence — and battery-saver scenarios) +must be addable without another rework. + +--- + +## Acceptance criteria + +1. Steady-state CPU of the timer loop with one registered infinite animation: **< 2% of one core** + (measure; the current implementation is the ~50% baseline per Finding 1). +2. No `timeBeginPeriod` while animations run (verify via `powercfg /energy` or timer-resolution + query on a Win10 1803+ box). +3. Frame delivery: mean interval within ±0.5 ms of target over a 10 s run on an idle machine at + 60 Hz; no monotonic slow drift (absolute-schedule check: 600th frame due time within one frame of + `epoch + 600 × period`). +4. Zero per-frame heap allocations in steady state (verify with an allocation-tracking test or + `GC.GetAllocatedBytesForCurrentThread` bracketing). +5. Race test for Finding 4: concurrent register/unregister stress leaves no registration without a + running loop. +6. Existing `HighPrecisionTimerTests` updated/extended accordingly; late-callback tolerance test for + consumers (Finding 7). +7. `HighPrecisionTimerTick` surface unchanged (internal consumers depend on it); all other churn is + internal to the pacer. + +## Constraints + +- Everything stays `internal`; no public API review implications. +- Follow repo conventions (`LibraryImport`, nullable enabled, existing XML-doc voice). +- Windows-only paths gated with `[SupportedOSPlatform]` / `OperatingSystem.IsWindowsVersionAtLeast` + as currently practiced in the file. diff --git a/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md b/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md new file mode 100644 index 00000000000..56070f96b3e --- /dev/null +++ b/.github/Feature-Prompts/Net11/02-B01-SystemVisualSettings-Implementation.md @@ -0,0 +1,158 @@ +# Work Order: SystemVisualSettings (Implementation) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Scope:** Code changes only. The GitHub proposal issue is handled by a separate work order +(`ApiReview-IssueUpdates.md`, Section 3) and run only after this implementation stands. +**Supersedes:** `Application.GetWindowsAccentColor` and the standalone accessibility text-size change +event currently on the branch (see item 6). + +--- + +## Background + +Windows delivers visual/accessibility setting changes through four channels — `WM_SETTINGCHANGE`, +`WM_DWMCOLORIZATIONCOLORCHANGED`, `WM_THEMECHANGED`, `WM_SYSCOLORCHANGE` — and today each consumer +normalizes that zoo individually. This work order introduces one typed, read-only snapshot plus one +unified change notification, with a **leak-free consumption path for controls** (virtual cascade, no +static-event subscription — the `SystemEvents.UserPreferenceChanged` leak class must be structurally +impossible for the default path). + +Deliberate non-goals, enforced by the type system: **no settable properties.** Perceptual adjustments +(contrast, color filtering, text scale, focus prominence, motion) are user-owned via Windows +accessibility settings; app-side theming is the business of control vendor partners. Renderers derive +output from this snapshot combined with `EffectiveVisualStylesMode`. + +--- + +## 1. New types (`System.Windows.Forms`) + +```csharp +public sealed class SystemVisualSettings +{ + public Color AccentColor { get; } + public float TextScaleFactor { get; } // 1.0–2.25, Windows a11y text scale + public bool HighContrastEnabled { get; } + public bool ClientAreaAnimationEnabled { get; } // SPI_GETCLIENTAREAANIMATION + public bool KeyboardCuesVisible { get; } // SPI_GETKEYBOARDCUES (system default) + public Size FocusBorderMetrics { get; } // SPI_GETFOCUSBORDERWIDTH/HEIGHT, pixels +} + +[Flags] +public enum SystemVisualSettingsCategories +{ + None = 0, + AccentColor = 1 << 0, + TextScale = 1 << 1, + HighContrast = 1 << 2, + Animations = 1 << 3, + KeyboardCues = 1 << 4, + FocusMetrics = 1 << 5 +} + +public class SystemVisualSettingsChangedEventArgs : EventArgs +{ + public SystemVisualSettings OldSettings { get; } + public SystemVisualSettings NewSettings { get; } + public SystemVisualSettingsCategories Changed { get; } +} +``` + +Immutable snapshot semantics — values must not shift under an event handler comparing old vs new. +XML remarks per the design notes: `HighContrastEnabled` cross-references the effective-mode clamp; +`KeyboardCuesVisible` documents the system-default vs per-window (`WM_UPDATEUISTATE`) distinction; +`FocusBorderMetrics` is documented as the baseline input for border/focus prominence in renderers +(scaled for DPI and `TextScaleFactor`), replacing fixed constants; the class remarks state the +no-app-side-overrides principle explicitly. + +## 2. `Application` surface + +```csharp +public static SystemVisualSettings SystemVisualSettings { get; } +public static event EventHandler? SystemVisualSettingsChanged; +``` + +Event XML remarks must state: (a) raised once per settings transition, normalized across the four +underlying messages; (b) handlers should early-out via `e.Changed`; (c) **audience is app-lifetime +consumers** (theming engines, services); components with shorter lifetime than the application must +unsubscribe; **controls and forms should use the `Control`-level virtual/instance event instead, +which requires no unsubscription** (see item 3). This positioning is the leak fix — make the +leak-free path the documented default. + +## 3. `Control`-level consumption (the leak-free path) + +```csharp +protected virtual void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e); +public event EventHandler? SystemVisualSettingsChanged; +``` + +- Model the cascade on the existing `OnSystemColorsChanged` pattern in `Control.cs`: virtual + dispatch parent→children, instance event raised from within the virtual. No subscription to any + static event exists anywhere in this path — lifetime coupling is structural. +- Keep the cascade pattern-identical to `OnVisualStylesModeChanged` / `OnParentVisualStylesModeChanged` + (see the VisualStylesMode impact work order). A `HighContrast` category change resolves as an + effective-visual-styles-mode change for affected controls and must route through that machinery's + early-out/dispatch — no duplicate HC handling in this cascade. +- **Remove/replace the existing Form-level replicated text-size event**: it generalizes into this + cascade and disappears as a special case. Migrate in-box usages. +- Staleness rule (document in XML remarks, mirroring `OnSystemColorsChanged` folklore — this time + written down): the cascade only reaches parented controls; a control created but not yet parented + misses transitions and must re-query `Application.SystemVisualSettings` on handle creation / + `OnParentChanged`. + +## 4. Message plumbing and normalization + +Central internal tracker (e.g. `SystemVisualSettingsTracker`) holding the current snapshot: + +- Every **top-level** window already receives the four raw messages; handle them in the existing + top-level `WndProc` paths. +- On receipt: the window asks the tracker to re-query. The **first** arriver computes the diff + against the current snapshot, atomically swaps it (`Interlocked` reference swap), raises the static + `Application` event **once**, and cascades into its own tree. Subsequent top-levels re-query, see + no diff, raise nothing at Application level, but **still cascade into their own trees** using the + already-computed args. +- Threading falls out for free: each tree is notified on the thread owning its top-level — no + marshaling, correct for multi-message-loop applications. Do not centralize onto one thread. +- Coalesce message storms: a single user action can produce several of the four messages; debounce + within a message-pump iteration (re-query once per burst per top-level, not per message). + +## 5. In-box consumption cleanup + +- Audit in-box `Microsoft.Win32.SystemEvents` subscriptions (`UserPreferenceChanged` et al.) in + controls/renderers; migrate those covered by the new categories to the cascade. Document any that + must remain (categories outside this surface) — do not expand the snapshot to chase them in this + work order. +- `TextBoxBase` Net11 border rendering: consume `FocusBorderMetrics` + `TextScaleFactor` as the + border-prominence input where fixed constants are currently used (coordinate with the animation / + focus-indicator renderer as applicable). +- Animated renderers (`AnimatedControlRenderer` / `AnimationManager`): honor + `ClientAreaAnimationEnabled == false` by rendering final state immediately and suppressing + transitions; react to the `Animations` category change at runtime. + +## 6. Supersede the piecemeal APIs + +- `Application.GetWindowsAccentColor` → `Application.SystemVisualSettings.AccentColor`. The method + has not shipped stable: **remove it** on this branch (preferred) rather than obsoleting, to avoid + two sources of truth. If removal is blocked by preview-compat policy, `[Obsolete]` with pointer. +- The standalone text-size change event (Application- and/or Form-level) → `Changed.HasFlag(TextScale)` + on the unified event / cascade. Same removal-vs-obsolete decision, same preference. +- Migrate all in-box call sites. + +## 7. Tests + +- Snapshot immutability and correct SPI mapping per property (mock/native-shim as the repo's test + infra allows). +- Normalization: N top-level windows + one settings transition ⇒ exactly one Application-level raise; + every window's tree cascaded exactly once; delivery on each tree's own thread. +- Flags correctness per category, including multi-category transitions (HC toggle typically changes + colors + HC + metrics in one burst — must coalesce to one event with combined flags). +- Leak test: create/dispose forms subscribing to the **control-level** event in a loop; assert + collectability (`WeakReference`), proving no static rooting. Counter-test documenting that the + static event does root (expected, documented behavior). +- HC toggle end-to-end: cascade triggers effective-mode change path once, no duplicate layout. + +## Constraints + +- All new public surface XML-documented in the branch's voice; internal tracker fully internal. +- No settable members anywhere on the new types — if implementation pressure suggests one, stop and + flag rather than adding it. +- Windows-only P/Invoke via `LibraryImport`, gated per existing repo practice. diff --git a/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md b/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md new file mode 100644 index 00000000000..a1d56d1aa0b --- /dev/null +++ b/.github/Feature-Prompts/Net11/03-B05-VisualStylesMode-ImpactApi-Implementation.md @@ -0,0 +1,137 @@ +# Work Order: VisualStylesMode — Change-Impact API (Implementation) + +**Branch:** `Net11/Integration-2` (KlausLoeffelmann/winforms) +**Scope:** Code changes only. GitHub issue updates are handled by a separate work order +(`ApiReview-IssueUpdates.md`, Section 1) and run only after this implementation stands. +**Out of scope:** `HighPrecisionTimer` (separate work order), `SystemVisualSettings` (separate work order — +but see the composition note in item 4, the two cascades share a pattern). + +--- + +## Background + +The `VisualStylesMode` API is implemented on this branch. Two gaps motivate this change: + +1. **Correctness/ordering:** Base `Control.OnVisualStylesModeChanged` only invalidates, raises the + event, and cascades — no preferred-size cache invalidation, no layout transaction (unlike + `OnFontChanged`). Every metric-changing control reimplements a four-step protocol; `TextBoxBase` + currently runs `LayoutTransaction.DoLayoutIf(...)` **before** `UpdateStyles()`, so containers + measure against pre-transition metrics — a live mode switch clips/overlaps until controls are + recreated. +2. **Performance:** A top-level switch triggers one full parent layout per metric-affected control, + each against partially-transitioned state; repaint-only controls pay costs they don't need. + +Fix: a declarative impact model — derived controls state **what** changes, the base class owns +**how and in what order** to react, with one coalesced layout pass per container. + +--- + +## Changes + +### 1. `Control.cs` — visibility promotion + +`EffectiveVisualStylesMode`: `private protected` → `protected`. **Non-virtual** (deliberate — it is +the accessibility policy enforcement point; the customization hook is the already-virtual +`DefaultVisualStylesMode`). Extend XML docs: this is the value controls must honor for rendering; +reflects High Contrast (⇒ `Classic`) and `Disabled`; cross-reference `DefaultVisualStylesMode`. + +### 2. `Control.cs` — impact enum + virtual + +```csharp +protected enum VisualStylesModeChangeImpact +{ + None, // no rendering difference; skip all work + Repaint, // client-area rendering only; metrics identical + NonClientUpdate, // NC frame changes; style/frame update, no size change + Metrics // preferred size / layout metrics change; full invalidation + layout +} + +protected virtual VisualStylesModeChangeImpact GetVisualStylesModeChangeImpact( + VisualStylesMode oldMode, + VisualStylesMode newMode) + => VisualStylesModeChangeImpact.Repaint; +``` + +Parameters are **effective** modes. Document: implementations may consult instance state +(`Multiline`, `BorderStyle`, …) but the value must be stable for the duration of the change handling. + +### 3. `Control.cs` — setter early-out on effective equality + +In the `VisualStylesMode` setter and `OnParentVisualStylesModeChanged`: capture effective mode before +the change; if unchanged after, skip `OnVisualStylesModeChanged` entirely (no invalidate, no cascade +into that subtree). Preserve the existing shadowing behavior (child with explicit local value already +stops the ambient cascade) and add the effective-equality check on top. High Contrast active ⇒ raw +`Net11 → Latest` switch is a complete no-op. + +### 4. `Control.cs` — `OnVisualStylesModeChanged` becomes the dispatcher + +Preserve the disposal guard and public event raise. Then: + +- `impact = GetVisualStylesModeChangeImpact(oldEffective, newEffective)` — plumb old/new effective + values from the setter/cascade (least-invasive mechanism consistent with how `OnParentFontChanged` + plumbs state). +- `None` → raise event, skip rendering/layout work, still cascade (children's impact may differ). +- `Repaint` → `Invalidate()`. +- `NonClientUpdate` → `UpdateStyles()` + NC frame refresh (`SetWindowPos` + `FRAMECHANGED`, per the + branch's existing pattern), then `Invalidate()`. +- `Metrics` → in order: `CommonProperties.xClearPreferredSizeCache(this)` → `UpdateStyles()` / frame + refresh → layout deferred to the coalesced step: + +```csharp +if (ChildControls is { } children) +{ + using (new LayoutTransaction(this, this, PropertyNames.VisualStylesMode, resumeLayout: false)) + { + for (int i = 0; i < children.Count; i++) + { + children[i].OnParentVisualStylesModeChanged(e); + } + } +} + +LayoutTransaction.DoLayout(this, this, PropertyNames.VisualStylesMode); +``` + +Add `PropertyNames.VisualStylesMode` if missing. + +**Composition note:** the `SystemVisualSettings` work order introduces a structurally identical +parent→child cascade (`OnSystemVisualSettingsChanged`, modeled on `OnSystemColorsChanged`). Keep the +two cascades pattern-identical; a High Contrast toggle arriving via that path resolves here as an +effective-mode change and must take the same early-out/dispatch route — no duplicate handling. + +### 5. `TextBoxBase.cs` — adopt the model, delete the hand-rolled protocol + +- Override `GetVisualStylesModeChangeImpact`: crossing `Classic`/`Disabled` ↔ `>= Net11` ⇒ `Metrics` + (`PreferredHeight` selection and NC padding both change). Within `>= Net11` (`Net11 → Latest`) ⇒ + `Repaint` **unless** the branch's renderer shows metric differences between those modes — verify + against `PreferredHeightCore` and the NC padding tables; document the decision in XML remarks. +- Slim `OnVisualStylesModeChanged`: remove manual `xClearPreferredSizeCache` / `DoLayoutIf` / + `AdjustHeight` sequencing — base owns it. Keep control-specific work + (`_focusIndicatorRenderer?.Synchronize(...)`, `_triggerNewClientSizeRequest = false`), with the + ordering contract: latch reset **before** `base.OnVisualStylesModeChanged(e)` (which runs + `UpdateStyles()`); any residual `AdjustHeight` need goes **after** `base` — verify with the + live-switch repro (TableLayoutPanel, AutoSize rows, anchored single-line TextBoxes; runtime mode + switch; rows must resize without reopening the form). +- This closes the `DoLayoutIf(AutoSize: false)` hole: base-owned coalesced layout means + `AutoSize == false` TextBoxes no longer skip parent re-measurement. + +### 6. Documentation & tests + +- `docs/Net11Api_05_VisualStylesMode.HighRiskReview.md`: add the behavioral change (base + `OnVisualStylesModeChanged` now clears caches, updates styles, requests layout; overriders calling + `base` inherit this). If the branch's `WFCC`/`ComponentChange` infrastructure is present, annotate; + otherwise the high-risk doc entry suffices. +- `ControlTests.VisualStylesMode.cs`: + - effective-equality early-out (HC active ⇒ no event storm, no layout), + - default impact is `Repaint`, + - `Metrics` path clears preferred-size cache; exactly one layout per container for a multi-control + subtree switch (layout-count instrumentation or `LayoutEventArgs` assertion), + - `TextBoxBase` live-switch regression test: preferred-height change reflected in an AutoSize + `TableLayoutPanel` row without control recreation. + +## Constraints + +- No binary breaking changes (`protected` promotion + new `protected` members on unsealed public + class are additive). +- Do not change the `EffectiveVisualStylesMode` clamping logic. +- Match repo analyzers/style and the branch's XML-doc voice. diff --git a/.github/Feature-Prompts/Net11/Application.SystemTextAwareness/Application.SystemTextAwareness.md b/.github/Feature-Prompts/Net11/Application.SystemTextAwareness/Application.SystemTextAwareness.md new file mode 100644 index 00000000000..da7edcf854e --- /dev/null +++ b/.github/Feature-Prompts/Net11/Application.SystemTextAwareness/Application.SystemTextAwareness.md @@ -0,0 +1,180 @@ +# Task: Create a new API — `Application` system-text-size awareness — and the respective API proposal + +## What to do + +Create a **new API proposal / API review issue in the upstream `dotnet/winforms` repo** +(`origin` = my fork, `upstream` = the Microsoft repo where this lands). Write it per the +WinForms repo conventions and the relevant skills for authoring new-API issues. Apply the +skills; don't ask me for boilerplate. + +This proposal introduces **runtime awareness of the Windows Accessibility text-size +setting** at the `Application` level, plus a per-`Form` change notification. It is the +**foundation** proposal; a companion `TreeView.NodeLeading` proposal references this one. + +## Before you implement anything + +**Verify every premise below against current source before committing to the design.** +Verify, don't trust. If any premise is wrong, stop and tell me. Key files (VMR @ +`96982699e0dd8c046f397541dc0eb235ea8a4958`): + +- `src/winforms/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.ThreadContext.cs` +- `src/winforms/src/System.Windows.Forms/System/Windows/Forms/Application.ParkingWindow.cs` +- `src/runtime/src/libraries/Microsoft.Win32.SystemEvents/...` (SystemEvents / UserPreferenceCategory) + +## Rationale — surface and complete an existing partial implementation + +This is **not** a net-new feature; it **finishes a half-built one**. WinForms already reads +the Accessibility text-size setting, but only once and only for the default font: + +`ScaleHelper.ScaleToSystemTextSize(Font?)` reads +`HKEY_CURRENT_USER\Software\Microsoft\Accessibility` → value `TextScaleFactor` +(REG_DWORD, clamped 100–225), and returns the font scaled by `TextScaleFactor / 100` +(or `null` if 100, if the font `IsSystemFont`, or if the OS is < Windows 10 1507). It is +documented in-source as the **Settings → Display → Make Text Bigger** setting. + +The gaps: (1) the value is **never surfaced** to developers; (2) it is read **once**, at +default-font construction, and the app **never reacts** when the user changes the setting at +runtime; (3) there is **no notification** mechanism. This proposal fills those three gaps. + +## Three-knob disambiguation (MANDATORY callout — reviewers will conflate these) + +Windows surfaces three *different* sizing mechanisms; the Settings UI even puts two on one +page (`System → Display → Custom scaling` shows a "Custom scaling 100–500%" box AND a +"Text size" link). The proposal MUST state plainly which one it targets: + +1. **Display / Custom scaling (100–500%)** → this is **DPI**. Already handled by + `HighDpiMode` and the DPI events (`WM_DPICHANGED`, `Control.DpiChanged*`). **NOT** this + proposal. +2. **Accessibility → Text size (100–225%)** → registry `TextScaleFactor` under + `HKCU\Software\Microsoft\Accessibility`; WinRT `UISettings.TextScaleFactor` / + `TextScaleFactorChanged`. **THIS is the target.** Independent of DPI. +3. **Legacy pre-Win10 per-element text sizing** (title bars/menus) → **removed** in Windows + 10 1703; the Accessibility slider replaced it. Mentioned only to close the loop. + +State explicitly that `Application.SystemTextSize` reflects **#2 only**, and is orthogonal to +DPI (#1). + +## Proposed API + +### `Application` (process-static) + +- **`public static double SystemTextSize { get; }`** — the current Accessibility text-scale + factor (1.0–2.25; i.e. `TextScaleFactor / 100`). **Live getter** — re-reads the value, does + not cache, because it is a system setting that changes at runtime. Well-defined regardless + of whether any `Form` exists or how many UI threads are running (it is process-global). +- **`public static SystemTextSizeAwareness SystemTextSizeAwareness { get; set; }`** — the + mode. **Enum, not bool**, deliberately, to reserve room for a future `Automatic`: + - `Unaware` (default) — no notification raised; fully back-compatible, nothing changes. + - `Notify` — raise change notifications (see below); the app decides how to respond. + - *(reserved, NOT implemented now: `Automatic` — framework re-flows for you. Reserving the + enum slot now avoids a future breaking bool→enum change, the same lesson `HighDpiMode` + learned.)* +- **`public static event EventHandler? SystemTextSizeChanged`** — fires once per process when + the setting changes (only when awareness is `Notify`). + +### `Form` (instance) + +- **`public event EventHandler? SystemTextSizeChanged`** — instance event, raised on each + top-level `Form` when the setting changes. +- **`protected virtual void OnSystemTextSizeChanged(EventArgs e)`** — overridable, fires the + instance event via the `EventHandlerList` pattern (`Events[s_systemTextSizeChangedEvent]`). + +## The trigger architecture (the leak-critical part — get this exactly right) + +A naive design — a static `Application.SystemTextSizeChanged` that `Form`s/`Control`s +subscribe to — **leaks**: the static event strongly roots every subscriber, so no `Form` +that subscribes is ever collected. Avoid this by **mirroring the DPI architecture**, where +`Control`/`Form` learn of DPI changes from their **own `WndProc`** (`WM_DPICHANGED` → +`OnDpiChanged` → instance event via `EventHandlerList`), **not** from a static subscription. + +Verified facts that constrain the design: + +- **`WM_SETTINGCHANGE` is broadcast** (`HWND_BROADCAST`) and delivered directly to top-level + windows' `WndProc`s — it bypasses the thread message queue, so **`IMessageFilter` does NOT + see it.** Do not use a message filter. +- **The WinForms parking window is message-only** (`CreateParams.Parent = HWND_MESSAGE`). + Message-only windows are **excluded from broadcasts**, so the parking window **cannot** + receive `WM_SETTINGCHANGE`. Do not use it. +- **`Application` has no `MainForm`.** The main form lives on `ApplicationContext` (per-run, + per-UI-thread), can be `null` (tray/loop-only apps), and is mutable (splash→main handoff). + So the main form is **not** a reliable receiver. Do not anchor the app-level event to it. +- **`SystemEvents` already owns a hidden top-level broadcast-receiving window** (its + `.NET-BroadcastEventWindow`), and exposes `UserPreferenceChanged` with a + `UserPreferenceCategory` (the relevant value is `Accessibility`, which is **coarse** — it + covers any accessibility change, so you must re-read `TextScaleFactor` and diff to confirm + it was text-scale). + +**Resulting design:** + +- **App-level:** `Application` makes **one internal, process-lifetime** subscription to + `SystemEvents.UserPreferenceChanged`, filters `Category == Accessibility`, re-reads + `TextScaleFactor`, diffs against the cached value, and if changed raises the static + `SystemTextSizeChanged`. This is a single framework-static→framework-static link — it does + **not** root any user object, so it is **not** the leak hazard. Reuses the existing hidden + broadcast window; **no new HWND** required. +- **Form-level:** each top-level `Form` handles `WM_SETTINGCHANGE` in its **own `WndProc`** + (it is a broadcast — every top-level window receives it), re-reads + diffs, and raises its + **instance** `SystemTextSizeChanged` via `OnSystemTextSizeChanged`. `Form`s do **not** + subscribe to `Application` — no rooting, lifetime = the window. + +Caveat to document: there is **no dedicated `WM_TEXTSCALECHANGED`** message. Both paths must +recognize a *relevant* change by re-reading `TextScaleFactor` and comparing, not by the +message alone. + +## Aids vs. leave-it-to-the-user + +**Notify-only. No automatic re-layout / font-rescaling aids in this proposal.** Reasons: +text scale interacts with `AutoScaleMode`, anchored/docked layout, and explicitly-set fonts +in app-specific ways; a generic "scale all fonts by the factor" helper breaks more than it +fixes. The reserved `Automatic` enum value is exactly where such behavior would live later. +`Notify` gives the developer the factor and the event; they decide. (Consistent with the +companion `NodeLeading` proposal's conservative-default philosophy.) + +## Why this matters across controls (evidence — include the matrix) + +Multiple text-measuring controls derive item/row/tile extents from a `Font` that today only +reacts to the text-size setting once at startup (via `ScaleToSystemTextSize` on the default +font) and never again. So **any single cached height scalar is wrong the moment text size +changes at runtime** — which is the core argument for runtime awareness: + +- **ListBox / ComboBox** — have `MeasureItem` + `OwnerDrawVariable` (a real per-item measure + hatch) and `ItemHeight`. Their gap is the legacy default base calc + the missing runtime + text-scale reaction — i.e. exactly this proposal. +- **TreeView** — has neither `MeasureItem` nor a wrapped native height API; only a uniform + native item height. Worst-positioned for a managed fix; addressed by the companion + `TreeView.NodeLeading` proposal, which depends on this one. +- **ListView (Details)** — no `MeasureItem`, no native row-height message; row height is + comctl-computed from `SmallImageList` + control font, while per-item/subitem fonts are + honored via `NM_CUSTOMDRAW` (`CDRF_NEWFONT`). Userland workarounds (phantom `SmallImageList`; + `LVS_OWNERDRAWFIXED` + one-shot reflected `WM_MEASUREITEM`; "inflate control font / shrink + item fonts") each have holes (header leak, set-once, exhaustive per-item font setting, + owner-draw-all). **No clean complete userland solution exists** — strengthening the case + that text-size reaction belongs in the framework. + +## XML doc requirements + +- Document that `SystemTextSize` is the **Accessibility text-size** factor (Settings → + Display → Make Text Bigger), **not** DPI/display scaling, and is process-global / live. +- Document the `Unaware`/`Notify` semantics and that `Automatic` is reserved for future use. +- Document the no-rooting design note on the static event (so consumers understand instance + vs. static). + +## Open questions for review + +- Should `SystemTextSize` be `double` (1.0–2.25) or expose the raw int percent (100–225)? +- Behavior on OS < Windows 10 1507 (where `ScaleToSystemTextSize` no-ops): `SystemTextSize` + returns 1.0 and no events fire? +- Whether to also expose the value/event on `Application` only, leaving `Form` consumers to + use their own `WndProc` override — or provide the `Form` instance event as proposed + (recommended, for parity with the DPI event model). + +## Output + +The upstream issue per the skills: summary; the "complete a partial implementation" +rationale; the mandatory three-knob disambiguation; the proposed API; the leak-safe trigger +architecture with the four verified constraints (broadcast vs. IMessageFilter, message-only +parking window, no MainForm on Application, SystemEvents reuse); Notify-only stance; the +cross-control matrix; XML-doc requirements; open questions. Flag anything the source +contradicts. diff --git a/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md new file mode 100644 index 00000000000..09894f9d23f --- /dev/null +++ b/.github/Feature-Prompts/Net11/Application/net11-VisualStylesMode/TextBoxBase-VisualStyles-WorkOrder.md @@ -0,0 +1,137 @@ +# Work Order — Port `TextBoxBase` NC-Painting + `VisualStylesMode` Chrome onto `VisualStylesNet11` + +**Target branch:** `KlausLoeffelmann/winforms` → `VisualStylesNet11` (current-`main`-based; modern path layout, no `/src/src/` doubling; `TextBoxBase.cs` ≈ 2130 lines). + +**Source of the original implementation (pinned):** `KlausLoeffelmann/winforms` @ `cf32e9c4efeba9d77e1a14025a8590b104e3c705` (old `/src/System.Windows.Forms/src/...` layout; `TextBoxBase.cs` ≈ 2562 lines). Relevant files: +- `.../Controls/TextBox/TextBoxBase.cs` — NC paint, NC calc, focus invalidation, `GetVisualStylesPadding`, helpers. +- `.../Controls/TextBox/TextBoxBase.NonClientBitmapCache.cs` — the offscreen cache class. +- `.../Controls/TextBox/TextBox.cs` — derived-level overrides (`CreateParams`, `WndProc`, `OnBackColorChanged`, `PRF_NONCLIENT` path). + +**Standing approval:** API review board sign-off from the .NET 9 cycle is still valid; DRI owns the call. This is a port + cleanup, **not** a redesign. Do not invent new public API beyond what the original exposed plus the `Padding` unshadowing called out below. + +**House style (apply throughout):** namespaces globally imported (no `using`/`imports` noise); C# 13/14; NRTs on; `var` only for long type names or when the type is obvious from the RHS, explicit type names for primitives; blank line between a new block and a following `return`; pattern matching / `is` / `and` / `or` / switch expressions preferred; collection expressions (`List x = [];`); expression-bodied members for single-line methods/read-only props formatted with the `=>` on the next line, 1-space-indented. Generate XML doc comments; use ``/``. + +--- + +## PROMPT 1 — Carry-over / Port + +> **Role:** You are porting a working-but-imperfect feature across a 3-year gap in the surrounding file. Faithfully reproduce the *mechanism*; do not improve, simplify, or "modernize" the algorithm except where this document explicitly says to. Where the surrounding `VisualStylesNet11` code has moved on, rebase onto the new shape rather than pasting. + +**Task.** Bring the non-client (NC) painting feature for `TextBoxBase` (and the `TextBox` derived touchpoints) from the pinned SHA `cf32e9c4…` onto `VisualStylesNet11`. The target branch currently has **none** of this work (no `WmNcPaint`/`WmNcCalcSize`/`OnNcPaint`/`GetVisualStylesPadding`/`NonClientBitmapCache`), but it **does** have the `VisualStylesMode` property infrastructure referenced in doc comments — wire into that, don't redeclare it. + +**Steps, in order:** + +1. **Fetch and diff the three source files** at SHA `cf32e9c4…` against their `VisualStylesNet11` counterparts. Produce a short inventory of every member you intend to add or modify, grouped by file, before writing any code. + +2. **Port `TextBoxBase.cs` members:** + - `WmNcPaint` / `OnNcPaint` (offscreen bitmap fill → AA rounded/single chrome → blit; `GetWindowDC`/`ReleaseDC` in `finally`). + - `WmNcCalcSize` (carves the padding band from `NCCALCSIZE_PARAMS->rgrc[0]`; gated on the `_triggerNewClientSizeRequest` latch). + - `InitializeClientArea` (the one-shot `SetWindowPos(SWP_FRAMECHANGED|NOMOVE|NOSIZE|NOZORDER|NOACTIVATE)` that provokes the single NC-calc). + - `GetVisualStylesPadding` / `GetScrollBarPadding` and the `VisualStyles{Fixed3D|FixedSingle|NoBorder}BorderPadding`, `BorderThickness` consts. + - The `WM_NCCALCSIZE` / `WM_NCPAINT` cases in `WndProc`. + - `OnGotFocus` / `OnLostFocus` / `OnSizeChanged` NC-frame invalidation (`RedrawWindow` with `RDW_FRAME|RDW_INVALIDATE`). + - `PreferredHeight` split (`PreferredHeightClassic` vs `PreferredHeightCore` selected by `VisualStylesMode`). + - Reconcile `GetPreferredSizeCore` with the modern branch already present on target. + +3. **Port `TextBoxBase.NonClientBitmapCache.cs`** — BUT this is a **decision point**, see Step 6. + +4. **Reconcile `TextBox.cs` (derived):** the target already has `CreateParams`, `WndProc`, `OnBackColorChanged` (special-casing `Fixed3D`), `OnGotFocus`, and a `WM_PRINTCLIENT`/`PRF_NONCLIENT` + `Application.RenderWithVisualStyles` path. Merge the NC behavior so the derived overrides cooperate with the new base NC painting (no double border draw, no fighting the `PRF_NONCLIENT` path). Call out every conflict you resolve. + +5. **Unshadow `Padding`.** On target it is still the neutered shadow (`[Browsable(false)]`, `EditorBrowsableState.Never`, `DesignerSerializationVisibility.Hidden`, `get/set => base.Padding`). Make it a real, browsable, serializable property that feeds the NC band via `GetVisualStylesPadding`. Preserve classic-mode behavior when `VisualStylesMode` is `Disabled`/`Classic`. Note the designer-serialization and back-compat implications in the PR description. + +6. **Retarget the rounded-rectangle helpers to the framework.** The original called fork-local `FillRoundedRectangle`/`DrawRoundedRectangle`. These now ship as **`System.Drawing.Graphics` instance methods** (`(Pen, Rectangle, Size)` + `RectangleF`/`SizeF` overloads; landed in the .NET 9 wave, current on target). **Delete the fork-local helpers and call the shipped methods.** ⚠️ Verify a **`FillRoundedRectangle(Brush, …)`** overload actually exists on target — the public ref only enumerates the `Draw`(`Pen`) overloads. If the `Fill`/`Brush` overload is absent, STOP and flag it; do not re-add a private helper without surfacing the gap. + +7. **Cache → `BufferedGraphics` (DECIDED — implement, do not re-evaluate).** The original used a hand-rolled per-instance `NonClientBitmapCache` (`CreateCompatibleBitmap` + `Image.FromHbitmap` + manual `DeleteObject`, `EnsureSize` realloc). Jeremy's late "use the existing cached bitmap" is confirmed to mean WinForms' own **`BufferedGraphics`/`BufferedGraphicsContext`** (the engine behind `OptimizedDoubleBuffer`; in `System.Drawing.Common` since .NET Framework 2.0, present on target). It is **not** `System.Drawing.Imaging.CachedBitmap` — that type is a read-only, device-dependent, blit-only frozen copy (no `Graphics`, translation-only, dies on bit-depth change) and cannot be a render target. **Delete `NonClientBitmapCache` entirely** (and its file `TextBoxBase.NonClientBitmapCache.cs`) and the `_cachedBitmap` field; replace with the shared buffer. Exact wiring: + - Inside `OnNcPaint`, get the shared context and allocate the buffer against the **window-DC `Graphics` already created in `WmNcPaint`**, sized to the window `bounds`: + `BufferedGraphicsContext context = BufferedGraphicsManager.Current;` + `using BufferedGraphics buffer = context.Allocate(graphics, bounds);` + `Graphics offscreenGraphics = buffer.Graphics;` + - **Do NOT `using`/dispose `buffer.Graphics`** — the `buffer` owns it; `using` the **buffer** only. (The original `using`-disposed its `GetNewGraphics()` because it owned that `Graphics`; that ownership is now the buffer's.) + - **All drawing into `offscreenGraphics` is unchanged** — the `FillRectangle(parentBackgroundBrush…)` corner-fill, the `BorderStyle` switch, the focus line: byte-for-byte identical, just a different `Graphics` target. + - **`ExcludeClip(clientBounds)` stays on the *target* `graphics`** (the window-DC one), exactly where it is now, set *before* the buffer draws. It governs where `Render()` may blit, protecting the client area — unchanged semantics. + - Replace the final blit `graphics.DrawImageUnscaled(offscreenBitmap, Point.Empty);` with **`buffer.Render();`** (no argument — it blits to the `graphics` captured at `Allocate` time). + - While here, fix the pre-existing **double-dispose** in `WmNcPaint`: it has both `using Graphics graphics = …` and an explicit `graphics.Dispose()` in `finally`. Drop the explicit `Dispose()`; keep the `using` (or keep explicit and drop `using` — one, not both). + - **Rationale to record in PR notes:** WinForms paints NC serially (one HWND at a time on the UI thread), so a single shared buffer suffices for any number of controls; the per-instance cache kept N resident GDI bitmaps to serve a one-deep queue. Steady-state allocation is unchanged (zero — shared buffer is reused when size fits); resident GDI memory drops from N× to 1×. The only cost is buffer-resize churn if controls of *wildly varying* sizes paint in a grow/shrink-alternating order — see smoke scenario 7 instrumentation. + +8. **Carve clamp + chrome degradation (settled design — implement exactly as stated, do NOT add a minimum size).** The original `WmNcCalcSize` does raw subtraction on `rgrc[0]` with **no clamp**, so a large `Padding` (made worse because `GetVisualStylesPadding(true)` *adds* the live scrollbar allowance from `GetScrollBarPadding` on top of the border padding) can drive the carved client rect to **zero or inverted**. Two separate fixes, and they are deliberately *not* a `MinimumSize`: + - **(8a) Never-invert clamp in `WmNcCalcSize`.** Floor each carved extent so the client rect can never invert: after the four adjustments, ensure `bottom >= top` and `right >= left` (e.g. clamp so the resulting client width/height is `Math.Max(0, …)`). A 0–1px client area is **acceptable and intended** — shipping multiline `TextBox` already shrinks to ~1px with scrollbars present, and we match that exactly. **Do NOT introduce a min-height/`MinimumSize`**, and do NOT make sizing behavior differ by `VisualStylesMode` (that would fracture the appearance-only contract of the opt-in). The clamp only prevents *underflow past zero*, which raw subtraction does and plain shrinking does not. + - **(8b) Paint-time chrome degradation in `OnNcPaint`.** The rounded `Fixed3D` chrome (15px radius) renders as a broken lozenge below roughly `2 × cornerRadius + BorderThickness` in height. When the available band/height is below that viable threshold, **fall back to the original/simple chrome render** (flat or single-style border) instead of the rounded path. This is a *rendering* fallback only — it does not change size or layout. Rationale on record: if the box is so small there's no usable client area, the control isn't usable anyway, so graceful visual degradation (not a size floor) is the correct response. + +9. **Build** `System.Windows.Forms` for the target TFM. Resolve all errors. Do not suppress new analyzer warnings without a one-line justification each. + +**Deliverable:** a single commit (or tight series) on `VisualStylesNet11` plus a PR description that lists: members added/modified per file, every `TextBox.cs` conflict resolved, confirmation that `NonClientBitmapCache` was removed and replaced by `BufferedGraphics` (Step 7) with the resize-churn rationale, the clamp/degradation (Step 8) confirmed as render-only with no size minimum, the `Padding` unshadowing implications, and any flagged gaps (Step 6 `Fill` overload). + +--- + +## PROMPT 2 — Critical Review (run AFTER Prompt 1, BEFORE smoke test) + +> **Role:** Adversarial reviewer. The author wants the issues a sharp WinForms maintainer would catch, not reassurance. Cite file + line for every finding. Classify each as **MUST-FIX**, **SHOULD-FIX**, or **PRESERVE (do not 'improve')**. + +Audit the ported code against this checklist. For each item, state the finding and the exact location. + +1. **DPI scaling of the corner radius.** The original hardcodes `const int cornerRadius = 15` and `BorderThickness = 1` in device-independent units, then uses them inside a DPI-scaled NC band, while `GetVisualStylesPadding` *does* take a DPI path (`_deviceDpi`). Confirm whether the radius/thickness now scale Per-Monitor-V2. If not → **MUST-FIX** (corners look proportionally too tight at 150/200%). + +2. **Full-frame NC repaint vs. partial `hrgnClip`.** `WmNcPaint` ignores the wParam clip region and repaints the whole frame. This is the **intended** fix for the offscreen-restore "dirty corners" artifact — verify it's preserved. But confirm `base.WndProc(ref m)` is still invoked with the original message and isn't double-painting the native border under the custom chrome. Classify the "ignore clip" behavior as **PRESERVE**. + +3. **Corner-blend source = `Parent?.BackColor ?? BackColor`.** This is the known ceiling: corners blend against the parent's flat back color, so they mismatch over a gradient/image/Mica/sibling. For the common case (solid form/panel) it's correct. **PRESERVE** — do not let it be "improved" into a fake general-case solution. Note it as a documented limitation only. + +4. **`WM_NCCALCSIZE` ↔ `Padding` round-trip + underflow.** Verify the band carved in `WmNcCalcSize` matches what `GetVisualStylesPadding(true)` reports and what `GetPreferredSizeCore`/`SizeFromClientSize` assume, for all three `BorderStyle` values × `Multiline` × scrollbars. Off-by-one here clips text or the caret. **Additionally** confirm the never-invert clamp (Prompt 1 Step 8a) is present and correct: with large `Padding` on a small multiline box *with both scrollbars*, the carved client rect must floor at 0, never invert. Remember the threshold is **border padding + live scrollbar padding** (`GetScrollBarPadding` reads `WS_HSCROLL`/`WS_VSCROLL`), so underflow hits sooner than the `Padding` value alone implies. Classify "0–1px client area is allowed, no min-size" as **PRESERVE** — do not let a reviewer or the agent add a `MinimumSize` floor. + +4b. **Chrome degradation below viable height.** Confirm `OnNcPaint` (Prompt 1 Step 8b) falls back to simple/flat chrome when height < ≈`2 × cornerRadius + BorderThickness`, instead of drawing a corrupted rounded rect. This is **render-only**; assert it does **not** alter size, layout, or `ClientSize`. Verify the fallback path itself is DPI-correct (the threshold scales with the radius, which per item 1 must scale). + +5. **`BorderStyle` fork + native edge suppression.** `Fixed3D` → rounded chrome, `FixedSingle` → single + underline, `None` → fill. Confirm the native `WS_EX_CLIENTEDGE`/`WS_BORDER` from `CreateParams` is suppressed when NC chrome is active, so the native edge isn't drawn under the custom one. + +6. **`VisualStylesMode` gating is total.** Every NC entry point (`WmNcPaint`, `WmNcCalcSize`, `InitializeClientArea`, the focus/size invalidations) must early-out to byte-for-byte classic behavior when `VisualStylesMode` is `Disabled`/`Classic`. One missing guard = a back-compat regression. Note the original's `OnLostFocus` was **missing** the guard that `OnGotFocus` had — verify the port fixed this asymmetry. + +7. **DC / GDI lifetime.** `GetWindowDC`→`ReleaseDC` in `finally`, and `Graphics.FromHdc`+`Dispose` ordering: correct **only** because `FromHdc` doesn't own the DC. **PRESERVE** — flag any "tidy into a single `using`" as a regression. Confirm the `WmNcPaint` **double-dispose** was fixed (it had both `using Graphics` and an explicit `graphics.Dispose()`). For the `BufferedGraphics` swap (Prompt 1 Step 7): verify the **buffer** is `using`-scoped but **`buffer.Graphics` is NOT separately disposed**; verify `Allocate` targets the window-DC `graphics` and `Render()` is called with no argument; confirm `NonClientBitmapCache` and the `_cachedBitmap` field are fully removed with no dangling refs. Audit for any HBITMAP/HDC leak in the new path (there should be none — the buffer owns it). + +8. **DPI-change without handle recreate.** `_triggerNewClientSizeRequest` is a one-shot latch reset on handle recreate. Does a DPI change that does *not* recreate the handle re-carve the band with new padding? If the band can go stale on monitor move → **MUST-FIX** or at least an explicit tracked issue. + +9. **Caret / IME / selection repaint.** The native `EDIT` invalidates aggressively. Confirm NC chrome doesn't go stale on caret blink/IME composition, and conversely that NC isn't thrashing-repainting on every caret tick. (Author never confirmed this was clean in the original.) + +10. **`TextBox.cs` derived reconciliation.** Verify the derived `WndProc`, `OnBackColorChanged` `Fixed3D` special-case, and the `PRF_NONCLIENT`/`Application.RenderWithVisualStyles` path don't conflict with base NC painting (double draw, wrong-mode paint). + +11. **Allocation churn.** Brushes/pens use cached scopes (`GetCachedSolidBrushScope`/`GetCachedPenScope`) — good; confirm preserved. Confirm the offscreen surface isn't reallocated per paint (only on size change). + +**Deliverable:** a findings list (file:line, severity, recommendation). MUST-FIX items get fixed in this pass; SHOULD-FIX either fixed or filed; PRESERVE items annotated in code with a brief `// Intentional:` comment so the next reader doesn't "fix" them. + +--- + +## PROMPT 3 — Smoke Test Harness + +> *("Smoke test" = the shallow "does it power on without catching fire" pass — from hardware bring-up, where first power-on literally checked for smoke — run before any deep/perf testing. Goal here: broad coverage that it comes up and behaves on the obvious axes, with the two known-fragile cases as explicit named tests.)* + +**Task.** Build a throwaway WinForms test app (separate project, not shipped) that exercises the ported feature across its permutation space and **specifically reproduces the two regressions this feature is prone to.** + +**Permutation grid** — generate a form populated with `TextBox`es (and at least one `RichTextBox`, since `TextBoxBase` is the shared base) covering the cross-product of: +- `BorderStyle`: `None` × `FixedSingle` × `Fixed3D` +- `Multiline`: `false` × `true` (+ `WordWrap` on/off for multiline) +- `Padding`: `Empty` × asymmetric (e.g. `2,6,2,6`) × large (`12`) +- Scrollbars: none × vertical × both +- `VisualStylesMode`: `Disabled`/`Classic` (must look exactly like today) × `Net10`+ (new chrome) +- Focused vs unfocused (drive focus programmatically to capture the adorner/underline) + +**Named, must-pass scenarios (the ones that silently regress):** + +1. **Offscreen-restore ("dirty corners").** Move the window partly off the left/top screen edge, then back. Assert the NC corner regions are repainted clean (no stale pixels). This is the artifact that drove the full-frame-repaint design. Automate the drag via `SetWindowPos`/`MoveWindow`; capture before/after. + +2. **Partial NC invalidation.** Trigger a partial `WM_NCPAINT` (e.g. overlap then reveal a sliver of the frame) and assert the whole chrome is coherent, not just the revealed strip. + +3. **Per-Monitor-V2 DPI.** Run DPI-aware; move forms between a 100% and a 150%/200% monitor (or fake via `LogicalToDeviceUnits`/DPI-changed messages). Assert corner radius, border thickness, and padding band all scale; assert no clipped text/caret. + +4. **Classic-mode parity.** With `VisualStylesMode = Disabled`, assert the control is pixel-identical to baseline `main` (native edge, no custom NC). A regression here is the back-compat line breaking. + +5. **`BorderStyle` switch at runtime** (`Fixed3D`↔`FixedSingle`↔`None`) and **`Padding` change at runtime** — assert the band re-carves and chrome redraws without artifacts (exercises the `_triggerNewClientSizeRequest` reset path). + +6. **Focus transitions** — tab through the grid; assert the focus underline (single) / 3D focus line (Fixed3D, shortened to clear the corner curve) appears/clears correctly. + +7. **Shrink-to-collapse (clamp + degradation).** Take a multiline `Fixed3D` box with large `Padding` (e.g. `12`) and **both** scrollbars visible, then programmatically drag/resize its height down toward 1px. Assert: (a) **no crash / no inverted client rect** handed to the native `EDIT` — the carve floors at 0 (Step 8a); (b) below ≈`2 × cornerRadius + thickness` the chrome **falls back to flat/simple render** rather than drawing a corrupted lozenge (Step 8b); (c) the control **still collapses** to ~1px exactly like classic multiline — assert it is **not** held open by any min-size (regression if a floor appeared). Repeat at 150%/200% DPI so the degradation threshold is verified scaled, not fixed at 96-dpi pixels. + +8. **BufferedGraphics allocation churn (perf sanity).** Build two forms: (a) **40 same-size** textboxes, (b) **40 wildly varying-size** textboxes (mix tiny and large), all `VisualStylesMode ≥ Net10`. Force a full repaint storm (invalidate all NC frames repeatedly; resize the form to cascade re-layout). Instrument the shared buffer: wrap/observe `BufferedGraphicsManager.Current` and count actual **bitmap (re)allocations** vs. reuses across the storm. Assert: case (a) allocates the buffer ≈once then reuses (steady-state alloc ≈ 0); case (b) may reallocate on grow but must **not** allocate-per-paint. Log alloc count per case. This empirically confirms the shared-buffer reasoning from Prompt 1 Step 7 and catches any accidental per-paint allocation regression. + +**Harness mechanics:** +- A "capture all" button that screenshots each form to disk per `VisualStylesMode`, for eyeball diffing classic-vs-modern and pre-vs-post-DPI. +- A console/log line per assertion (pass/fail) so it can run semi-automated. +- Keep it dependency-light: raw WinForms + `SetWindowPos`/`RedrawWindow` P/Invoke for the offscreen and invalidation drivers. + +**Deliverable:** the test project + a one-screen README naming the six scenarios and how to run them, plus a results log from one full run on the porter's machine (note DPI of monitors used). diff --git a/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md new file mode 100644 index 00000000000..ce3ad461cab --- /dev/null +++ b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md @@ -0,0 +1,120 @@ +# Copilot Prompt 1 — Author the WinForms API Suggestion + +## Your task + +Write a complete API suggestion for the `dotnet/winforms` repository, ready to be filed +as a GitHub issue with the `api-suggestion` label. The issue covers two related features +for reducing visible intermediate states while WinForms applications update their UI: + +1. Painting suspension with optional layout suspension across a control tree. +2. Deferred top-level form reveal. + +Use these issue sections: + +- `## Rationale` +- `## API Proposal` +- `## API Usage` +- `## Alternative Designs` +- `## Risks` +- `## Will this feature affect UI controls?` +- `### Status Checklist` + +## Sub-feature A — painting and layout suspension + +### Settled API surface + +```csharp +namespace System.Windows.Forms; + +public interface ISupportSuspendPainting +{ + void BeginSuspendPainting(); + void EndSuspendPainting(); +} + +public enum LayoutSuspendTraversal +{ + None = 0, + TopLevelOnly = 1, + Traverse = 2, +} + +public sealed class SuspendPaintingScope : IDisposable +{ + public SuspendPaintingScope(ISupportSuspendPainting? target); + public void Dispose(); +} + +public static class ControlMutationExtensions +{ + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); +} +``` + +- `Control` implements `ISupportSuspendPainting` explicitly and exposes protected virtual + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` route painting + suspension through their existing `BeginUpdate` / `EndUpdate` paths. +- `None` suspends painting only. +- `TopLevelOnly` suspends layout on the target `Control`. +- `Traverse` suspends layout on the target and all existing descendants. +- The predicate is evaluated for the target and every descendant. A `false` result skips + that node but does not prune traversal. +- Selected nodes are suspended even when they currently have no children. +- Layout-aware overloads require the target to derive from `Control`. +- The scope is a sealed class so it can span `await`. + +### Design considerations + +- Snapshot the selected controls when the scope starts. +- Suspend layout root-to-leaf and resume it deepest-first. +- Resume layout before ending painting so recursive invalidation occurs after layout. +- Keep disposal idempotent and preserve nesting through the existing ref counts. +- Explain that controls added after the snapshot are covered by their parent's suspended + layout but do not receive an independently balanced suspension. +- Discuss traversal cost for large control trees and exceptions thrown by predicates. +- Include invalid and non-`Control` target behavior in the proposal. + +## Sub-feature B — deferred form reveal + +Use the current `FormRevealMode` design from the tracked API proposal: + +```csharp +namespace System.Windows.Forms; + +public enum FormRevealMode +{ + Inherit = -1, + Classic = 0, + Deferred = 1, +} + +public partial class Form +{ + public virtual FormRevealMode FormRevealMode { get; set; } +} + +public partial class Application +{ + public static FormRevealMode DefaultFormRevealMode { get; } + public static void SetDefaultFormRevealMode(FormRevealMode mode); + public static bool IsFormRevealDeferred { get; } +} +``` + +Describe DWM cloaking, dark-mode-aware default resolution, designer serialization, +top-level-window limitations, and conservative fallback behavior on unsupported systems. + +## Filing instruction + +Produce a complete proposal rather than an implementation plan. Clearly distinguish fixed +API shape from implementation choices that remain open for review. diff --git a/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md new file mode 100644 index 00000000000..35d765ad3ce --- /dev/null +++ b/.github/Feature-Prompts/Net11/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md @@ -0,0 +1,76 @@ +# Copilot Prompt 2 — Implement the flicker-free UI mutation APIs + +## Prerequisite + +Read the current upstream API suggestion before implementation. Treat its latest +`API Proposal` section as the public contract and report any conflict instead of silently +choosing a different shape. + +## Scope + +### A — painting suspension with optional layout traversal + +- Implement `ISupportSuspendPainting` on `Control` with ref-counted + `BeginSuspendPaintingCore` / `EndSuspendPaintingCore` hooks. +- Route `ListView`, `ListBox`, `ComboBox`, `TreeView`, and `RichTextBox` through their + existing `BeginUpdate` / `EndUpdate` mechanisms. +- Keep `SuspendPaintingScope` as a sealed, idempotent `IDisposable` class so it can span + `await`. +- Provide these extension methods: + + ```csharp + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal); + + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter); + ``` + +- `LayoutSuspendTraversal` has `None`, `TopLevelOnly`, and `Traverse`. +- Snapshot selected controls before suspension. Suspend root-to-leaf and resume + deepest-first. +- The predicate is evaluated for the target and every descendant. A `false` result skips + suspension for that node without pruning its descendants. +- Suspend selected controls even when they currently have no children. +- Validate layout-aware calls before beginning painting. A non-`Control` target is invalid. +- Resume all selected layout scopes before ending painting so the recursive invalidation + occurs after layout. + +### B — deferred form reveal + +- Implement the approved `FormRevealMode`, `Form.FormRevealMode`, and + `Application` configuration APIs. +- Preserve classic behavior when deferred reveal is not active. +- Cloak eligible top-level form handles and uncloak according to the timing strategy in the + proposal. +- Keep unsupported DWM and window configurations inert and safe. + +## Engineering requirements + +- Match the current repository language version, nullable annotations, code style, and + interop conventions. +- Add XML documentation for every public or protected API. +- Update `PublicAPI.Unshipped.txt`. +- Keep state lazy where existing `Control` property-store patterns apply. +- Do not change existing public `BeginUpdate` / `EndUpdate` behavior. + +## Tests + +- Painting ref-count balance, nesting, idempotent disposal, handle creation, and handle + recreation. +- `None`, `TopLevelOnly`, and `Traverse` layout selection. +- Predicate selection, continued traversal after a rejected node, and empty containers. +- Deepest-first layout resume and recursive invalidation after layout. +- Null, invalid-enum, and non-`Control` target failures without partial suspension. +- Existing native update paths for the selected built-in controls. +- Classic/deferred form reveal behavior and unsupported-OS fallback. + +## Deliverable + +Provide production code, focused tests, updated API tracking, and an updated API proposal. +Call out any behavior that the implementation proves unsafe or unnecessarily costly. diff --git a/.github/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..4deb770ce1b 100644 --- a/Winforms.sln +++ b/Winforms.sln @@ -197,9 +197,6 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.Private.Windows.GdiPlus", "src\System.Private.Windows.GdiPlus\System.Private.Windows.GdiPlus.csproj", "{442C867C-51C0-8CE5-F067-DF065008E3DA}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Copilot", "Copilot", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" - ProjectSection(SolutionItems) = preProject - .github\copilot-instructions.md = .github\copilot-instructions.md - EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GDI", "GDI", "{D619FF8C-D99A-48AB-B16B-2F0E819B46D5}" ProjectSection(SolutionItems) = preProject @@ -218,6 +215,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 +1192,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/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/Create-Github-API-Proposal-Prompt.md new file mode 100644 index 00000000000..ce3ad461cab --- /dev/null +++ b/docs/Feature-Prompts/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/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md new file mode 100644 index 00000000000..35d765ad3ce --- /dev/null +++ b/docs/Feature-Prompts/SuspendRelocationPaintingFlashPreventing/SuspendRelocationAndPainting-API-Feature-Prompt.md @@ -0,0 +1,76 @@ +# Copilot Prompt 2 — Implement the flicker-free UI mutation APIs + +## Prerequisite + +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/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md new file mode 100644 index 00000000000..0150a26b9a5 --- /dev/null +++ b/docs/Net11Api_03_ImproveControlRendering.HighRiskReview.md @@ -0,0 +1,23 @@ +# Net11Api_03 ImproveControlRendering High-Risk Review + +This document records a review finding that needs native lifecycle investigation before it can be patched safely. + +## Deferred reveal is not activated during initial display + +Reviewed merge base: `8b618e7f5` +Reviewed branch tip: `1ee2b58a6` + +The deferred-reveal implementation attempts to cloak a form from `OnHandleCreated`, while `ShouldUseDeferredAppearanceCloak` requires both `IsHandleCreated` and `Visible`. During `Show`, `ShowDialog`, and `Application.Run(form)`, the handle is created while evaluating `HWND`, before `ShowWindow` makes the form visible. The visibility state changes later while processing `WM_SHOWWINDOW`, and there is no subsequent cloak attempt. The intended initial-display cloak therefore does not activate on the normal display path. + +A safe correction requires selecting a one-shot point after handle creation but before the first compositor presentation. Moving the operation into `WM_SHOWWINDOW` could cloak later hide/show cycles, while removing the visibility check could cloak hidden forms whose handles are created for unrelated reasons. + +Before implementation, document and verify the state timeline for: + +- `Show`, `ShowDialog`, and `Application.Run`; +- hidden forms with pre-created handles; +- handle recreation; +- hide/show cycles; +- owned forms and splash screens; +- DWM composition or cloak-call failure. + +The selected design should define a one-shot invariant and explicitly reject stale state after handle recreation. Native verification should inspect the DWM cloak state before first paint and after reveal. Automated tests should cover all display paths and preserve a clear rollback path if compositor timing differs across supported Windows versions. diff --git a/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md b/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md new file mode 100644 index 00000000000..b3cb075a1a1 --- /dev/null +++ b/docs/Net11Api_04_KioskManagerComponent.HighRiskReview.md @@ -0,0 +1,38 @@ +# Net11Api_04 KioskModeManager High-Risk Review + +This document records review findings that require an API or lifecycle design decision before they can be patched safely. + +## FullScreen requested state and actual state differ + +`KioskModeManager.FullScreen` currently returns `true` when fullscreen has been requested but no target form is available. `FullScreenChanged`, however, is raised only when the resolved form actually enters or exits fullscreen. This creates two observable state models: + +- The property reports requested state through `_pendingFullScreen || _isFullScreen`. +- The event reports actual window state through changes to `_isFullScreen`. + +Consequently, setting `FullScreen = true` without a resolved form changes the property without raising the event. When a form later becomes available, the event is raised even though the property value remains `true`. + +Before changing this behavior, decide whether `FullScreen` represents requested state or actual window state. The decision must cover: + +- initialization and delayed parenting; +- missing, replaced, reparented, and disposed container controls; +- event ordering and two-way data binding; +- `ToggleFullScreen` behavior while a request is pending; +- disposal and failed fullscreen transitions; +- compatibility for applications already observing the property or event. + +Tests should define the complete transition matrix for requested, pending, entered, exited, reparented, and disposed states. + +## Session-notification registration is not retried + +`KioskModeFormObserver.RegisterSessionNotifications` records a failed `WTSRegisterSessionNotification` call as `false` and retries only after form handle recreation. During Windows startup, registration can fail with `RPC_S_INVALID_BINDING` before `Global\TermSrvReadyEvent` is signaled. A kiosk application started during that interval can therefore miss session notifications for the lifetime of its form handle. + +A safe fix needs a non-blocking retry design that specifies: + +- which Win32 errors are retryable and which are terminal; +- how waiting for `Global\TermSrvReadyEvent` is cancelled; +- how completion is marshalled to the form's UI thread; +- how stale callbacks are rejected after handle recreation; +- how registration and unregistration remain paired; +- how disposal races with an outstanding wait. + +Tests should cover service-not-ready startup, successful retry, handle recreation during the wait, and disposal before the wait completes. diff --git a/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/BuildAssist/BuildAssist.msbuildproj b/src/BuildAssist/BuildAssist.msbuildproj index 2e188f76ff6..400c78f44e6 100644 --- a/src/BuildAssist/BuildAssist.msbuildproj +++ b/src/BuildAssist/BuildAssist.msbuildproj @@ -37,7 +37,7 @@ @@ -32,6 +36,18 @@ Namespace Microsoft.VisualBasic.ApplicationServices ''' Public Property ColorMode As SystemColorMode + ''' + ''' Setting this property inside the event handler determines the default + ''' for newly created top-level forms. + ''' + Public Property FormRevealMode As FormRevealMode + + ''' + ''' Setting this property inside the event handler 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..4ed4e27e212 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,12 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' Note: We aim to expose this to the App Designer in later runtime/VS versions. Private _colorMode As SystemColorMode = SystemColorMode.Classic + ' The FormRevealMode the user assigned to the ApplyApplicationsDefault event. + Private _formRevealMode As FormRevealMode = FormRevealMode.Classic + + ' 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 +204,39 @@ Namespace Microsoft.VisualBasic.ApplicationServices End Set End Property + ''' + ''' Gets or sets the for the Application. + ''' + ''' + ''' The that newly created top-level forms use by + ''' default. + ''' + + Protected Property FormRevealMode As FormRevealMode + Get + Return _formRevealMode + End Get + Set(value As FormRevealMode) + _formRevealMode = value + End Set + End Property + + ''' + ''' 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. ''' @@ -734,7 +771,7 @@ Namespace Microsoft.VisualBasic.ApplicationServices ' in a derived class and setting `MyBase.MinimumSplashScreenDisplayTime` there. ' We are picking this (probably) changed value up, and pass it to the ApplyDefaultsEvents ' where it could be modified (again). So event wins over Override over default value (2 seconds). - ' b) We feed the defaults for HighDpiMode, ColorMode, VisualStylesMode to the EventArgs. + ' b) We feed the defaults for HighDpiMode, ColorMode, FormRevealMode to the EventArgs. ' With the introduction of the HighDpiMode property, we changed Project System the chance to reflect ' those default values in the App Designer UI and have it code-generated based on a modified ' Application.myapp, which would result it to be set in the derived constructor. @@ -746,7 +783,9 @@ Namespace Microsoft.VisualBasic.ApplicationServices Dim applicationDefaultsEventArgs As New ApplyApplicationDefaultsEventArgs( MinimumSplashScreenDisplayTime, HighDpiMode, - ColorMode) With + ColorMode, + FormRevealMode, + VisualStylesMode) With { .MinimumSplashScreenDisplayTime = MinimumSplashScreenDisplayTime } @@ -765,6 +804,8 @@ Namespace Microsoft.VisualBasic.ApplicationServices _highDpiMode = applicationDefaultsEventArgs.HighDpiMode _colorMode = applicationDefaultsEventArgs.ColorMode + _formRevealMode = applicationDefaultsEventArgs.FormRevealMode + _visualStylesMode = applicationDefaultsEventArgs.VisualStylesMode ' Then, it's applying what we got back as HighDpiMode. Dim dpiSetResult As Boolean = Application.SetHighDpiMode(_highDpiMode) @@ -780,7 +821,10 @@ Namespace Microsoft.VisualBasic.ApplicationServices Application.EnableVisualStyles() End If + Application.SetDefaultVisualStylesMode(_visualStylesMode) + Application.SetColorMode(_colorMode) + Application.SetDefaultFormRevealMode(_formRevealMode) ' We'll handle "/nosplash" for you. If Not (commandLineArgs.Contains("/nosplash") OrElse Me.CommandLineArgs.Contains("-nosplash")) Then diff --git a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt index e69de29bb2d..93b55542d62 100644 --- a/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualBasic.Forms/src/PublicAPI.Unshipped.txt @@ -0,0 +1,8 @@ +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.FormRevealMode() -> System.Windows.Forms.FormRevealMode +Microsoft.VisualBasic.ApplicationServices.ApplyApplicationDefaultsEventArgs.FormRevealMode(AutoPropertyValue As System.Windows.Forms.FormRevealMode) -> Void +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.FormRevealMode() -> System.Windows.Forms.FormRevealMode +Microsoft.VisualBasic.ApplicationServices.WindowsFormsApplicationBase.FormRevealMode(value As System.Windows.Forms.FormRevealMode) -> Void +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..1aefb4bdfd7 100644 --- a/src/System.Windows.Forms.Primitives/src/NativeMethods.txt +++ b/src/System.Windows.Forms.Primitives/src/NativeMethods.txt @@ -7,6 +7,7 @@ ADVF AreDpiAwarenessContextsEqual ARW_* AUTOCOMPLETEOPTIONS +BeginDeferWindowPos BFFM_* BIF_* BITMAP @@ -62,6 +63,7 @@ DATETIMEPICK_CLASS DeactivateActCtx DefFrameProc DefMDIChildProc +DeferWindowPos DESKTOP_ACCESS_FLAGS DestroyAcceleratorTable DestroyCursor @@ -92,6 +94,7 @@ DTM_* DTN_* DTS_* DuplicateHandle +DWMWINDOWATTRIBUTE DWM_WINDOW_CORNER_PREFERENCE DwmGetWindowAttribute DwmSetWindowAttribute @@ -104,6 +107,7 @@ EN_* EnableMenuItem EnableScrollBar EnableWindow +EndDeferWindowPos EndDialog ENM_* EnumDisplaySettings @@ -183,6 +187,7 @@ GetProcessWindowStation GETPROPERTYSTOREFLAGS GetRgnBox GetROP2 +GetScrollBarInfo GetScrollInfo GetShortPathName GetStartupInfo @@ -512,6 +517,9 @@ PD_RESULT_* PostQuitMessage PostQuitMessage PostThreadMessage +PowerCreateRequest +PowerClearRequest +PowerSetRequest PRF_* PROGRESS_CLASS PROPERTYKEY @@ -692,9 +700,11 @@ WC_TABCONTROL WC_TREEVIEW WHEEL_DELTA WindowFromPoint +WTSRegisterSessionNotification +WTSUnRegisterSessionNotification WINDOWPOS WINEVENT_INCONTEXT WSF_VISIBLE XBUTTON1 XBUTTON2 -XFORMCOORDS \ No newline at end of file +XFORMCOORDS 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/src/System/Windows/Forms/Internals/ScaleHelper.cs b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs index 764a35ca3a1..7144aa66cc0 100644 --- a/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs +++ b/src/System.Windows.Forms.Primitives/src/System/Windows/Forms/Internals/ScaleHelper.cs @@ -30,6 +30,12 @@ internal static partial class ScaleHelper private static bool s_processPerMonitorAware; private static Size? s_logicalSmallSystemIconSize; + private const string AccessibilityRegistryKeyPath = "Software\\Microsoft\\Accessibility"; + private const string TextScaleFactorValueName = "TextScaleFactor"; + private const int MinSystemTextScalePercent = 100; + private const int MaxSystemTextScalePercent = 225; + private const double DefaultSystemTextScaleFactor = 1.0; + /// /// The initial primary monitor DPI (logical pixels per inch) for the process. /// @@ -227,20 +233,59 @@ internal static Bitmap ScaleToDpi(Bitmap logicalBitmap, int dpi, bool disposeBit return null; } - // The default(100) and max(225) text scale factor is value what Settings display text scale - // applies and also clamps the text scale factor value between 100 and 225 value. - // See https://docs.microsoft.com/windows/uwp/design/input/text-scaling. - const int MinTextScaleValue = 100; - const int MaxTextScaleValue = 225; + if (!TryGetSystemTextScaleFactor(out double textScaleFactor) + || textScaleFactor == DefaultSystemTextScaleFactor) + { + return null; + } + + return font.WithSize(font.Size * (float)textScaleFactor); + } + + /// + /// Gets the current Windows Accessibility text-scale factor as a multiplier. + /// + /// + /// + /// Returns 1.0 when text scaling is unsupported or when the setting cannot be read. + /// + /// + internal static double GetSystemTextScaleFactor() + => TryGetSystemTextScaleFactor(out double textScaleFactor) + ? textScaleFactor + : DefaultSystemTextScaleFactor; + + /// + /// Attempts to get the current Windows Accessibility text-scale factor as a multiplier. + /// + /// The current text-scale factor. + /// + /// if the value was read successfully; otherwise, . + /// + internal static bool TryGetSystemTextScaleFactor( + out double textScaleFactor) + { + textScaleFactor = DefaultSystemTextScaleFactor; + + if (!OsVersion.IsWindows10_1507OrGreater()) + { + return false; + } try { - // Retrieve the text scale factor, which is set via Settings > Display > Make Text Bigger. - using RegistryKey? key = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\Accessibility"); - if (key is not null && key.GetValue("TextScaleFactor") is int textScale) + // Retrieve the text scale factor, which is set via Settings > Accessibility > Text size. + using RegistryKey? key = Registry.CurrentUser.OpenSubKey( + AccessibilityRegistryKeyPath); + if (key is not null + && key.GetValue(TextScaleFactorValueName) is int textScale) { - textScale = Math.Clamp(textScale, MinTextScaleValue, MaxTextScaleValue); - return textScale == 100 ? null : font.WithSize(font.Size * (textScale / 100.0f)); + textScale = Math.Clamp( + textScale, + MinSystemTextScalePercent, + MaxSystemTextScalePercent); + textScaleFactor = textScale / 100.0; + return true; } } catch @@ -251,7 +296,7 @@ internal static Bitmap ScaleToDpi(Bitmap logicalBitmap, int dpi, bool disposeBit #endif } - return null; + return false; } /// 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..c8a634a5aeb 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -0,0 +1,151 @@ +#nullable enable +static System.Windows.Forms.Application.SystemVisualSettings.get -> System.Windows.Forms.SystemVisualSettings! +static System.Windows.Forms.Application.SystemVisualSettingsChanged -> System.Windows.Forms.SystemVisualSettingsChangedEventHandler? +virtual System.Windows.Forms.Control.OnSystemVisualSettingsChanged(System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +System.Windows.Forms.Control.SystemVisualSettingsChanged -> System.Windows.Forms.SystemVisualSettingsChangedEventHandler? +System.Windows.Forms.SystemVisualSettings.AccentColor.get -> System.Drawing.Color +System.Windows.Forms.SystemVisualSettings.ClientAreaAnimationEnabled.get -> bool +System.Windows.Forms.SystemVisualSettings.FocusBorderMetrics.get -> System.Drawing.Size +System.Windows.Forms.SystemVisualSettings.HighContrastEnabled.get -> bool +System.Windows.Forms.SystemVisualSettings.KeyboardCuesVisible.get -> bool +System.Windows.Forms.SystemVisualSettings.TextScaleFactor.get -> float +System.Windows.Forms.SystemVisualSettings +System.Windows.Forms.SystemVisualSettingsCategories.AccentColor = 1 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.Animations = 8 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.FocusMetrics = 32 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.HighContrast = 4 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.KeyboardCues = 16 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.None = 0 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories.TextScale = 2 -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.Changed.get -> System.Windows.Forms.SystemVisualSettingsCategories +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.NewSettings.get -> System.Windows.Forms.SystemVisualSettings! +System.Windows.Forms.SystemVisualSettingsChangedEventArgs.OldSettings.get -> System.Windows.Forms.SystemVisualSettings! +System.Windows.Forms.SystemVisualSettingsChangedEventArgs +System.Windows.Forms.SystemVisualSettingsChangedEventHandler +virtual System.Windows.Forms.SystemVisualSettingsChangedEventHandler.Invoke(object? sender, System.Windows.Forms.SystemVisualSettingsChangedEventArgs! e) -> void +System.Windows.Forms.TreeView.NodeLeading.get -> float +System.Windows.Forms.TreeView.NodeLeading.set -> void +System.Windows.Forms.TreeView.NodeLeadingChanged -> System.EventHandler? +virtual System.Windows.Forms.TreeView.OnNodeLeadingChanged(System.EventArgs! e) -> void +System.Windows.Forms.ControlMutationExtensions +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target) -> System.Windows.Forms.SuspendPaintingScope! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target, System.Func! suspendLayoutContainerFilter) -> System.Windows.Forms.SuspendPaintingScope! +static System.Windows.Forms.ControlMutationExtensions.SuspendPainting(this System.Windows.Forms.ISupportSuspendPainting! target, System.Windows.Forms.LayoutSuspendTraversal layoutSuspendTraversal) -> System.Windows.Forms.SuspendPaintingScope! +System.Windows.Forms.FormRevealMode +System.Windows.Forms.FormRevealMode.Classic = 0 -> System.Windows.Forms.FormRevealMode +System.Windows.Forms.FormRevealMode.Deferred = 1 -> System.Windows.Forms.FormRevealMode +System.Windows.Forms.FormRevealMode.Inherit = -1 -> System.Windows.Forms.FormRevealMode +System.Windows.Forms.ISupportSuspendPainting +System.Windows.Forms.ISupportSuspendPainting.BeginSuspendPainting() -> void +System.Windows.Forms.ISupportSuspendPainting.EndSuspendPainting() -> void +System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.None = 0 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.TopLevelOnly = 1 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.LayoutSuspendTraversal.Traverse = 2 -> System.Windows.Forms.LayoutSuspendTraversal +System.Windows.Forms.SuspendPaintingScope +System.Windows.Forms.SuspendPaintingScope.Dispose() -> void +System.Windows.Forms.SuspendPaintingScope.SuspendPaintingScope(System.Windows.Forms.ISupportSuspendPainting? target) -> void +static System.Windows.Forms.Application.DefaultFormRevealMode.get -> System.Windows.Forms.FormRevealMode +static System.Windows.Forms.Application.IsFormRevealDeferred.get -> bool +static System.Windows.Forms.Application.SetDefaultFormRevealMode(System.Windows.Forms.FormRevealMode mode) -> void +virtual System.Windows.Forms.Control.BeginSuspendPaintingCore() -> void +virtual System.Windows.Forms.Control.EndSuspendPaintingCore() -> void +virtual System.Windows.Forms.Form.FormRevealMode.get -> System.Windows.Forms.FormRevealMode +virtual System.Windows.Forms.Form.FormRevealMode.set -> void +override System.Windows.Forms.ComboBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ComboBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.ListView.EndSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.RichTextBox.EndSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.BeginSuspendPaintingCore() -> void +override System.Windows.Forms.TreeView.EndSuspendPaintingCore() -> void +System.Windows.Forms.KioskModeManager +System.Windows.Forms.KioskModeManager.ContainerControl.get -> System.Windows.Forms.ContainerControl? +System.Windows.Forms.KioskModeManager.ContainerControl.set -> void +System.Windows.Forms.KioskModeManager.ContainerControlChanged -> System.EventHandler? +override System.Windows.Forms.KioskModeManager.Dispose(bool disposing) -> void +System.Windows.Forms.KioskModeManager.EscapeExitsFullScreen.get -> bool +System.Windows.Forms.KioskModeManager.EscapeExitsFullScreen.set -> void +System.Windows.Forms.KioskModeManager.FullScreen.get -> bool +System.Windows.Forms.KioskModeManager.FullScreen.set -> void +System.Windows.Forms.KioskModeManager.FullScreenChanged -> System.EventHandler? +System.Windows.Forms.KioskModeManager.HideTaskbar.get -> bool +System.Windows.Forms.KioskModeManager.HideTaskbar.set -> void +System.Windows.Forms.KioskModeManager.KioskModeManager() -> void +System.Windows.Forms.KioskModeManager.KioskModeManager(System.ComponentModel.IContainer! container) -> void +System.Windows.Forms.KioskModeManager.MousePointerAutoHideDelay.get -> int +System.Windows.Forms.KioskModeManager.MousePointerAutoHideDelay.set -> void +virtual System.Windows.Forms.KioskModeManager.OnContainerControlChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.KioskModeManager.OnFullScreenChanged(System.EventArgs! e) -> void +virtual System.Windows.Forms.KioskModeManager.OnWakeup(System.Windows.Forms.KioskModeWakeupEventArgs! e) -> void +override System.Windows.Forms.KioskModeManager.Site.get -> System.ComponentModel.ISite? +override System.Windows.Forms.KioskModeManager.Site.set -> void +System.Windows.Forms.KioskModeManager.SuppressPowerSaving.get -> bool +System.Windows.Forms.KioskModeManager.SuppressPowerSaving.set -> void +System.Windows.Forms.KioskModeManager.ToggleFullScreen() -> void +System.Windows.Forms.KioskModeManager.ToggleFullScreenKey.get -> System.Windows.Forms.Keys +System.Windows.Forms.KioskModeManager.ToggleFullScreenKey.set -> void +System.Windows.Forms.KioskModeManager.TopMostInFullScreen.get -> bool +System.Windows.Forms.KioskModeManager.TopMostInFullScreen.set -> void +System.Windows.Forms.KioskModeManager.Wakeup -> System.Windows.Forms.KioskModeWakeupEventHandler? +System.Windows.Forms.KioskModeManager.WakeUpCommand.get -> System.Windows.Input.ICommand? +System.Windows.Forms.KioskModeManager.WakeUpCommand.set -> void +System.Windows.Forms.KioskModeWakeupEventArgs +System.Windows.Forms.KioskModeWakeupEventArgs.KioskModeWakeupEventArgs(System.Windows.Forms.KioskModeWakeupSource source) -> void +System.Windows.Forms.KioskModeWakeupEventArgs.Source.get -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupEventHandler +virtual System.Windows.Forms.KioskModeWakeupEventHandler.Invoke(object? sender, System.Windows.Forms.KioskModeWakeupEventArgs! e) -> void +System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Keyboard = 0 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Mouse = 1 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.PowerResume = 2 -> System.Windows.Forms.KioskModeWakeupSource +System.Windows.Forms.KioskModeWakeupSource.Session = 3 -> System.Windows.Forms.KioskModeWakeupSource +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.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.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..c88e5ed24a0 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 @@ -1035,6 +1041,9 @@ Specifies the minimum size of the control. + + Layout suspension requires a target derived from Control. + 'child' is not a child control of this parent. @@ -1224,6 +1233,9 @@ Event raised when the system colors change. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when the value of the TabIndex property is changed. @@ -2679,6 +2691,45 @@ To replace this default dialog please handle the DataError event. Manages a collection of images that are typically used by other controls such as ListView, TreeView, or ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + + The container whose containing form is controlled by the kiosk mode manager. + + + Occurs when the ContainerControl property value changes. + + + Indicates whether pressing Escape exits fullscreen mode. + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + Occurs when the fullscreen state changes. + + + Indicates whether fullscreen mode covers the Windows taskbar. + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + Indicates whether Windows should keep the display and system awake while this component is active. + + + The key that toggles between fullscreen and restored mode. + + + Indicates whether the form is topmost while fullscreen. + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + The command that is executed whenever the kiosk experience is woken. + Provides run-time information or descriptive text for a control. @@ -3286,6 +3337,9 @@ Do you want to replace it? Indicates whether the form always appears above all other forms that do not have this property set to true. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + A color which will appear transparent when painted on the form. @@ -4987,7 +5041,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. @@ -6319,6 +6373,9 @@ Stack trace where the illegal operation occurred was: The color of the lines that connect the nodes of the TreeView. + + The uniform row-height leading factor applied to all nodes. + Occurs when a node is clicked with the mouse. @@ -6334,6 +6391,9 @@ Stack trace where the illegal operation occurred was: The sorting comparer for the TreeView. + + Occurs when the value of the NodeLeading property changes. + The string delimiter used for the path returned by a node's FullPath property. @@ -6931,6 +6991,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. @@ -7045,4 +7111,4 @@ Stack trace where the illegal operation occurred was: The FormScreenCaptureMode property can only be changed on top-level Forms with their TopLevel property set to true. - \ No newline at end of file + diff --git a/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf b/src/System.Windows.Forms/Resources/xlf/SR.cs.xlf index ce2bc9260f9..d5f39a8d68d 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 @@ -1697,6 +1707,11 @@ Určuje minimální velikost ovládacího prvku. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. Objekt child není podřízeným ovládacím prvkem tohoto nadřazeného objektu. @@ -2027,6 +2042,11 @@ Událost aktivovaná při změně systémových barev + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Událost aktivovaná při změně hodnoty vlastnosti TabIndex @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Chcete-li nahradit toto výchozí dialogové okno, nastavte popisovač události Spravuje kolekci obrázků, které standardně používají ovládací prvky, jako například ListView, TreeView nebo ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Poskytuje běhové informace nebo popisný text pro ovládací prvek. @@ -5420,6 +5455,11 @@ Chcete ho nahradit? Hodnota, kterou tento formulář vrátí, pokud bude zobrazen jako dialogové okno. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Událost aktivovaná při kliknutí na tlačítko nápovědy @@ -6104,6 +6144,66 @@ Trasování zásobníku, kde došlo k neplatné operaci: Kombinace klíčů je neplatná. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Umožňuje automatické zpracování textu, který je delší než šířka ovládacího prvku popisku. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Trasování zásobníku, kde došlo k neplatné operaci: Barva čar spojujících uzly ovládacího prvku TreeView + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Vyvolá se při kliknutí myší na uzel. @@ -11044,6 +11149,11 @@ Trasování zásobníku, kde došlo k neplatné operaci: Kořenové uzly v ovládacím prvku TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Oddělovač řetězců použitý pro cesty vracené vlastností FullPath uzlu. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.de.xlf b/src/System.Windows.Forms/Resources/xlf/SR.de.xlf index b5cf47b1170..4ef1cefa58b 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 @@ -1697,6 +1707,11 @@ Gibt die Mindestgröße des Steuerelements an. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. "child" ist kein untergeordnetes Steuerelement dieses übergeordneten Elements. @@ -2027,6 +2042,11 @@ Das ausgelöste Ereignis, wenn sich die Systemfarben ändern. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Das ausgelöste Ereignis, wenn der Wert der TabIndex-Eigenschaft geändert wird. @@ -2172,6 +2192,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). @@ -4519,6 +4549,11 @@ Behandeln Sie das DataError-Ereignis, um dieses Standarddialogfeld zu ersetzen.< Verwaltet eine Sammlung von Bildern, die in der Regel von anderen Steuerelementen wie ListView, TreeView oder ToolStrip verwendet werden. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Stellt Laufzeitinformationen oder deskriptiven Text für ein Steuerelement bereit. @@ -5420,6 +5455,11 @@ Möchten Sie den Pfad ersetzen? Der Wert, den dieses Formular zurückgibt, wenn es als Dialogfeld angezeigt wird. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Das ausgelöste Ereignis, wenn auf die Schaltfläche "Hilfe" geklickt wird. @@ -6104,6 +6144,66 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Ungültige Schlüsselkombination. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Aktiviert die automatische Behandlung von Text, der über die Breite des Bezeichnungssteuerelements hinausgeht. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Die Farbe der Linien, die die Knoten der TreeView verbinden. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Tritt auf, wenn mit der Maus auf einen Knoten geklickt wird. @@ -11044,6 +11149,11 @@ Stapelüberwachung, in der der unzulässige Vorgang auftrat: Die Stammknoten im TreeView-Steuerelement. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Das Zeichenfolgen-Trennzeichen für den Pfad, der von der FullPath-Eigenschaft eines Knotens zurückgegeben wird. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.es.xlf b/src/System.Windows.Forms/Resources/xlf/SR.es.xlf index 82eb0cd40bb..9378cb1c0a7 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 @@ -1697,6 +1707,11 @@ Especifica el tamaño mínimo del control. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' no es un control secundario de este primario. @@ -2027,6 +2042,11 @@ Evento que se desencadena cuando cambian los colores del sistema. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Evento que se desencadena cuando se cambia el valor de la propiedad TabIndex. @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Para reemplazar este cuadro de diálogo predeterminado controle el evento DataEr Controla una colección de imágenes que suelen utilizar otros controles como ListView, TreeView o ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Proporciona información en tiempo de ejecución o texto descriptivo para un control. @@ -5420,6 +5455,11 @@ Do you want to replace it? Valor de este formulario si se muestra como un cuadro de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento que se desencadena cuando se hace clic en el botón Ayuda. @@ -6104,6 +6144,66 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: La combinación de teclas no es válida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Permite el control automático del texto que se extiende más allá del ancho del control de la etiqueta. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: Color de las líneas que conectan los nodos en TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Tiene lugar cuando se hace clic con el mouse en un nodo. @@ -11044,6 +11149,11 @@ El seguimiento de la pila donde tuvo lugar la operación no válida fue: Nodos raíz en el control TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Delimitador de cadena utilizado para la ruta de acceso devuelta por la propiedad FullPath de un nodo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.fr.xlf index f02c36b8e65..5e7c4c57d18 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 @@ -1697,6 +1707,11 @@ Indique la taille minimale du contrôle. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' n'est pas un contrôle enfant de ce parent. @@ -2027,6 +2042,11 @@ Événement déclenché lorsque les couleurs système changent. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Événement déclenché lorsque la valeur de la propriété TabIndex change. @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Pour remplacer cette boîte de dialogue par défaut, traitez l'événement DataE Gère une collection d'images qui sont généralement utilisées par d'autres contrôles tels que ListView, TreeView et ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fournit des informations d'exécution ou un texte descriptif pour un contrôle. @@ -5420,6 +5455,11 @@ Voulez-vous le remplacer ? La valeur que ce formulaire retourne s'il est affiché en tant que boîte de dialogue. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Événement qui est déclenché à la suite d'un clic sur le bouton d'aide. @@ -6104,6 +6144,66 @@ Cette opération non conforme s'est produite sur la trace de la pile : La combinaison de touches n'est pas valide. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Active la gestion automatique du texte qui dépasse les limites de largeur du contrôle label. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Cette opération non conforme s'est produite sur la trace de la pile : La couleur des lignes qui connectent les nœuds du TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Se produit lors d'un clic de souris sur un nœud. @@ -11044,6 +11149,11 @@ Cette opération non conforme s'est produite sur la trace de la pile : Les nœuds racine dans le contrôle TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Le délimiteur de chaîne utilisé pour le chemin d'accès retourné par la propriété FullPath d'un nœud. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.it.xlf b/src/System.Windows.Forms/Resources/xlf/SR.it.xlf index fc9f709a0be..42fd4b7f4fc 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 @@ -1697,6 +1707,11 @@ Specifica le dimensioni minime del controllo. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' non è un controllo figlio di questo elemento. @@ -2027,6 +2042,11 @@ Evento generato alla modifica dei colori di sistema. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Generato quando il valore della proprietà TabIndex cambia. @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Per sostituire questa finestra di dialogo, gestisci l'evento DataError. Gestisce una raccolta di immagini utilizzate in genere da altri controlli, quali ListView, TreeView o ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fornisce informazioni di runtime o testo descrittivo per un controllo. @@ -5420,6 +5455,11 @@ Sostituirlo? Il valore che questo form restituirà se viene visualizzato come finestra di dialogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento generato quando si fa clic sul pulsante ?. @@ -6104,6 +6144,66 @@ Analisi dello stack dove si è verificata l'operazione non valida: Combinazione di tasti non valida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Consente la gestione automatica del testo che non è contenuto all'interno della larghezza del controllo etichetta. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Analisi dello stack dove si è verificata l'operazione non valida: Il colore delle righe che connettono i nodi del controllo TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Generato quando si fa clic con il mouse su un nodo. @@ -11044,6 +11149,11 @@ Analisi dello stack dove si è verificata l'operazione non valida: I nodi radice nel controllo TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Il delimitatore di stringa utilizzato per il percorso restituito dalla proprietà FullPath di un nodo. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ja.xlf index 4511ba6cf76..3e2cadde163 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) @@ -1697,6 +1707,11 @@ コントロールの最小サイズを指定します。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. '子' はこの親の子コントロールではありません。 @@ -2027,6 +2042,11 @@ システム カラーが変更されるときに発生するイベントです。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex プロパティの値が変更されたときに発生するイベントです。 @@ -2172,6 +2192,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. コンテナー座標で表したコントロールの幅です。 @@ -4519,6 +4549,11 @@ To replace this default dialog please handle the DataError event. ListView、TreeView、または ToolStrip のような他のコントロールが通常使用するイメージのコレクションを管理します。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. コントロールの実行時の情報または説明用のテキストを提供します。 @@ -5420,6 +5455,11 @@ Do you want to replace it? ダイアログ ボックスとして表示された場合に、このフォームが返す値です。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [ヘルプ] ボタンがクリックされたときに発生するイベントです。 @@ -6104,6 +6144,66 @@ Stack trace where the illegal operation occurred was: キーの組み合わせが有効ではありません。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. ラベル コントロールの幅を越えるテキストの自動処理を有効にします。 @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stack trace where the illegal operation occurred was: ツリー ビューのノード間を接続する線の色です。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. ノードがマウスでクリックされたときに発生します。 @@ -11044,6 +11149,11 @@ Stack trace where the illegal operation occurred was: TreeView コントロールのルートのノードです。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. ノードの FullPath プロパティにより返されるパスで使用する、文字列の区切り文字です。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ko.xlf index 2924f72aab7..eca40e2f977 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) @@ -1697,6 +1707,11 @@ 컨트롤의 최소 크기를 지정합니다. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child'는 이 부모의 자식 컨트롤이 아닙니다. @@ -2027,6 +2042,11 @@ 시스템 색이 변경되면 이벤트가 발생합니다. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 속성 값이 변경되면 이벤트가 발생합니다. @@ -2172,6 +2192,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. 컨테이너 좌표로 표시한 컨트롤의 너비입니다. @@ -4519,6 +4549,11 @@ To replace this default dialog please handle the DataError event. ListView, TreeView 또는 ToolStrip과 같은 다른 컨트롤에서 일반적으로 사용하는 이미지 컬렉션을 관리합니다. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 컨트롤에 대한 설명 텍스트나 런타임 정보를 제공합니다. @@ -5420,6 +5455,11 @@ Do you want to replace it? 대화 상자로 표시할 경우, 이 폼에서 반환하는 값입니다. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. [도움말] 단추를 클릭하면 이벤트가 발생합니다. @@ -6104,6 +6144,66 @@ Stack trace where the illegal operation occurred was: 키 조합이 잘못되었습니다. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. 레이블 컨트롤의 너비보다 큰 텍스트를 자동으로 처리합니다. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stack trace where the illegal operation occurred was: TreeView의 노드를 연결하는 줄의 색입니다. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 마우스로 노드를 클릭할 때 발생합니다. @@ -11044,6 +11149,11 @@ Stack trace where the illegal operation occurred was: TreeView 컨트롤의 루트 노드입니다. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 노드의 FullPath 속성으로 반환된 경로에 사용되는 문자열 구분 기호입니다. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pl.xlf index f86297cb7f6..6028184db11 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 @@ -1697,6 +1707,11 @@ Określa minimalny rozmiar formantu. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. Element 'child' nie jest formantem podrzędnym dla tego formantu. @@ -2027,6 +2042,11 @@ Zdarzenie wywoływane, gdy kolory systemowe zostaną zmienione. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Zdarzenie wywoływane, gdy wartość właściwości TabIndex zostanie zmieniona. @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Aby zamienić to domyślne okno dialogowe, obsłuż zdarzenie DataError.Zarządza kolekcją obrazów, które są zazwyczaj używane przez inne formanty, takie jak ListView, TreeView lub ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Wyświetla informacje o wykonaniu lub tekst opisowy dla formantu. @@ -5420,6 +5455,11 @@ Czy chcesz zastąpić? Wartość zwracana przez formularz, gdy jest on wyświetlany jako okno dialogowe. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Zdarzenie wywoływane po kliknięciu przycisku pomocy. @@ -6104,6 +6144,66 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Kombinacja kluczy jest nieprawidłowa. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Włącza automatyczną obsługę tekstu, który mieści się w szerokości formantu etykiety. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Kolor linii łączących węzły elementu TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Występuje, gdy węzeł zostanie kliknięty przy użyciu myszy. @@ -11044,6 +11149,11 @@ Stos śledzenia, w którym wystąpiła zabroniona operacja: Węzły główne w formancie TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Ogranicznik ciągu używany w ścieżce zwracanej przez właściwość FullPath węzła. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf b/src/System.Windows.Forms/Resources/xlf/SR.pt-BR.xlf index 5dcda8ac793..2c8cccf37ea 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 @@ -1697,6 +1707,11 @@ Especifica o tamanho mínimo do controle. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' não é um controle filho deste pai. @@ -2027,6 +2042,11 @@ Evento gerado quando as cores do sistema são alteradas. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Evento gerado quando o valor da propriedade TabIndex é alterado. @@ -2172,6 +2192,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. @@ -4519,6 +4549,11 @@ Para substituir a caixa de diálogo padrão, manipule o evento DataError.Gerencia uma coleção de imagens geralmente usadas por outros controles, como ListView, TreeView ou ToolStrip. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Fornece informações em tempo de execução ou texto descritivo para um controle. @@ -5420,6 +5455,11 @@ Deseja substituí-lo? O valor que este formulário retornará se exibido como uma caixa de diálogo. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Evento gerado quando o usuário clica no botão de ajuda. @@ -6104,6 +6144,66 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: A combinação de chave não é válida. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Permite a manipulação automática de texto que ultrapassa a largura do controle de rótulo. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: A cor das linhas que conectam os nós de TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Ocorre quando o usuário clica com o mouse em um nó. @@ -11044,6 +11149,11 @@ O rastreamento de pilha em que a operação ilegal ocorreu foi: Nós raiz no controle TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. O delimitador de cadeia de caracteres usado para o caminho retornado pela propriedade FullPath de um nó. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf b/src/System.Windows.Forms/Resources/xlf/SR.ru.xlf index 89af4e75583..916c2726fdb 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 &Применить @@ -1697,6 +1707,11 @@ Определяет минимальный размер элемента управления. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' не является дочерним элементом управления этого родительского элемента. @@ -2027,6 +2042,11 @@ Событие возникает при изменении системной палитры. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. Событие возникает при изменении значения свойства TabIndex. @@ -2172,6 +2192,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. Ширина элемента управления в координатах контейнера. @@ -4519,6 +4549,11 @@ To replace this default dialog please handle the DataError event. Управляет коллекцией изображений, которые обычно используются другими элементами управления (например, ListView, TreeView или ToolStrip). + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Предоставляет элементу управления текст описания либо информацию во время выполнения. @@ -5420,6 +5455,11 @@ Do you want to replace it? Значение, возвращаемое этой формой при ее отображении в виде диалогового окна. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Событие возникает при нажатии кнопки справки. @@ -6104,6 +6144,66 @@ Stack trace where the illegal operation occurred was: Недопустимая комбинация ключа. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Включает автоматическую обработку текста, выходящего за пределы ширины элемента управления с подписью. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stack trace where the illegal operation occurred was: Цвет линий, соединяющих узлы в TreeView. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Возникает при щелчке мыши на узле. @@ -11044,6 +11149,11 @@ Stack trace where the illegal operation occurred was: Корневые узлы в элементе управления TreeView. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Разделитель строк в пути, возвращаемом свойством FullPath узла. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf b/src/System.Windows.Forms/Resources/xlf/SR.tr.xlf index 9e72305a890..59870d46beb 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 @@ -1697,6 +1707,11 @@ Denetimin en küçük boyutunu belirtir. + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'alt' bu üstün alt denetimi değil. @@ -2027,6 +2042,11 @@ Sistem renkleri değiştiğinde harekete geçirilen olay. + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex özelliğinin değeri değiştirildiğinde harekete geçirilen olay. @@ -2172,6 +2192,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). @@ -4519,6 +4549,11 @@ Bu varsayılan iletişim kutusunu değiştirmek için, lütfen DataError olayın Genellikle ListView, TreeView veya ToolStrip gibi diğer denetimler tarafından kullanılan resim koleksiyonunu düzenler. + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. Bir denetim için çalışma zamanı bilgileri veya açıklayıcı metin sağlar. @@ -5420,6 +5455,11 @@ Değiştirmek istiyor musunuz? Bu formun iletişim kutusu olarak görüntülendiğinde döndüreceği değer. + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. Yardım düğmesi tıklatıldığında harekete geçirilen olay. @@ -6104,6 +6144,66 @@ Geçersiz işlemin gerçekleştiği yığın izi: Anahtar birleşimi geçerli değil. + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. Etiket denetimi genişliğini aşan metnin otomatik işlenmesini sağlar. @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Geçersiz işlemin gerçekleştiği yığın izi: TreeView'in düğümlerini bağlayan çizgilerin rengi. + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. Öğe fareyle tıklatıldığında gerçekleşir. @@ -11044,6 +11149,11 @@ Geçersiz işlemin gerçekleştiği yığın izi: TreeView denetiminde kök düğümler. + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. Düğümün FullPath özelliği tarafından döndürülen yol için kullanılan dize sınırlayıcı. diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hans.xlf index 6827f20269f..9d3537fea34 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) @@ -1697,6 +1707,11 @@ 指定控件的最小大小。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. “child”不是此父级的子控件。 @@ -2027,6 +2042,11 @@ 在系统颜色更改时引发的事件。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 属性值更改时引发的事件。 @@ -2172,6 +2192,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. 控件的宽度(以容器坐标表示)。 @@ -4519,6 +4549,11 @@ To replace this default dialog please handle the DataError event. 管理通常由其他控件(如 ListView、TreeView 或 ToolStrip)使用的图像集合。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 为控件提供运行时信息或说明性文字。 @@ -5420,6 +5455,11 @@ Do you want to replace it? 此窗体在显示为对话框时将返回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 单击“帮助”按钮时引发的事件。 @@ -6104,6 +6144,66 @@ Stack trace where the illegal operation occurred was: 组合键无效。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. 启用对扩展到标签控件宽度以外的文本的自动处理。 @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stack trace where the illegal operation occurred was: 连接树视图节点的线条的颜色。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 用鼠标单击节点时发生。 @@ -11044,6 +11149,11 @@ Stack trace where the illegal operation occurred was: TreeView 控件中的根节点。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 用于由节点的 FullPath 属性返回的路径的字符串分隔符。 diff --git a/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf b/src/System.Windows.Forms/Resources/xlf/SR.zh-Hant.xlf index 15f5f06c126..60ad5ac5ae5 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) @@ -1697,6 +1707,11 @@ 指定控制項大小的最小值。 + + Layout suspension requires a target derived from Control. + Layout suspension requires a target derived from Control. + + 'child' is not a child control of this parent. 'child' 不是此父系的子控制項。 @@ -2027,6 +2042,11 @@ 當系統色彩變更時引發的事件。 + + Event raised when a Windows visual or accessibility setting changes. + Event raised when a Windows visual or accessibility setting changes. + + Event raised when the value of the TabIndex property is changed. TabIndex 屬性的值變更時引發的事件。 @@ -2172,6 +2192,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. 容器座標中控制項的寬度。 @@ -4519,6 +4549,11 @@ To replace this default dialog please handle the DataError event. 管理通常由其他控制項 (例如 ListView、TreeView 或 ToolStrip) 使用的影像集合。 + + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + Manages common fullscreen, input, cursor, and power behavior for kiosk-style WinForms applications. + + Provides run-time information or descriptive text for a control. 提供控制項的執行階段資訊或描述文字。 @@ -5420,6 +5455,11 @@ Do you want to replace it? 如果顯示為對話方塊,此表單會傳回的值。 + + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + Indicates how the form is presented while its initial appearance is prepared. Classic never defers reveal. Deferred hides the form behind a DWM cloak until its background has painted, to reduce startup flash. Inherit resolves from the application-wide default. + + Event raised when the help button is clicked. 按下說明按鈕時引發的事件。 @@ -6104,6 +6144,66 @@ Stack trace where the illegal operation occurred was: 組合鍵無效。 + + Occurs when the ContainerControl property value changes. + Occurs when the ContainerControl property value changes. + + + + The container whose containing form is controlled by the kiosk mode manager. + The container whose containing form is controlled by the kiosk mode manager. + + + + Indicates whether pressing Escape exits fullscreen mode. + Indicates whether pressing Escape exits fullscreen mode. + + + + Gets or sets a value indicating whether the resolved form is currently fullscreen. + Gets or sets a value indicating whether the resolved form is currently fullscreen. + + + + Occurs when the fullscreen state changes. + Occurs when the fullscreen state changes. + + + + Indicates whether fullscreen mode covers the Windows taskbar. + Indicates whether fullscreen mode covers the Windows taskbar. + + + + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + The delay, in milliseconds, before the mouse pointer is hidden while fullscreen. A value of 0 disables automatic pointer hiding. + + + + Indicates whether Windows should keep the display and system awake while this component is active. + Indicates whether Windows should keep the display and system awake while this component is active. + + + + The key that toggles between fullscreen and restored mode. + The key that toggles between fullscreen and restored mode. + + + + Indicates whether the form is topmost while fullscreen. + Indicates whether the form is topmost while fullscreen. + + + + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + Occurs when keyboard, mouse, power resume, or session activity wakes the kiosk experience. + + + + The command that is executed whenever the kiosk experience is woken. + The command that is executed whenever the kiosk experience is woken. + + Enables the automatic handling of text that extends beyond the width of the label control. 啟用自動處理,以處理超出標籤控制項寬度的文件。 @@ -8675,8 +8775,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. @@ -11019,6 +11119,11 @@ Stack trace where the illegal operation occurred was: 連接樹狀檢視節點的線條色彩。 + + The uniform row-height leading factor applied to all nodes. + The uniform row-height leading factor applied to all nodes. + + Occurs when a node is clicked with the mouse. 以滑鼠按一下節點時發生。 @@ -11044,6 +11149,11 @@ Stack trace where the illegal operation occurred was: TreeView 控制項中的根節點。 + + Occurs when the value of the NodeLeading property changes. + Occurs when the value of the NodeLeading property changes. + + The string delimiter used for the path returned by a node's FullPath property. 節點 FullPath 屬性用來傳回路徑的字串分隔符號。 diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs new file mode 100644 index 00000000000..a91d409c770 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.SystemVisualSettings.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public sealed partial class Application +{ + private static readonly object s_eventSystemVisualSettingsChanged = new(); + + /// + /// Gets the current Windows visual and accessibility settings snapshot. + /// + public static SystemVisualSettings SystemVisualSettings + => SystemVisualSettingsTracker.CurrentSettings; + + /// + /// Occurs when Windows visual or accessibility settings change. + /// + /// + /// + /// WinForms raises this event once for each settings transition, normalizing + /// WM_SETTINGCHANGE, WM_DWMCOLORIZATIONCOLORCHANGED, + /// WM_THEMECHANGED, and WM_SYSCOLORCHANGE. Handlers should use + /// to early-out when + /// their category did not change. + /// + /// + /// This static event is for application-lifetime consumers such as theming engines and + /// services. Components whose lifetime is shorter than the application must unsubscribe. + /// Controls and forms should use or + /// override + /// instead; that instance path requires no unsubscription. + /// + /// + public static event SystemVisualSettingsChangedEventHandler? SystemVisualSettingsChanged + { + add => AddEventHandler(s_eventSystemVisualSettingsChanged, value); + remove => RemoveEventHandler(s_eventSystemVisualSettingsChanged, value); + } + + internal static void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + { + SystemVisualSettingsChangedEventHandler? handler; + + lock (s_internalSyncObject) + { + handler = s_eventHandlers?[s_eventSystemVisualSettingsChanged] as SystemVisualSettingsChangedEventHandler; + } + + handler?.Invoke(null, e); + } +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Application.cs b/src/System.Windows.Forms/System/Windows/Forms/Application.cs index aab496c2744..4b82d013a66 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Application.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Application.cs @@ -44,6 +44,11 @@ public sealed partial class Application private static bool s_useWaitCursor; private static SystemColorMode? s_colorMode; +#if NET11_0_OR_GREATER + private static FormRevealMode? s_defaultFormRevealMode; +#endif + + private static VisualStylesMode? s_defaultVisualStylesMode; private const string DarkModeKeyPath = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; private const string DarkModeKey = "AppsUseLightTheme"; @@ -249,6 +254,45 @@ internal static bool CustomThreadExceptionHandlerAttached /// public static SystemColorMode ColorMode => s_colorMode ?? SystemColorMode.Classic; +#if NET11_0_OR_GREATER + /// + /// Gets the configured default used as the reveal behavior for + /// top-level forms that do not set explicitly. + /// + /// + /// + /// If no mode has been configured with , this + /// returns . Unlike , this + /// getter may return the unresolved sentinel; use + /// for the fully resolved answer. + /// + /// + public static FormRevealMode DefaultFormRevealMode + => s_defaultFormRevealMode ?? FormRevealMode.Inherit; + + /// + /// Gets a value indicating whether newly created top-level forms use deferred reveal by default. + /// + /// + /// + /// This is the fully resolved answer: when + /// is , or when it is + /// (the default, when + /// has never been called) and is . + /// Accessibility trumps compatibility here: an application that opts into dark mode gets + /// flash-reduced form reveal automatically, without also having to call + /// explicitly. + /// + /// + public static bool IsFormRevealDeferred => DefaultFormRevealMode switch + { + FormRevealMode.Deferred => true, + FormRevealMode.Classic => false, + _ => IsDarkModeEnabled + }; + +#endif + /// /// True if the has been set at least once. /// @@ -381,6 +425,36 @@ static void NotifySystemEventsOfColorChange() } } +#if NET11_0_OR_GREATER + /// + /// Sets the process-wide default used for top-level forms that do + /// not set explicitly. + /// + /// The default form reveal mode to use for newly created top-level forms. + /// + /// + /// Set the default form reveal mode before creating UI to ensure newly created forms use the + /// intended startup presentation behavior. Unlike the visual-styles default-mode setter (which is + /// write-once, since rendering-version selection is a static, one-time choice), this method can be + /// called more than once, and is a valid argument (it resets + /// the default back to its own ambient resolution via ). Free + /// reassignment is intentional: the effective default is derived in part from + /// and , which can themselves change for the lifetime of the process; + /// locking this value after first use would make forms created after a later dark-mode change use a + /// stale reveal behavior. + /// + /// + /// + /// is not a valid value. + /// + public static void SetDefaultFormRevealMode(FormRevealMode mode) + { + SourceGenerated.EnumValidator.Validate(mode, nameof(mode)); + s_defaultFormRevealMode = mode; + } + +#endif + internal static Font DefaultFont => s_defaultFontScaled ?? s_defaultFont!; /// @@ -430,8 +504,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 +666,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/ErrorProvider/ErrorBlinkStyle.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorBlinkStyle.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorBlinkStyle.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorBlinkStyle.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorIconAlignment.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorIconAlignment.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorIconAlignment.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorIconAlignment.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.ControlItemAccessibleObject.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ControlItem.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ControlItem.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorProviderStates.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorProviderStates.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorProviderStates.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorProviderStates.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.ErrorWindowAccessibleObject.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.ErrorWindow.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.ErrorWindow.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.IconRegion.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.IconRegion.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.IconRegion.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.IconRegion.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/ErrorProvider/ErrorProvider.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ErrorProvider/ErrorProvider.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Help/HelpProvider.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/HelpProvider.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Help/HelpProvider.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/HelpProvider.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ColorDepth.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ColorDepth.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ColorDepth.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ColorDepth.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.ImageInfo.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.ImageInfo.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.ImageInfo.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.ImageInfo.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.ImageCollection.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.ImageCollection.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Indexer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Indexer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Indexer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Indexer.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.NativeImageList.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.NativeImageList.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.NativeImageList.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.NativeImageList.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Original.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Original.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.Original.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.Original.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.OriginalOptions.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.OriginalOptions.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.OriginalOptions.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.OriginalOptions.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageList.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageList.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListConverter.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListConverter.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListConverter.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListConverter.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListStreamer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/ImageListStreamer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/ImageListStreamer.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/RelatedImageListAttribute.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/RelatedImageListAttribute.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Controls/ImageList/RelatedImageListAttribute.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/ImageList/RelatedImageListAttribute.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs new file mode 100644 index 00000000000..0a7a98fede8 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeManager.cs @@ -0,0 +1,1224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.ComponentModel.Design; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Input; +using Windows.Win32.System.Power; +using Windows.Win32.System.Threading; + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Manages common kiosk-mode behavior for a WinForms form. +/// +/// +/// +/// is a component that can be dropped on a +/// form or on a user control at design time. Set +/// to the owning container and the component resolves the containing +/// when fullscreen behavior is needed. +/// +/// +/// The component can make the resolved form fullscreen, optionally cover the +/// taskbar, keep the form topmost while fullscreen, suppress display and +/// system sleep, hide the mouse pointer after inactivity, and notify the +/// application when user or system activity wakes the kiosk experience. +/// +/// +/// +/// +/// public partial class MainForm : Form +/// { +/// private readonly KioskModeManager _kioskModeManager; +/// +/// public MainForm() +/// { +/// InitializeComponent(); +/// +/// _kioskModeManager = new KioskModeManager +/// { +/// ContainerControl = this, +/// HideTaskbar = true, +/// TopMostInFullScreen = true, +/// MousePointerAutoHideDelay = 3000, +/// SuppressPowerSaving = true +/// }; +/// +/// _kioskModeManager.Wakeup += (sender, e) => +/// { +/// if (e.Source == KioskModeWakeupSource.Mouse) +/// { +/// // Refresh the kiosk surface after mouse activity. +/// } +/// }; +/// +/// if (!_kioskModeManager.FullScreen) +/// { +/// _kioskModeManager.ToggleFullScreen(); +/// } +/// } +/// } +/// +/// +[DefaultProperty(nameof(ToggleFullScreenKey))] +[ToolboxItemFilter("System.Windows.Forms")] +[SRDescription(nameof(SR.DescriptionKioskModeManager))] +public class KioskModeManager : Component, ISupportInitialize +{ + private const uint PowerRequestContextVersion = 0; + private const POWER_REQUEST_CONTEXT_FLAGS PowerRequestContextSimpleString = (POWER_REQUEST_CONTEXT_FLAGS)0x00000001; + private const uint NotifyForThisSession = 0; + private const nint PbtApmResumeAutomatic = 0x0012; + private const nint SpiSetWorkArea = 0x002F; + private const nint WtsConsoleConnect = 0x0001; + private const nint WtsRemoteConnect = 0x0003; + private const nint WtsSessionLogon = 0x0005; + private const nint WtsSessionUnlock = 0x0008; + private const string PowerRequestReason = "WinForms KioskModeManager: Preventing screen saver and sleep"; + + private static readonly object s_containerControlChangedEvent = new(); + private static readonly object s_fullScreenChangedEvent = new(); + private static readonly object s_wakeupEvent = new(); + + private ContainerControl? _containerControl; + private bool _containerControlExplicitlySet; + private Form? _targetForm; + private bool _isFullScreen; + private bool _pendingFullScreen; + private bool _initializing; + private bool _isCursorHidden; + private readonly List _parentChain = []; + private KioskModeMessageFilter? _messageFilter; + private KioskModeFormObserver? _formObserver; + private Timer? _mousePointerAutoHideTimer; + + private FormBorderStyle _savedBorderStyle; + private FormWindowState _savedWindowState; + private Rectangle _savedBounds; + private bool _savedTopMost; + + private bool _hideTaskbar; + private bool _topMostInFullScreen; + private Keys _toggleFullScreenKey = Keys.F11; + private bool _escapeExitsFullScreen = true; + private bool _suppressPowerSaving; + private int _mousePointerAutoHideDelay; + private ICommand? _wakeUpCommand; + + private HANDLE _powerRequestHandle; + + /// + /// Initializes a new instance of the class. + /// + public KioskModeManager() + { + } + + /// + /// Initializes a new instance of the class + /// and adds it to the specified container. + /// + /// The container that owns the component. + /// + /// is . + /// + public KioskModeManager(IContainer container) + { + ArgumentNullException.ThrowIfNull(container); + container.Add(this); + } + + /// + /// Gets or sets the associated with the component. + /// + /// + /// + /// When the component is sited at design time and + /// has not been set explicitly, the component resolves the root component of the + /// designer host (typically the owning or ) + /// and assigns it as the . An explicitly assigned + /// is never overwritten. + /// + /// + public override ISite? Site + { + get => base.Site; + set + { + base.Site = value; + + if (value is not null + && !_containerControlExplicitlySet + && value.GetService(typeof(IDesignerHost)) is IDesignerHost host + && host.RootComponent is ContainerControl root) + { + SetContainerControl(root, isExplicitAssignment: false); + } + } + } + + /// + /// Gets or sets the container whose containing form is controlled by this + /// component. + /// + /// + /// A that is either a or + /// a child container that can resolve a parent form; otherwise, + /// . + /// + /// + /// + /// This property intentionally uses rather + /// than . A can be placed + /// on a at design time, and that user control + /// can later be hosted by a form. Restricting the property to + /// would make that design-time scenario inconsistent. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerContainerControlDescr))] + [DefaultValue(null)] + public ContainerControl? ContainerControl + { + get => _containerControl; + set => SetContainerControl(value, isExplicitAssignment: true); + } + + /// + /// Occurs when the value of changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.KioskModeManagerContainerControlChangedDescr))] + public event EventHandler? ContainerControlChanged + { + add => Events.AddHandler(s_containerControlChangedEvent, value); + remove => Events.RemoveHandler(s_containerControlChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnContainerControlChanged(EventArgs e) + { + if (Events[s_containerControlChangedEvent] is EventHandler handler) + { + handler(this, e); + } + } + + /// + /// Gets or sets a value indicating whether fullscreen mode covers the + /// Windows taskbar. + /// + /// + /// to size the form to the complete screen bounds; + /// to use normal maximized form behavior. + /// The default is . + /// + /// + /// + /// When this property is , the form is maximized + /// and Windows keeps the taskbar available according to the user's shell + /// settings. When this property is , the form is + /// sized to the screen bounds so the kiosk surface covers the taskbar. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerHideTaskbarDescr))] + [DefaultValue(false)] + public bool HideTaskbar + { + get => _hideTaskbar; + set + { + if (_hideTaskbar == value) + { + return; + } + + _hideTaskbar = value; + + if (_isFullScreen && _targetForm is not null) + { + ApplyFullScreen(_targetForm); + } + } + } + + /// + /// Gets or sets a value indicating whether the form is topmost while + /// fullscreen. + /// + /// + /// to set while + /// fullscreen; otherwise, . The default is + /// . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerTopMostInFullScreenDescr))] + [DefaultValue(false)] + public bool TopMostInFullScreen + { + get => _topMostInFullScreen; + set + { + if (_topMostInFullScreen == value) + { + return; + } + + _topMostInFullScreen = value; + + if (_isFullScreen && _targetForm is not null) + { + _targetForm.TopMost = value; + } + } + } + + /// + /// Gets or sets the key that toggles fullscreen mode. + /// + /// + /// A value that is handled without modifiers. The + /// default is . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerToggleFullScreenKeyDescr))] + [DefaultValue(Keys.F11)] + public Keys ToggleFullScreenKey + { + get => _toggleFullScreenKey; + set + { + if (_toggleFullScreenKey == value) + { + return; + } + + _toggleFullScreenKey = value; + } + } + + /// + /// Gets or sets a value indicating whether pressing Escape exits + /// fullscreen mode. + /// + /// + /// to exit fullscreen mode when Escape is pressed + /// without modifiers; otherwise, . The default is + /// . + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerEscapeExitsFullScreenDescr))] + [DefaultValue(true)] + public bool EscapeExitsFullScreen + { + get => _escapeExitsFullScreen; + set + { + if (_escapeExitsFullScreen == value) + { + return; + } + + _escapeExitsFullScreen = value; + } + } + + /// + /// Gets or sets a value indicating whether the component requests that + /// Windows keep the display and system awake. + /// + /// + /// to request that Windows suppress display sleep + /// and system sleep; otherwise, . The default is + /// . + /// + /// + /// + /// This property uses the Windows power request APIs. It prevents ordinary + /// display and system idle sleep while enabled, but it does not override + /// every system policy and does not configure wake timers, Wake-on-LAN, or + /// voice activation. + /// + /// + /// + /// Windows could not create or activate the power request. + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerSuppressPowerSavingDescr))] + [DefaultValue(false)] + public bool SuppressPowerSaving + { + get => _suppressPowerSaving; + set + { + if (_suppressPowerSaving == value) + { + return; + } + + bool previousValue = _suppressPowerSaving; + _suppressPowerSaving = value; + + try + { + UpdatePowerRequest(); + } + catch + { + _suppressPowerSaving = previousValue; + throw; + } + } + } + + /// + /// Gets or sets the amount of time, in milliseconds, before the mouse + /// pointer is hidden while fullscreen. + /// + /// + /// The inactivity delay in milliseconds. A value of 0 disables automatic + /// pointer hiding. The default is 0. + /// + /// + /// + /// The timer is active only while the component is in fullscreen mode. + /// When the user moves the mouse after the component hid the pointer, the + /// pointer is shown immediately and the timer starts again. + /// + /// + /// + /// is less than 0. + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerMousePointerAutoHideDelayDescr))] + [DefaultValue(0)] + public int MousePointerAutoHideDelay + { + get => _mousePointerAutoHideDelay; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + + if (_mousePointerAutoHideDelay == value) + { + return; + } + + _mousePointerAutoHideDelay = value; + + if (value == 0) + { + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + } + else if (_isFullScreen) + { + RestartMousePointerAutoHideTimer(); + } + } + } + + /// + /// Gets or sets a value indicating whether the resolved form is currently + /// fullscreen. + /// + /// + /// if the component has placed the form in + /// fullscreen mode; otherwise, . + /// + /// + /// + /// Setting this property to enters fullscreen mode and + /// setting it to restores the previously saved form + /// state. The property is bindable so it can participate in two-way data + /// binding. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.KioskModeManagerFullScreenDescr))] + [Bindable(true)] + [DefaultValue(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public bool FullScreen + { + get => _pendingFullScreen || _isFullScreen; + set + { + if (!value) + { + _pendingFullScreen = false; + ExitFullScreen(); + } + else if (_initializing || DesignMode || !EnterFullScreen()) + { + _pendingFullScreen = true; + } + else + { + _pendingFullScreen = false; + } + } + } + + /// + /// Occurs when the fullscreen state changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.KioskModeManagerFullScreenChangedDescr))] + public event EventHandler? FullScreenChanged + { + add => Events.AddHandler(s_fullScreenChangedEvent, value); + remove => Events.RemoveHandler(s_fullScreenChangedEvent, value); + } + + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnFullScreenChanged(EventArgs e) + { + if (Events[s_fullScreenChangedEvent] is EventHandler handler) + { + handler(this, e); + } + } + + /// + /// Occurs when keyboard, mouse, power resume, or session activity wakes + /// the kiosk experience. + /// + /// + /// + /// The event reports activity that the component can observe on the UI + /// thread or through Windows power/session notifications. It does not mean + /// that the component caused the computer to wake from sleep, and it does + /// not configure voice wake, network wake, or wake timers. + /// + /// + [SRCategory(nameof(SR.CatAction))] + [SRDescription(nameof(SR.KioskModeManagerWakeupDescr))] + public event KioskModeWakeupEventHandler? Wakeup + { + add => Events.AddHandler(s_wakeupEvent, value); + remove => Events.RemoveHandler(s_wakeupEvent, value); + } + + /// + /// Raises the event. + /// + /// A that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnWakeup(KioskModeWakeupEventArgs e) + { + if (Events[s_wakeupEvent] is KioskModeWakeupEventHandler handler) + { + handler(this, e); + } + + if (_wakeUpCommand is not null) + { + // The wakeup source is passed as its string name so the bound command stays + // independent of the WinForms-specific enum type. + string parameter = e.Source.ToString(); + if (_wakeUpCommand.CanExecute(parameter)) + { + _wakeUpCommand.Execute(parameter); + } + } + } + + /// + /// Gets or sets the command that is executed whenever the + /// event is raised. + /// + /// + /// An to execute on every wakeup, or + /// to execute no command. The default is . + /// + /// + /// + /// The command is executed with the wakeup source name (the result of + /// .) as the command + /// parameter, keeping the command independent of the WinForms-specific enum type. + /// + /// + [SRCategory(nameof(SR.CatAction))] + [SRDescription(nameof(SR.KioskModeManagerWakeUpCommandDescr))] + [Bindable(true)] + [DefaultValue(null)] + public ICommand? WakeUpCommand + { + get => _wakeUpCommand; + set => _wakeUpCommand = value; + } + + /// + /// Places the resolved form in fullscreen mode. + /// + /// + /// +/// The component saves the form's border style, window state, bounds, and +/// topmost state before applying fullscreen mode. Call +/// again to restore the saved state. + /// + /// + private bool EnterFullScreen() + { + if (_isFullScreen || _initializing || DesignMode) + { + return _isFullScreen; + } + + Form? targetForm = ResolveTargetForm(); + if (targetForm is null) + { + return false; + } + + _savedBorderStyle = targetForm.FormBorderStyle; + _savedWindowState = targetForm.WindowState; + _savedBounds = targetForm.WindowState == FormWindowState.Normal + ? targetForm.Bounds + : targetForm.RestoreBounds; + _savedTopMost = targetForm.TopMost; + + ApplyFullScreen(targetForm); + SetFullScreenState(true); + _pendingFullScreen = false; + RestartMousePointerAutoHideTimer(); + return true; + } + + /// + /// Restores the form state that was saved when fullscreen mode was + /// entered. + /// + private void ExitFullScreen() + { + if (!_isFullScreen || _targetForm is null) + { + return; + } + + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + + _targetForm.FormBorderStyle = _savedBorderStyle; + _targetForm.TopMost = _savedTopMost; + _targetForm.WindowState = FormWindowState.Normal; + _targetForm.Bounds = _savedBounds; + _targetForm.WindowState = _savedWindowState; + + SetFullScreenState(false); + } + + /// + /// Updates the fullscreen state and raises + /// when the value changes. + /// + /// The new fullscreen state. + private void SetFullScreenState(bool value) + { + if (_isFullScreen == value) + { + return; + } + + _isFullScreen = value; + OnFullScreenChanged(EventArgs.Empty); + } + + /// + /// Toggles the resolved form between fullscreen and restored mode. + /// + /// + /// + /// Use to determine the current state before + /// calling this method when the application needs an explicit target + /// state. + /// + /// + public void ToggleFullScreen() + => FullScreen = !FullScreen; + + void ISupportInitialize.BeginInit() + { + _initializing = true; + } + + void ISupportInitialize.EndInit() + { + _initializing = false; + + AttachToForm(); + UpdatePowerRequest(); + + if (_pendingFullScreen) + { + FullScreen = true; + } + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + _pendingFullScreen = false; + ExitFullScreen(); + DetachFromForm(); + _mousePointerAutoHideTimer?.Dispose(); + _mousePointerAutoHideTimer = null; + ReleasePowerRequest(); + } + + base.Dispose(disposing); + } + + private void SetContainerControl(ContainerControl? value, bool isExplicitAssignment) + { + if (_containerControl == value) + { + if (isExplicitAssignment) + { + _containerControlExplicitlySet = true; + } + + return; + } + + if (_isFullScreen) + { + _pendingFullScreen = false; + ExitFullScreen(); + } + + if (value is null) + { + _pendingFullScreen = false; + } + + DetachFromForm(); + _containerControl = value; + if (isExplicitAssignment) + { + _containerControlExplicitlySet = true; + } + + AttachToForm(); + OnContainerControlChanged(EventArgs.Empty); + } + + private void AttachToForm() + { + if (_initializing || DesignMode || _containerControl is null) + { + return; + } + + UpdateParentChangedSubscriptions(); + EnsureMessageMonitoring(); + ResolveTargetForm(); + if (_pendingFullScreen && _targetForm is not null) + { + EnterFullScreen(); + } + } + + private void DetachFromForm() + { + ClearParentChangedSubscriptions(); + + if (_messageFilter is not null) + { + Application.RemoveMessageFilter(_messageFilter); + _messageFilter = null; + } + + _formObserver?.Detach(); + StopMousePointerAutoHideTimer(); + ShowMousePointerIfHidden(); + _targetForm = null; + } + + private void OnContainerControlParentChanged(object? sender, EventArgs e) + { + UpdateParentChangedSubscriptions(); + + Form? newTarget = _containerControl as Form ?? _containerControl?.FindForm(); + if (ReferenceEquals(_targetForm, newTarget)) + { + return; + } + + bool restoreFullScreen = _pendingFullScreen || _isFullScreen; + + if (_isFullScreen) + { + ExitFullScreen(); + } + + ResolveTargetForm(); + + if (restoreFullScreen) + { + FullScreen = true; + } + } + + private void UpdateParentChangedSubscriptions() + { + ClearParentChangedSubscriptions(); + + for (Control? current = _containerControl; current is not null; current = current.Parent) + { + current.ParentChanged += OnContainerControlParentChanged; + _parentChain.Add(current); + } + } + + private void ClearParentChangedSubscriptions() + { + foreach (Control control in _parentChain) + { + control.ParentChanged -= OnContainerControlParentChanged; + } + + _parentChain.Clear(); + } + + private Form? ResolveTargetForm() + { + // ContainerControl intentionally supports both Form and UserControl + // design-time placement. FindForm handles the UserControl case. + Form? targetForm = _containerControl as Form ?? _containerControl?.FindForm(); + if (!ReferenceEquals(_targetForm, targetForm)) + { + _targetForm = targetForm; + UpdateFormObserver(); + } + + return _targetForm; + } + + private void EnsureMessageMonitoring() + { + // IMessageFilter observes input for child controls without changing + // Form.KeyPreview or installing global keyboard/mouse hooks. + if (_messageFilter is null) + { + _messageFilter = new KioskModeMessageFilter(this); + Application.AddMessageFilter(_messageFilter); + } + } + + private void ApplyFullScreen(Form form) + { + Rectangle screenReferenceBounds = form.WindowState == FormWindowState.Normal + ? form.Bounds + : form.RestoreBounds; + Screen screen = Screen.FromRectangle(screenReferenceBounds); + Rectangle fullScreenBounds = _hideTaskbar + ? screen.Bounds + : screen.WorkingArea; + + // Apply fullscreen from a normal state so bounds and border changes do + // not operate on Windows' maximized window rectangle. + form.WindowState = FormWindowState.Normal; + form.FormBorderStyle = FormBorderStyle.None; + form.TopMost = _topMostInFullScreen; + form.Bounds = fullScreenBounds; + } + + private void RefreshFullScreenBounds() + { + if (_isFullScreen + && _targetForm is { IsDisposed: false } form) + { + ApplyFullScreen(form); + } + } + + private void UpdateFormObserver() + { + if (DesignMode) + { + return; + } + + _formObserver ??= new KioskModeFormObserver(this); + _formObserver.Attach(_targetForm); + } + + private void UpdatePowerRequest() + { + if (_initializing || DesignMode) + { + return; + } + + if (_suppressPowerSaving) + { + CreatePowerRequest(); + } + else + { + ReleasePowerRequest(); + } + } + + private void ProcessPowerBroadcast(WPARAM powerEvent) + { + if ((nint)powerEvent.Value == PbtApmResumeAutomatic) + { + RaiseWakeup(KioskModeWakeupSource.PowerResume); + } + } + + private void ProcessSessionChange(WPARAM sessionEvent) + { + nint value = (nint)sessionEvent.Value; + if (value is WtsConsoleConnect or WtsRemoteConnect or WtsSessionLogon or WtsSessionUnlock) + { + RaiseWakeup(KioskModeWakeupSource.Session); + } + } + + private void RestartMousePointerAutoHideTimer() + { + // Use a WinForms timer so cursor changes happen on the UI thread that + // receives the input messages restarting this timer. + if (!_isFullScreen || _mousePointerAutoHideDelay == 0) + { + return; + } + + _mousePointerAutoHideTimer ??= new Timer(); + _mousePointerAutoHideTimer.Stop(); + _mousePointerAutoHideTimer.Interval = _mousePointerAutoHideDelay; + _mousePointerAutoHideTimer.Tick -= OnMousePointerAutoHideTimerTick; + _mousePointerAutoHideTimer.Tick += OnMousePointerAutoHideTimerTick; + _mousePointerAutoHideTimer.Start(); + } + + private void StopMousePointerAutoHideTimer() + { + _mousePointerAutoHideTimer?.Stop(); + } + + private void OnMousePointerAutoHideTimerTick(object? sender, EventArgs e) + { + StopMousePointerAutoHideTimer(); + + if (!_isCursorHidden) + { + Cursor.Hide(); + _isCursorHidden = true; + } + } + + private void ProcessKeyboardActivity(Keys keyData, Keys modifiers, bool isRepeat) + { + // Any keyboard input wakes the kiosk experience, even when it does not + // match one of the configured fullscreen control keys. + RaiseWakeup(KioskModeWakeupSource.Keyboard); + + if (isRepeat) + { + return; + } + + Keys keyCode = keyData & Keys.KeyCode; + + if (keyCode == _toggleFullScreenKey && modifiers == Keys.None) + { + ToggleFullScreen(); + } + else if (keyCode == Keys.Escape && modifiers == Keys.None + && _escapeExitsFullScreen && _isFullScreen) + { + ExitFullScreen(); + } + } + + private void ProcessMouseActivity() + { + ShowMousePointerIfHidden(); + RestartMousePointerAutoHideTimer(); + RaiseWakeup(KioskModeWakeupSource.Mouse); + } + + private void ShowMousePointerIfHidden() + { + // Cursor.Hide and Cursor.Show are counter-based. Only balance hides + // performed by this component. + if (!_isCursorHidden) + { + return; + } + + Cursor.Show(); + _isCursorHidden = false; + } + + private void RaiseWakeup(KioskModeWakeupSource source) + => OnWakeup(new KioskModeWakeupEventArgs(source)); + + private unsafe void CreatePowerRequest() + { + // PowerClearRequest clears the active request, but only CloseHandle + // releases the native HANDLE returned by PowerCreateRequest. + if (!_powerRequestHandle.IsNull) + { + return; + } + + REASON_CONTEXT context = new() + { + Version = PowerRequestContextVersion, + Flags = PowerRequestContextSimpleString, + }; + + nint reasonString = Marshal.StringToHGlobalUni(PowerRequestReason); + + try + { + context.Reason.SimpleReasonString = (PWSTR)(char*)reasonString; + _powerRequestHandle = PInvoke.PowerCreateRequest(in context); + } + finally + { + Marshal.FreeHGlobal(reasonString); + } + + if (_powerRequestHandle.IsNull) + { + throw new Win32Exception(); + } + + try + { + if (!PInvoke.PowerSetRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestDisplayRequired) + || !PInvoke.PowerSetRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestSystemRequired)) + { + throw new Win32Exception(); + } + } + catch + { + ReleasePowerRequest(); + throw; + } + } + + private void ReleasePowerRequest() + { + if (_powerRequestHandle.IsNull) + { + return; + } + + PInvoke.PowerClearRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestDisplayRequired); + PInvoke.PowerClearRequest(_powerRequestHandle, POWER_REQUEST_TYPE.PowerRequestSystemRequired); + PInvoke.CloseHandle(_powerRequestHandle); + _powerRequestHandle = default; + } + + private bool IsMessageFromMonitoredControl(HWND hwnd) + { + if (_targetForm is not null + && !_targetForm.IsDisposed + && (hwnd == _targetForm.HWND || PInvoke.IsChild(_targetForm, hwnd))) + { + return true; + } + + return _containerControl is not null + && !_containerControl.IsDisposed + && _containerControl.IsHandleCreated + && (hwnd == _containerControl.HWND || PInvoke.IsChild(_containerControl, hwnd)); + } + + private bool ProcessMessage(Message message) + { + if (_containerControl is null || DesignMode) + { + return false; + } + + if (_targetForm is null || _targetForm.IsDisposed) + { + ResolveTargetForm(); + if (_pendingFullScreen && _targetForm is not null) + { + EnterFullScreen(); + } + } + + if (!IsMessageFromMonitoredControl(message.HWND)) + { + return false; + } + + if (message.MsgInternal == PInvokeCore.WM_KEYDOWN + || message.MsgInternal == PInvokeCore.WM_SYSKEYDOWN) + { + bool isRepeat = ((nuint)(nint)message.LParamInternal & (1u << 30)) != 0; + ProcessKeyboardActivity( + (Keys)(nint)message.WParamInternal, + Control.ModifierKeys & Keys.Modifiers, + isRepeat); + } + else if (message.MsgInternal >= PInvokeCore.WM_MOUSEFIRST + && message.MsgInternal <= PInvokeCore.WM_MOUSELAST) + { + ProcessMouseActivity(); + } + + return false; + } + + /// + /// Observes keyboard and mouse messages that belong to the target form. + /// + private sealed class KioskModeMessageFilter : IMessageFilter + { + private readonly KioskModeManager _owner; + + public KioskModeMessageFilter(KioskModeManager owner) + { + _owner = owner; + } + + public bool PreFilterMessage(ref Message m) + => _owner.ProcessMessage(m); + } + + /// + /// Observes power and session messages that belong to the target form. + /// + private sealed class KioskModeFormObserver : NativeWindow + { + private readonly KioskModeManager _owner; + private Form? _form; + private bool _sessionNotificationsRegistered; + + public KioskModeFormObserver(KioskModeManager owner) + { + _owner = owner; + } + + public void Attach(Form? form) + { + if (ReferenceEquals(_form, form)) + { + return; + } + + Detach(); + + if (form is null || form.IsDisposed) + { + return; + } + + _form = form; + form.HandleCreated += OnFormHandleCreated; + form.HandleDestroyed += OnFormHandleDestroyed; + + if (form.IsHandleCreated) + { + OnFormHandleCreated(form, EventArgs.Empty); + } + } + + public void Detach() + { + if (_form is not null) + { + _form.HandleCreated -= OnFormHandleCreated; + _form.HandleDestroyed -= OnFormHandleDestroyed; + _form = null; + } + + UnregisterSessionNotifications(); + if (!HWND.IsNull) + { + ReleaseHandle(); + } + } + + private void OnFormHandleCreated(object? sender, EventArgs e) + { + if (_form is null || _form.IsDisposed || !_form.IsHandleCreated) + { + return; + } + + AssignHandle(_form.HWND); + RegisterSessionNotifications(); + } + + private void OnFormHandleDestroyed(object? sender, EventArgs e) + { + UnregisterSessionNotifications(); + if (!HWND.IsNull) + { + ReleaseHandle(); + } + } + + private void RegisterSessionNotifications() + { + if (_sessionNotificationsRegistered || HWND.IsNull) + { + return; + } + + _sessionNotificationsRegistered = PInvoke.WTSRegisterSessionNotification(HWND, NotifyForThisSession); + } + + private void UnregisterSessionNotifications() + { + if (!_sessionNotificationsRegistered) + { + return; + } + + PInvoke.WTSUnRegisterSessionNotification(HWND); + _sessionNotificationsRegistered = false; + } + + protected override void WndProc(ref Message m) + { + if (m.MsgInternal == PInvokeCore.WM_POWERBROADCAST) + { + _owner.ProcessPowerBroadcast(m.WParamInternal); + } + else if (m.MsgInternal == PInvokeCore.WM_WTSSESSION_CHANGE) + { + _owner.ProcessSessionChange(m.WParamInternal); + } + + base.WndProc(ref m); + + if (m.MsgInternal == PInvokeCore.WM_DISPLAYCHANGE + || (m.MsgInternal == PInvokeCore.WM_SETTINGCHANGE + && (nint)m.WParamInternal == SpiSetWorkArea)) + { + _owner.RefreshFullScreenBounds(); + } + } + } +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs new file mode 100644 index 00000000000..1ad14d4fcc2 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventArgs.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides data for the event. +/// +/// +/// +/// The property identifies the kind of activity the +/// component observed. Applications can use this value to distinguish direct +/// user activity, such as mouse or keyboard input, from system activity such +/// as power resume or session unlock. +/// +/// +public class KioskModeWakeupEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the + /// class. + /// + /// The source of the wakeup notification. + public KioskModeWakeupEventArgs(KioskModeWakeupSource source) + { + SourceGenerated.EnumValidator.Validate(source, nameof(source)); + Source = source; + } + + /// + /// Gets the source of the wakeup notification. + /// + public KioskModeWakeupSource Source { get; } +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs new file mode 100644 index 00000000000..b2784bb3b3c --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupEventHandler.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Represents the method that will handle the +/// event. +/// +/// The source of the event. +/// A that contains the event data. +public delegate void KioskModeWakeupEventHandler(object? sender, KioskModeWakeupEventArgs e); +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs new file mode 100644 index 00000000000..6c9a05784c4 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Components/KioskModeManager/KioskModeWakeupSource.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Specifies the source of a +/// notification. +/// +public enum KioskModeWakeupSource +{ + /// + /// The wakeup notification was caused by keyboard activity. + /// + Keyboard = 0, + + /// + /// The wakeup notification was caused by mouse activity. + /// + Mouse = 1, + + /// + /// The wakeup notification was caused by the system resuming from a low + /// power state. + /// + PowerResume = 2, + + /// + /// The wakeup notification was caused by a Windows session activity, such + /// as logon or unlock. + /// + Session = 3, +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Timer.cs b/src/System.Windows.Forms/System/Windows/Forms/Components/Timer.cs similarity index 100% rename from src/System.Windows.Forms/System/Windows/Forms/Timer.cs rename to src/System.Windows.Forms/System/Windows/Forms/Components/Timer.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs new file mode 100644 index 00000000000..dad7898f68c --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.SuspendMutation.cs @@ -0,0 +1,129 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +public unsafe partial class Control : + ISupportSuspendPainting +#else +public unsafe partial class Control +#endif +{ +#if NET11_0_OR_GREATER + /// + /// Begins a painting suspension region for this control. + /// + void ISupportSuspendPainting.BeginSuspendPainting() => BeginSuspendPaintingCore(); + + /// + /// Ends a painting suspension region for this control. + /// + void ISupportSuspendPainting.EndSuspendPainting() + { + EndSuspendPaintingCore(); + CompleteRecursiveInvalidateAfterSuspendPainting(); + } + + /// + /// When overridden in a derived class, begins a painting suspension region for this control. + /// + /// + /// + /// The default implementation suppresses native painting through . + /// Controls that already have a public update mechanism (such as ) + /// should override this method to route through that existing mechanism instead of introducing a + /// second, competing suspension path. + /// + /// + protected virtual void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + /// When overridden in a derived class, ends a painting suspension region for this control. + /// + protected virtual void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } + + internal bool IsRecursiveInvalidateAfterSuspendPaintingRequested + => Properties.ContainsKey(s_recursiveInvalidateAfterSuspendPaintingProperty); + + internal int SuspendPaintingCount + => Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0); + + internal void RequestRecursiveInvalidateAfterSuspendPainting() + => Properties.AddValue(s_recursiveInvalidateAfterSuspendPaintingProperty, true); + + internal bool BeginSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault( + key: s_suspendPaintingCountProperty, + defaultValue: 0); + + Properties.AddOrRemoveValue( + key: s_suspendPaintingCountProperty, + value: suspendPaintingCount + 1, + defaultValue: 0); + + return true; + } + + internal bool EndSuspendPaintingScope() + { + int suspendPaintingCount = Properties.GetValueOrDefault( + key: s_suspendPaintingCountProperty, + defaultValue: 0); + + if (suspendPaintingCount == 0) + { + return false; + } + + Properties.AddOrRemoveValue( + s_suspendPaintingCountProperty, + suspendPaintingCount - 1, + defaultValue: 0); + + return true; + } + + internal void ResumeLayoutAfterSuspendPainting() + { + byte layoutSuspendCount = LayoutSuspendCount; + + try + { + ResumeLayout(); + } + catch + { + if (LayoutSuspendCount == layoutSuspendCount && LayoutSuspendCount > 0) + { + LayoutSuspendCount--; + } + + throw; + } + } + + private void CompleteRecursiveInvalidateAfterSuspendPainting() + { + if (Properties.GetValueOrDefault(s_suspendPaintingCountProperty, 0) != 0 + || !Properties.ContainsKey(s_recursiveInvalidateAfterSuspendPaintingProperty)) + { + return; + } + + Properties.RemoveValue(s_recursiveInvalidateAfterSuspendPaintingProperty); + Invalidate(invalidateChildren: true); + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.VisualStylesMode.Docs.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.VisualStylesMode.Docs.cs new file mode 100644 index 00000000000..61025e7a904 --- /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 for the control. When not explicitly set, the value is inherited + /// from the parent control, or, for a top-level control, from . + /// + /// + /// + /// A control without an explicit value inherits from its parent 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..60451cd3234 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -146,6 +146,8 @@ public unsafe partial class Control : private protected static readonly object s_paddingChangedEvent = new(); private static readonly object s_previewKeyDownEvent = new(); private static readonly object s_dataContextEvent = new(); + private static readonly object s_systemVisualSettingsChangedEvent = new(); + private static readonly object s_visualStylesModeChangedEvent = new(); private static MessageId s_threadCallbackMessage; private static ContextCallback? s_invokeMarshaledCallbackHelperDelegate; @@ -224,9 +226,18 @@ 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(); + private static readonly int s_systemVisualSettingsProperty = PropertyStore.CreateKey(); + private static readonly int s_systemVisualSettingsRefreshPendingProperty = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_recursiveInvalidateAfterSuspendPaintingProperty = PropertyStore.CreateKey(); + private static readonly int s_suspendPaintingCountProperty = PropertyStore.CreateKey(); +#endif + private static readonly int s_updateCountProperty = PropertyStore.CreateKey(); private static bool s_needToLoadComCtl = true; @@ -268,7 +279,6 @@ public unsafe partial class Control : // bits 0-4: BoundsSpecified stored in RequiredScaling property. Bit 5: RequiredScalingEnabled property. private byte _requiredScaling; private TRACKMOUSEEVENT _trackMouseEvent; - private short _updateCount; private LayoutEventArgs? _cachedLayoutEventArgs; private Queue? _threadCallbackList; @@ -824,6 +834,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 +878,147 @@ private bool ShouldSerializeDataContext() private void ResetDataContext() => Properties.RemoveValue(s_dataContextProperty); + public virtual partial VisualStylesMode VisualStylesMode + { + get + { + if (!Properties.TryGetValue(s_visualStylesModeProperty, out VisualStylesMode value) + || value == VisualStylesMode.Inherit) + { + value = ParentInternal?.VisualStylesMode ?? DefaultVisualStylesMode; + } + + return value; + } + 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 oldValue = 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.VisualStylesMode == value)) + { + Properties.RemoveValue(s_visualStylesModeProperty); + } + else + { + Properties.AddValue(s_visualStylesModeProperty, value); + } + + if (oldValue != VisualStylesMode && oldEffectiveValue != EffectiveVisualStylesMode) + { + RaiseVisualStylesModeChanged(oldEffectiveValue, EffectiveVisualStylesMode); + } + } + } + + private bool ShouldSerializeVisualStylesMode() + => Properties.TryGetValue( + s_visualStylesModeProperty, + out VisualStylesMode value) + && value != VisualStylesMode.Inherit; + + private void ResetVisualStylesMode() + => VisualStylesMode = VisualStylesMode.Inherit; + + /// + /// 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 raw + /// value. + /// + /// + /// + /// 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 route the changed effective + /// value through . + /// + /// + protected VisualStylesMode EffectiveVisualStylesMode + => GetEffectiveVisualStylesMode(IsHighContrast); + + private VisualStylesMode GetEffectiveVisualStylesMode(bool highContrast) + => VisualStylesMode is VisualStylesMode.Disabled + ? VisualStylesMode.Disabled + : highContrast + ? VisualStylesMode.Classic + : VisualStylesMode; + + /// + /// 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 + => SystemVisualSettingsTracker.CurrentSettings.HighContrastEnabled; + + /// + /// 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, + } + + /// + /// 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 +3902,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 @@ -4188,6 +4364,24 @@ public event EventHandler? SystemColorsChanged remove => Events.RemoveHandler(s_systemColorsChangedEvent, value); } + /// + /// Occurs when a Windows visual or accessibility setting changes. + /// + /// + /// + /// This instance event is the leak-free default for controls and forms. Unlike + /// , it does not require + /// unsubscription when the control is disposed. + /// + /// + [SRCategory(nameof(SR.CatBehavior))] + [SRDescription(nameof(SR.ControlOnSystemVisualSettingsChangedDescr))] + public event SystemVisualSettingsChangedEventHandler? SystemVisualSettingsChanged + { + add => Events.AddHandler(s_systemVisualSettingsChangedEvent, value); + remove => Events.RemoveHandler(s_systemVisualSettingsChangedEvent, value); + } + /// /// Occurs when the control is validating. /// @@ -4246,6 +4440,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 +4486,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. @@ -4374,17 +4574,16 @@ public IAsyncResult BeginInvoke(Delegate method, params object?[]? args) internal void BeginUpdateInternal() { - if (!IsHandleCreated) - { - return; - } - - if (_updateCount == 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + if (updateCount == 0 && IsHandleCreated) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); } - _updateCount++; + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount + 1, + defaultValue: 0); } /// @@ -5070,13 +5269,21 @@ public void DrawToBitmap(Bitmap bitmap, Rectangle targetBounds) internal bool EndUpdateInternal(bool invalidate) { - if (_updateCount > 0) + int updateCount = Properties.GetValueOrDefault(s_updateCountProperty, 0); + + if (updateCount > 0) { - Debug.Assert(IsHandleCreated, "Handle should be created by now"); - _updateCount--; - if (_updateCount == 0) + updateCount--; + + Properties.AddOrRemoveValue( + s_updateCountProperty, + updateCount, + defaultValue: 0); + + if (updateCount == 0 && IsHandleCreated) { PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)true); + if (invalidate) { Invalidate(); @@ -5349,7 +5556,8 @@ private static bool IsFocusManagingContainerControl(Control ctl) /// by calling "WM_SETREDRAW" even if the control in "Begin - End" update cycle. Using this Function we can guard /// against repetitively redrawing the control. /// - internal bool IsUpdating() => _updateCount > 0; + internal bool IsUpdating() + => Properties.GetValueOrDefault(s_updateCountProperty, 0) > 0; /// /// This is a helper method that is called by ScaleControl to retrieve the bounds @@ -6829,6 +7037,141 @@ 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; + 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)) + { + CascadeVisualStylesModeChanged(children, e, transition); + } + } + + private static void CascadeVisualStylesModeChanged( + Control.ControlCollection children, + EventArgs e, + VisualStylesModeChangeEventArgs? transition) + { + 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 +7385,230 @@ 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 (e is VisualStylesModeChangeEventArgs highContrastTransition + && highContrastTransition.SystemVisualSettingsTransition is not null) + { + if (!highContrastTransition.IsCurrent) + { + return; + } + + VisualStylesModeChangeEventArgs transitionForControl = + highContrastTransition.CreateForControl(this); + if (transitionForControl.OldEffectiveVisualStylesMode + != transitionForControl.NewEffectiveVisualStylesMode) + { + OnVisualStylesModeChanged(transitionForControl); + } + else if (ChildControls is { } children) + { + CascadeVisualStylesModeChanged(children, transitionForControl, transitionForControl); + } + + return; + } + + if (Properties.ContainsKey(s_visualStylesModeProperty)) + { + if (Properties.GetValueOrDefault(s_visualStylesModeProperty) == Parent?.VisualStylesMode) + { + // 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 readonly VisualStylesModeChangeState _state; + + public VisualStylesModeChangeEventArgs( + Control source, + int sourceChangeVersion, + VisualStylesMode oldEffectiveVisualStylesMode, + VisualStylesMode newEffectiveVisualStylesMode, + SystemVisualSettingsChangedEventArgs? systemVisualSettingsTransition = null) + : this( + new VisualStylesModeChangeState(source, sourceChangeVersion), + source, + sourceChangeVersion, + oldEffectiveVisualStylesMode, + newEffectiveVisualStylesMode, + systemVisualSettingsTransition) + { + } + + private VisualStylesModeChangeEventArgs( + VisualStylesModeChangeState state, + Control control, + int controlChangeVersion, + VisualStylesMode oldEffectiveVisualStylesMode, + VisualStylesMode newEffectiveVisualStylesMode, + SystemVisualSettingsChangedEventArgs? systemVisualSettingsTransition) + { + _state = state; + Control = control; + ControlChangeVersion = controlChangeVersion; + OldEffectiveVisualStylesMode = oldEffectiveVisualStylesMode; + NewEffectiveVisualStylesMode = newEffectiveVisualStylesMode; + SystemVisualSettingsTransition = systemVisualSettingsTransition; + } + + private Control Control { get; } + + private int ControlChangeVersion { get; } + + public VisualStylesMode NewEffectiveVisualStylesMode { get; } + + public VisualStylesMode OldEffectiveVisualStylesMode { get; } + + public SystemVisualSettingsChangedEventArgs? SystemVisualSettingsTransition { get; } + + public bool IsCurrent + => _state.Source.Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0) + == _state.SourceChangeVersion + && Control.Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0) + == ControlChangeVersion; + + public VisualStylesModeChangeEventArgs CreateForControl(Control control) + { + if (SystemVisualSettingsTransition is not { } systemVisualSettingsTransition) + { + return this; + } + + return new( + _state, + control, + control.Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0), + control.GetEffectiveVisualStylesMode(systemVisualSettingsTransition.OldSettings.HighContrastEnabled), + control.GetEffectiveVisualStylesMode(systemVisualSettingsTransition.NewSettings.HighContrastEnabled), + systemVisualSettingsTransition); + } + + public void PerformLayouts() + { + if (_state.LayoutsPerformed || _state.LayoutRequests is null) + { + return; + } + + _state.LayoutsPerformed = true; + + for (int i = _state.LayoutRequests.Count - 1; i >= 0; i--) + { + _state.LayoutRequests[i].Transaction.Dispose(); + } + + _state.LayoutRequests.Sort( + static (left, right) => GetLayoutDepth(right.Target).CompareTo(GetLayoutDepth(left.Target))); + + foreach (VisualStylesModeLayoutRequest request in _state.LayoutRequests) + { + LayoutTransaction.DoLayout(request.Target, request.Cause, PropertyNames.VisualStylesMode); + } + } + + public void RequestLayout(Control? target, Control cause) + { + if (target is null) + { + return; + } + + _state.LayoutRequests ??= []; + + foreach (VisualStylesModeLayoutRequest request in _state.LayoutRequests) + { + if (ReferenceEquals(request.Target, target)) + { + return; + } + } + + _state.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; + } + + private sealed class VisualStylesModeChangeState( + Control source, + int sourceChangeVersion) + { + public List? LayoutRequests; + public bool LayoutsPerformed; + public int SourceChangeVersion { get; } = sourceChangeVersion; + public Control Source { get; } = source; + } + } + + /// + /// 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) { @@ -7292,6 +7859,8 @@ protected virtual void OnParentChanged(EventArgs e) { OnTopMostActiveXParentChanged(EventArgs.Empty); } + + ResynchronizeSystemVisualSettings(); } /// @@ -7407,6 +7976,11 @@ protected virtual void OnHandleCreated(EventArgs e) pszSubAppName: $"{DarkModeIdentifier}_{ExplorerThemeIdentifier}", pszSubIdList: null); } + + if (IsUpdating()) + { + PInvokeCore.SendMessage(this, PInvokeCore.WM_SETREDRAW, (WPARAM)(BOOL)false); + } } ((EventHandler?)Events[s_handleCreatedEvent])?.Invoke(this, e); @@ -7420,6 +7994,8 @@ protected virtual void OnHandleCreated(EventArgs e) PInvokeCore.PostMessage(this, s_threadCallbackMessage); SetState(States.ThreadMarshalPending, false); } + + ResynchronizeSystemVisualSettings(); } void HandleHighDpi() @@ -8111,6 +8687,127 @@ protected virtual void OnSystemColorsChanged(EventArgs e) ((EventHandler?)Events[s_systemColorsChangedEvent])?.Invoke(this, e); } + /// + /// Raises the event. + /// + /// The event data. + /// + /// + /// The cascade reaches parented controls only. A control created before it is parented can + /// miss a transition and should query when + /// it creates a handle or is parented. + /// + /// + /// A High Contrast transition also routes each changed effective + /// through . + /// Controls whose effective mode is unchanged do not receive a visual-styles transition. + /// + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + { + if ((e.Changed & SystemVisualSettingsCategories.HighContrast) != 0 + && ParentInternal is null) + { + RaiseHighContrastVisualStylesModeChanged(e); + } + + Properties.AddOrRemoveValue(s_systemVisualSettingsProperty, e.NewSettings); + + if ((e.Changed & SystemVisualSettingsCategories.AccentColor) != 0) + { + Invalidate(); + } + + if (ChildControls is { } children) + { + for (int i = 0; i < children.Count; i++) + { + children[i].OnSystemVisualSettingsChanged(e); + } + } + + ((SystemVisualSettingsChangedEventHandler?)Events[s_systemVisualSettingsChangedEvent])?.Invoke(this, e); + } + + private void RaiseHighContrastVisualStylesModeChanged(SystemVisualSettingsChangedEventArgs e) + { + VisualStylesMode oldEffectiveVisualStylesMode = + GetEffectiveVisualStylesMode(e.OldSettings.HighContrastEnabled); + VisualStylesMode newEffectiveVisualStylesMode = + GetEffectiveVisualStylesMode(e.NewSettings.HighContrastEnabled); + int changeVersion = Properties.GetValueOrDefault(s_visualStylesModeChangeVersionProperty, 0) + 1; + Properties.AddValue(s_visualStylesModeChangeVersionProperty, changeVersion); + + VisualStylesModeChangeEventArgs transition = new( + this, + changeVersion, + oldEffectiveVisualStylesMode, + newEffectiveVisualStylesMode, + e); + + try + { + if (oldEffectiveVisualStylesMode != newEffectiveVisualStylesMode) + { + OnVisualStylesModeChanged(transition); + } + else if (ChildControls is { } children) + { + CascadeVisualStylesModeChanged(children, transition, transition); + } + } + finally + { + transition.PerformLayouts(); + } + } + + private void QueueSystemVisualSettingsRefresh() + { + if (!GetTopLevel() + || !IsHandleCreated + || Disposing + || IsDisposed + || Properties.GetValueOrDefault(s_systemVisualSettingsRefreshPendingProperty)) + { + return; + } + + Properties.AddOrRemoveValue(s_systemVisualSettingsRefreshPendingProperty, true, defaultValue: false); + BeginInvoke((Action)ProcessSystemVisualSettingsChange); + } + + internal void ProcessSystemVisualSettingsChange() + { + Properties.AddOrRemoveValue(s_systemVisualSettingsRefreshPendingProperty, false, defaultValue: false); + + if (!GetTopLevel() || Disposing || IsDisposed) + { + return; + } + + SystemVisualSettings newSettings = SystemVisualSettingsTracker.Refresh(); + SystemVisualSettings? oldSettings = Properties.GetValueOrDefault(s_systemVisualSettingsProperty); + if (oldSettings is null) + { + ResynchronizeSystemVisualSettings(); + return; + } + + SystemVisualSettingsCategories changed = SystemVisualSettingsTracker.GetChangedCategories(oldSettings, newSettings); + if (changed != SystemVisualSettingsCategories.None) + { + OnSystemVisualSettingsChanged( + new SystemVisualSettingsChangedEventArgs(oldSettings, newSettings, changed)); + } + } + + private void ResynchronizeSystemVisualSettings() + => Properties.AddOrRemoveValue( + s_systemVisualSettingsProperty, + Application.SystemVisualSettings); + /// /// Raises the event. /// @@ -12649,14 +13346,22 @@ protected virtual void WndProc(ref Message m) } } + QueueSystemVisualSettingsRefresh(); break; + case PInvokeCore.WM_DWMCOLORIZATIONCOLORCHANGED: case PInvokeCore.WM_SYSCOLORCHANGE: if (GetExtendedState(ExtendedStates.InterestedInUserPreferenceChanged) && GetTopLevel()) { OnSystemColorsChanged(EventArgs.Empty); } + QueueSystemVisualSettingsRefresh(); + break; + + case PInvokeCore.WM_THEMECHANGED: + DefWndProc(ref m); + QueueSystemVisualSettingsRefresh(); break; case PInvokeCore.WM_EXITMENULOOP: diff --git a/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs new file mode 100644 index 00000000000..963199a4782 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ControlMutationExtensions.cs @@ -0,0 +1,57 @@ +// 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; + +using System.ComponentModel; + +#if NET11_0_OR_GREATER +/// +/// Provides extension methods for batching synchronous WinForms UI mutations. +/// +public static class ControlMutationExtensions +{ + /// + /// Suspends painting for the specified target until the returned scope is disposed. + /// + /// The target whose painting should be suspended. + /// A scope that resumes painting when disposed. + public static SuspendPaintingScope SuspendPainting(this ISupportSuspendPainting target) + => new(target); + + /// + /// Suspends painting and the selected layout work until the returned scope is disposed. + /// + /// The target whose painting and layout should be suspended. + /// + /// A value that specifies which controls in the target's control tree should suspend layout. + /// + /// A scope that resumes layout and painting when disposed. + /// is . + /// + /// is not a valid value. + /// + /// is not a . + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + => new(target, layoutSuspendTraversal); + + /// + /// Suspends painting and layout for selected controls until the returned scope is disposed. + /// + /// The target whose painting and selected layout should be suspended. + /// + /// A predicate that returns for each control whose layout should be suspended. + /// + /// A scope that resumes layout and painting when disposed. + /// + /// or is . + /// + /// is not a . + public static SuspendPaintingScope SuspendPainting( + this ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + => new(target, suspendLayoutContainerFilter); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/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..db9d2977647 --- /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.SystemVisualSettings.AccentColor; + + 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.SystemVisualSettings.AccentColor, AccentBlendAmount); + + internal static Color GetDefaultButtonColor(PushButtonState state) + => GetDefaultButtonColor(Application.SystemVisualSettings.AccentColor, 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..82ecce54546 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.Modern.cs @@ -0,0 +1,541 @@ +// 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 Rectangle _modernEditAppliedBounds; + private Rectangle _modernEditBaseBounds; + private Size _modernEditBaseClientSize; + private bool _adjustingModernChildBounds; + private bool _modernFieldHeightApplied; + private bool _modernHandleInitialized; + private Rectangle _modernSimpleListBaseBounds; + + internal bool UsesModernComboAdapter + => EffectiveVisualStylesMode >= VisualStylesMode.Net11 + && FlatStyle != FlatStyle.System; + + private bool UsesComboAdapter + => UsesModernComboAdapter + || FlatStyle is FlatStyle.Flat or FlatStyle.Popup; + + private int ModernPreferredHeight + { + get + { + return ModernControlVisualStyles.GetPreferredFieldHeight( + FontHeight, + GetModernFieldPadding(), + DeviceDpiInternal); + } + } + + private void ResetComboAdapter() + => Properties.RemoveValue(s_propFlatComboAdapter); + + private void ApplyPreferredFieldHeight() + { + if (DropDownStyle == ComboBoxStyle.Simple) + { + return; + } + + bool usesModernMetrics = UsesModernComboAdapter; + if (!usesModernMetrics + && !_modernFieldHeightApplied) + { + return; + } + + int preferredHeight = PreferredHeight; + int previousHeight = Height; + if (IsHandleCreated) + { + int currentSelectionHeight = (int)PInvokeCore.SendMessage( + this, + PInvoke.CB_GETITEMHEIGHT, + (WPARAM)(-1)); + int heightDelta = preferredHeight - Height; + if (currentSelectionHeight >= 0 + && heightDelta != 0) + { + PInvokeCore.SendMessage( + this, + PInvoke.CB_SETITEMHEIGHT, + (WPARAM)(-1), + (LPARAM)Math.Max( + 1, + currentSelectionHeight + heightDelta)); + } + } + + if (Height != preferredHeight) + { + Height = preferredHeight; + } + + if (Height != previousHeight + && ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.Bounds); + } + + _modernFieldHeightApplied = usesModernMetrics; + } + + private unsafe void UpdateModernEditMargins() + { + if (!_modernHandleInitialized + || !IsHandleCreated + || DropDownStyle == ComboBoxStyle.DropDownList + || (!UsesModernComboAdapter + && _modernEditBaseBounds.IsEmpty)) + { + return; + } + + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + if (!PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo) + || comboBoxInfo.hwndItem.IsNull) + { + return; + } + + int styleMargin = UsesModernComboAdapter + ? ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.Fixed3DBorderPadding + + ModernControlVisualStyles.InternalChromeInset + + ModernControlVisualStyles.ComboBoxStyleInset, + DeviceDpiInternal) + : 0; + int leftMargin = styleMargin + + (UsesModernComboAdapter ? Padding.Left : 0); + int rightMargin = styleMargin + + (UsesModernComboAdapter ? Padding.Right : 0); + PInvokeCore.SendMessage( + comboBoxInfo.hwndItem, + PInvokeCore.EM_SETMARGINS, + (WPARAM)(PInvoke.EC_LEFTMARGIN | PInvoke.EC_RIGHTMARGIN), + LPARAM.MAKELPARAM(leftMargin, rightMargin)); + + UpdateModernEditBounds(comboBoxInfo); + } + + private void UpdateModernEditBounds( + COMBOBOXINFO comboBoxInfo) + { + int styleInset = UsesModernComboAdapter + ? ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.ComboBoxStyleInset, + DeviceDpiInternal) + : 0; + if (DropDownStyle == ComboBoxStyle.Simple) + { + UpdateModernSimpleEditBounds( + comboBoxInfo, + styleInset); + return; + } + + int topInset = styleInset + + (UsesModernComboAdapter ? Padding.Top : 0); + int bottomInset = styleInset + + (UsesModernComboAdapter ? Padding.Bottom : 0); + Rectangle itemBounds = _modernEditBaseBounds.IsEmpty + ? comboBoxInfo.rcItem + : _modernEditBaseBounds; + if (!_modernEditBaseBounds.IsEmpty + && _modernEditBaseClientSize != ClientSize) + { + itemBounds.X = comboBoxInfo.rcItem.left; + itemBounds.Width = comboBoxInfo.rcItem.Width; + } + + Rectangle editBounds = new( + itemBounds.Left, + itemBounds.Top + topInset, + itemBounds.Width, + Math.Max( + 1, + itemBounds.Height - topInset - bottomInset)); + + PInvoke.SetWindowPos( + comboBoxInfo.hwndItem, + HWND.Null, + editBounds.Left, + editBounds.Top, + editBounds.Width, + editBounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + _modernEditBaseBounds = itemBounds; + _modernEditBaseClientSize = ClientSize; + _modernEditAppliedBounds = editBounds; + } + + private void UpdateModernSimpleEditBounds( + COMBOBOXINFO comboBoxInfo, + int styleInset) + { + if (_modernEditBaseBounds.IsEmpty) + { + _modernEditBaseBounds = GetChildBounds( + comboBoxInfo.hwndItem); + _modernSimpleListBaseBounds = GetChildBounds( + comboBoxInfo.hwndList); + _modernEditBaseClientSize = ClientSize; + } + + int topInset = styleInset + + (UsesModernComboAdapter ? Padding.Top : 0); + int bottomInset = styleInset + + (UsesModernComboAdapter ? Padding.Bottom : 0); + Rectangle editBounds = _modernEditBaseBounds; + editBounds.Y += topInset; + Rectangle listBounds = _modernSimpleListBaseBounds; + listBounds.Y = editBounds.Bottom + bottomInset; + listBounds.Height = Math.Max( + 1, + _modernSimpleListBaseBounds.Bottom - listBounds.Top); + + _adjustingModernChildBounds = true; + try + { + PInvoke.SetWindowPos( + comboBoxInfo.hwndItem, + HWND.Null, + editBounds.Left, + editBounds.Top, + 0, + 0, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOSIZE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + PInvoke.SetWindowPos( + comboBoxInfo.hwndList, + HWND.Null, + listBounds.Left, + listBounds.Top, + listBounds.Width, + listBounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + } + finally + { + _adjustingModernChildBounds = false; + } + + _modernEditAppliedBounds = editBounds; + } + + private unsafe void RestoreModernEditBounds() + { + if (!_modernHandleInitialized + || !IsHandleCreated + || _modernEditBaseBounds.IsEmpty) + { + return; + } + + COMBOBOXINFO comboBoxInfo = default; + comboBoxInfo.cbSize = (uint)sizeof(COMBOBOXINFO); + if (!PInvoke.GetComboBoxInfo( + HWND, + ref comboBoxInfo)) + { + return; + } + + _adjustingModernChildBounds = true; + try + { + PInvoke.SetWindowPos( + comboBoxInfo.hwndItem, + HWND.Null, + _modernEditBaseBounds.Left, + _modernEditBaseBounds.Top, + _modernEditBaseBounds.Width, + _modernEditBaseBounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + if (DropDownStyle == ComboBoxStyle.Simple + && !_modernSimpleListBaseBounds.IsEmpty) + { + PInvoke.SetWindowPos( + comboBoxInfo.hwndList, + HWND.Null, + _modernSimpleListBaseBounds.Left, + _modernSimpleListBaseBounds.Top, + _modernSimpleListBaseBounds.Width, + _modernSimpleListBaseBounds.Height, + SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE + | SET_WINDOW_POS_FLAGS.SWP_NOZORDER); + } + } + finally + { + _adjustingModernChildBounds = false; + } + } + + private void RestoreAndResetModernEditBounds() + { + RestoreModernEditBounds(); + ResetModernEditBounds(); + } + + private void ResizeModernSimpleBaseBounds() + { + if (_modernEditBaseBounds.IsEmpty + || _modernEditBaseClientSize.IsEmpty + || _modernEditBaseClientSize == ClientSize) + { + return; + } + + int widthDelta = ClientSize.Width + - _modernEditBaseClientSize.Width; + int heightDelta = ClientSize.Height + - _modernEditBaseClientSize.Height; + _modernEditBaseBounds.Width = Math.Max( + 1, + _modernEditBaseBounds.Width + widthDelta); + _modernSimpleListBaseBounds.Width = Math.Max( + 1, + _modernSimpleListBaseBounds.Width + widthDelta); + _modernSimpleListBaseBounds.Height = Math.Max( + 1, + _modernSimpleListBaseBounds.Height + heightDelta); + _modernEditBaseClientSize = ClientSize; + } + + 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 void ResetModernEditBounds() + { + _modernEditAppliedBounds = Rectangle.Empty; + _modernEditBaseBounds = Rectangle.Empty; + _modernEditBaseClientSize = Size.Empty; + _modernSimpleListBaseBounds = Rectangle.Empty; + } + + private Padding GetModernFieldPadding() + { + SystemVisualSettings settings = Application.SystemVisualSettings; + int styleInset = ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.ComboBoxStyleInset, + DeviceDpiInternal); + + return ModernControlVisualStyles.GetFieldPadding( + BorderStyle.Fixed3D, + Padding + new Padding(styleInset), + settings.FocusBorderMetrics, + settings.TextScaleFactor, + DeviceDpiInternal); + } + + 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(); + base.OnVisualStylesModeChanged(e); + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); + RefreshModernDropDownCornerPreference(); + } + + /// + protected override void OnSystemVisualSettingsChanged( + SystemVisualSettingsChangedEventArgs e) + { + base.OnSystemVisualSettingsChanged(e); + + if (!UsesModernComboAdapter) + { + return; + } + + if ((e.Changed + & (SystemVisualSettingsCategories.TextScale + | SystemVisualSettingsCategories.FocusMetrics)) != 0) + { + RestoreAndResetModernEditBounds(); + ResetComboAdapter(); + ResetHeightCache(); + CommonProperties.xClearPreferredSizeCache(this); + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); + 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); + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); + Invalidate(); + } + + /// + protected override void OnLayout(LayoutEventArgs levent) + { + base.OnLayout(levent); + + if (UsesModernComboAdapter + && DropDownStyle == ComboBoxStyle.Simple) + { + UpdateModernEditMargins(); + } + } + + /// + /// + /// + /// Net11 and later share ComboBox field metrics. Crossing the classic or disabled + /// boundary changes preferred height for WinForms-painted styles; modern-to-modern + /// transitions 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.Metrics + : VisualStylesModeChangeImpact.Repaint; + } + + /// + protected override void RescaleConstantsForDpi( + int deviceDpiOld, + int deviceDpiNew) + { + RestoreAndResetModernEditBounds(); + ResetComboAdapter(); + ResetHeightCache(); + base.RescaleConstantsForDpi(deviceDpiOld, deviceDpiNew); + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); + } +} 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..e4302988e76 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.ModernComboAdapter.cs @@ -0,0 +1,480 @@ +// 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); + _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; + } + + ClearNativeFrame( + 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 ClearNativeFrame( + ComboBox comboBox, + Graphics graphics, + Rectangle bounds) + { + int thickness = Math.Max( + 1, + ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.Fixed3DBorderPadding, + comboBox.DeviceDpiInternal)); + Color background = GetEffectiveBackColor(comboBox); + using var brush = background.GetCachedSolidBrushScope(); + graphics.FillRectangle( + brush, + bounds.Left, + bounds.Top, + bounds.Width, + Math.Min(thickness, bounds.Height)); + graphics.FillRectangle( + brush, + bounds.Left, + Math.Max(bounds.Top, bounds.Bottom - thickness), + bounds.Width, + Math.Min(thickness, bounds.Height)); + graphics.FillRectangle( + brush, + bounds.Left, + bounds.Top, + Math.Min(thickness, bounds.Width), + bounds.Height); + graphics.FillRectangle( + brush, + Math.Max(bounds.Left, bounds.Right - thickness), + bounds.Top, + Math.Min(thickness, bounds.Width), + bounds.Height); + } + + 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); + } + + 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 = Math.Clamp( + ScaleHelper.ScaleToDpi( + ModernControlVisualStyles.FieldCornerRadius, + comboBox.DeviceDpiInternal), + 1, + Math.Max( + 1, + Math.Min(bounds.Width, bounds.Height))); + path.AddRoundedRectangle( + bounds, + new Size(radius, radius)); + + return path; + } + + 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.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs new file mode 100644 index 00000000000..26590de34aa --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ComboBox +{ +#if NET11_0_OR_GREATER + /// + protected override void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + protected override void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ComboBox/ComboBox.cs index 5e4422eaf9c..1b17034a130 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 @@ -495,24 +495,43 @@ 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); + ApplyPreferredFieldHeight(); + LayoutTransaction.DoLayout( + this, + this, + PropertyNames.FlatStyle); + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout( + parent, + this, + PropertyNames.FlatStyle); + } + } + + UpdateModernEditMargins(); + RefreshModernDropDownCornerPreference(); Invalidate(); } } @@ -720,7 +739,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 +750,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,6 +794,12 @@ public int PreferredHeight { get { + if (UsesModernComboAdapter + && DropDownStyle != ComboBoxStyle.Simple) + { + return ModernPreferredHeight; + } + if (!FormattingEnabled) { // do preferred height the old broken way for everett apps @@ -1575,10 +1618,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)); + UpdateModernEditMargins(); } break; @@ -1855,7 +1895,9 @@ protected override void Dispose(bool disposing) /// beginUpdate(), any redrawing caused by operations performed on the /// combo box is deferred until the call to endUpdate(). /// - public unsafe void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private unsafe void EndUpdate(bool invalidate) { _updateCount--; if (_updateCount == 0 && AutoCompleteSource == AutoCompleteSource.ListItems) @@ -1863,7 +1905,7 @@ public unsafe void EndUpdate() SetAutoComplete(false, false); } - if (EndUpdateInternal()) + if (EndUpdateInternal(invalidate) && invalidate) { if (_childEdit is not null && !_childEdit.HWND.IsNull) { @@ -2332,16 +2374,34 @@ 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); + } + } + + _modernHandleInitialized = true; + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); + if (hasComboBoxInfo) + { + ApplyModernDropDownCornerPreference( + comboBoxInfo.hwndList); } if (_itemsCollection is not null) @@ -2369,6 +2429,9 @@ protected override unsafe void OnHandleCreated(EventArgs e) /// protected override void OnHandleDestroyed(EventArgs e) { + _modernHandleInitialized = false; + ResetModernEditBounds(); + _modernFieldHeightApplied = false; _dropDownHandle = HWND.Null; if (Disposing) { @@ -2654,7 +2717,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(); } @@ -2666,6 +2732,7 @@ protected override void OnParentBackColorChanged(EventArgs e) /// protected override void OnFontChanged(EventArgs e) { + RestoreAndResetModernEditBounds(); base.OnFontChanged(e); ResetHeightCache(); @@ -2680,6 +2747,9 @@ protected override void OnFontChanged(EventArgs e) } CommonProperties.xClearPreferredSizeCache(this); + ResetComboAdapter(); + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); } private void OnAutoCompleteCustomSourceChanged(object? sender, CollectionChangeEventArgs e) @@ -2813,7 +2883,14 @@ private void UpdateControl(bool recreate) protected override void OnResize(EventArgs e) { base.OnResize(e); - if (DropDownStyle == ComboBoxStyle.Simple && IsHandleCreated) + if (DropDownStyle == ComboBoxStyle.DropDown + && IsHandleCreated) + { + UpdateModernEditMargins(); + } + + if (DropDownStyle == ComboBoxStyle.Simple + && IsHandleCreated) { // simple style combo boxes have more painting problems than you can shake a stick at InvalidateEverything(); @@ -3388,6 +3465,9 @@ private void UpdateItemHeight() } } } + + ApplyPreferredFieldHeight(); + UpdateModernEditMargins(); } /// @@ -3461,6 +3541,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); } } @@ -3756,8 +3838,10 @@ protected override unsafe void WndProc(ref Message m) case PInvokeCore.WM_PAINT: 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); @@ -3794,24 +3878,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 +3940,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 +3981,14 @@ protected override unsafe void WndProc(ref Message m) } _suppressNextWindowsPos = false; + if (DropDownStyle == ComboBoxStyle.Simple + && _modernHandleInitialized + && !_adjustingModernChildBounds) + { + ResizeModernSimpleBaseBounds(); + UpdateModernEditMargins(); + } + break; case PInvokeCore.WM_NCDESTROY: @@ -3896,5 +4024,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..c6327df09d7 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/GroupBox/GroupBox.Modern.cs @@ -0,0 +1,575 @@ +// 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); + + 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); + } + + private GraphicsPath CreateModernFramePath(Rectangle bounds) + { + GraphicsPath path = new(); + int radius = Math.Clamp( + ScaleModernMetric(ModernControlVisualStyles.GroupBoxCornerRadius), + 1, + Math.Max(1, Math.Min(bounds.Width, bounds.Height))); + path.AddRoundedRectangle(bounds, new Size(radius, radius)); + + return path; + } + + 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, + 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/ListBoxes/ListBox.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs new file mode 100644 index 00000000000..20964896aa1 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListBox +{ +#if NET11_0_OR_GREATER + /// + protected override void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + protected override void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs index ef7c6daab90..bbfa4ad39cc 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListBoxes/ListBox.cs @@ -1350,9 +1350,11 @@ public void ClearSelected() /// way of doing this. BeginUpdate should be called first, and this method /// should be called when you want the control to start painting again. /// - public void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) { - EndUpdateInternal(); + EndUpdateInternal(invalidate); --_updateCount; } diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs new file mode 100644 index 00000000000..2a266c72548 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class ListView +{ +#if NET11_0_OR_GREATER + /// + protected override void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + protected override void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs index f290e8ce8eb..65335aea479 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/ListView/ListView.cs @@ -3158,7 +3158,9 @@ private bool ClearingInnerListOnDispose /// /// Cancels the effect of BeginUpdate. /// - public void EndUpdate() + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) { // On the final EndUpdate, check to see if we've got any cached items. // If we do, insert them as normal, then turn off the painting freeze. @@ -3167,7 +3169,7 @@ public void EndUpdate() ApplyUpdateCachedItems(); } - EndUpdateInternal(); + EndUpdateInternal(invalidate); } private void EnsureDefaultGroup() diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/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.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs new file mode 100644 index 00000000000..f5d35fe41e3 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/RichTextBox/RichTextBox.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class RichTextBox +{ +#if NET11_0_OR_GREATER + /// + protected override void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdateInternal(); + } + + /// + protected override void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdateInternal(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } +#endif +} 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..69371c0aa52 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,210 @@ 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); - internal override Size GetPreferredSizeCore(Size proposedConstraints) + if (includeScrollbars) + { + padding += GetScrollBarPadding(); + } + + return padding; + } + + private int ScaleVisualStylesMetric(int logicalValue) + => ScaleHelper.ScaleToDpi(logicalValue, DeviceDpiInternal); + + private Size GetVisualStylesFocusBorderMetrics() { - // 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; + 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.SystemVisualSettings.AccentColor; + + /// + /// Returns the additional padding required to clear the live scrollbars, + /// if any are currently shown. + /// + private protected virtual Padding GetScrollBarPadding() + { + Padding padding = Padding.Empty; + + // Are the scrollbars visible? + WINDOW_STYLE style = (WINDOW_STYLE)PInvokeCore.GetWindowLong( + this, + WINDOW_LONG_PTR_INDEX.GWL_STYLE); + + bool hasHScroll = (style & WINDOW_STYLE.WS_HSCROLL) != 0; + bool hasVScroll = (style & WINDOW_STYLE.WS_VSCROLL) != 0; - if (BorderStyle != BorderStyle.None) + 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 +1068,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 +1144,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 +1157,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 +1176,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 +1205,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 +1234,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 +1332,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 +1355,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 +1375,7 @@ internal override string WindowText if (!WindowText.Equals(value)) { _textBoxFlags[s_codeUpdateText] = true; + try { base.WindowText = value; @@ -1133,6 +1398,7 @@ internal void ForceWindowText(string? value) value ??= string.Empty; _textBoxFlags[s_codeUpdateText] = true; + try { if (IsHandleCreated) @@ -1160,13 +1426,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 +1453,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 +1481,7 @@ private void AdjustHeight(bool returnIfAnchored) } _integralHeightAdjust = true; + try { Height = saveHeight; @@ -1281,22 +1553,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 +1593,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 +1657,7 @@ protected override bool IsInputKey(Keys keyData) protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); + if (!IsHandleCreated) { return; @@ -1380,8 +1667,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 +1681,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,10 +1696,65 @@ protected override void OnHandleDestroyed(EventArgs e) base.OnHandleDestroyed(e); } - /// - /// Replaces the current selection in the text box with the contents of the Clipboard. + /// + 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); + + RecalculateVisualStylesClientArea(); + } + + /// + protected override void OnSystemVisualSettingsChanged(SystemVisualSettingsChangedEventArgs e) + { + base.OnSystemVisualSettingsChanged(e); + + if ((e.Changed & (SystemVisualSettingsCategories.TextScale | SystemVisualSettingsCategories.FocusMetrics)) == 0 + || EffectiveVisualStylesMode < VisualStylesMode.Net11) + { + return; + } + + CommonProperties.xClearPreferredSizeCache(this); + AdjustHeight(false); + RecalculateVisualStylesClientArea(); + + if (ParentInternal is { } parent) + { + LayoutTransaction.DoLayout(parent, this, PropertyNames.SystemVisualSettings); + } + } + + /// + /// + /// + /// 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); + public void Paste() + => PInvokeCore.SendMessage(this, PInvokeCore.WM_PASTE); protected override bool ProcessDialogKey(Keys keyData) { @@ -1451,12 +1797,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 +1928,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 +2018,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 +2031,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 +2046,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 +2079,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 +2118,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 +2140,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 +2166,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 +2195,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 +2214,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 +2252,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 +2274,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 +2321,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 +2371,7 @@ public override string ToString() string s = base.ToString(); string txt = Text; + if (txt.Length > 40) { txt = $"{txt.AsSpan(0, 40)}..."; @@ -1982,7 +2383,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 +2396,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 +2411,581 @@ 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); + } + 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(Application.SystemVisualSettings.HighContrastEnabled); + FocusIndicatorRenderer.DrawRoundedFocusIndicator( + offscreenGraphics, + deflatedBounds, + cornerRadius, + borderThickness, + focusBandHeight, + adornerColor, + focusColor); + } + else if (Focused) + { + Color focusColor = GetVisualStylesFocusColor(Application.SystemVisualSettings.HighContrastEnabled); + using var focusPen = focusColor.GetCachedPenScope(borderThickness); + int focusLineCount = Math.Min( + Math.Max(2, focusBorderMetrics.Height), + Math.Max(1, deflatedBounds.Height)); + for (int i = 0; i < focusLineCount; i++) + { + offscreenGraphics.DrawLine( + focusPen, + deflatedBounds.Left, + deflatedBounds.Bottom - i, + deflatedBounds.Right, + deflatedBounds.Bottom - i); + } + } + + 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 +2995,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 +3042,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 +3085,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 +3096,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/TreeView/TreeView.SuspendMutation.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs new file mode 100644 index 00000000000..1c738bb8360 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.SuspendMutation.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +public partial class TreeView +{ +#if NET11_0_OR_GREATER + /// + protected override void BeginSuspendPaintingCore() + { + BeginSuspendPaintingScope(); + BeginUpdate(); + } + + /// + protected override void EndSuspendPaintingCore() + { + if (EndSuspendPaintingScope()) + { + EndUpdate(invalidate: !IsRecursiveInvalidateAfterSuspendPaintingRequested); + } + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs index 4d6a3c53602..90e74d24f1e 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs @@ -43,6 +43,7 @@ public partial class TreeView : Control private TreeViewEventHandler? _onAfterSelect; private ItemDragEventHandler? _onItemDrag; private TreeNodeMouseHoverEventHandler? _onNodeMouseHover; + private EventHandler? _onNodeLeadingChanged; private EventHandler? _onRightToLeftLayoutChanged; internal TreeNode? _selectedNode; @@ -78,6 +79,7 @@ public partial class TreeView : Control private Collections.Specialized.BitVector32 _treeViewState; // see TREEVIEWSTATE_ constants above private static bool s_isScalingInitialized; + private static readonly int s_nodeLeadingProperty = PropertyStore.CreateKey(); private static Size? s_stateImageSize; private static Size? StateImageSize { @@ -117,6 +119,30 @@ internal ImageList.Indexer SelectedImageIndexer } } + private bool RequiresNonEvenHeightStyle + { + get + { +#if NET11_0_OR_GREATER + return _setOddHeight || UsesNodeLeading; +#else + return _setOddHeight; +#endif + } + } + + private bool UsesNodeLeading + { + get + { +#if NET11_0_OR_GREATER + return NodeLeading != 0.0f; +#else + return false; +#endif + } + } + private ImageList? _imageList; private int _indent = -1; private int _itemHeight = -1; @@ -170,6 +196,11 @@ public TreeView() SetStyle(ControlStyles.UserPaint, false); SetStyle(ControlStyles.StandardClick, false); SetStyle(ControlStyles.UseTextForAccessibility, false); + +#if NET11_0_OR_GREATER + FontChanged += HandleNodeLeadingMetricChanged; + DpiChangedAfterParent += HandleNodeLeadingMetricChanged; +#endif } internal override void ReleaseUiaProvider(HWND handle) @@ -366,7 +397,7 @@ protected override CreateParams CreateParams cp.Style |= (int)PInvoke.TVS_FULLROWSELECT; } - if (_setOddHeight) + if (RequiresNonEvenHeightStyle) { cp.Style |= (int)PInvoke.TVS_NONEVENHEIGHT; } @@ -760,6 +791,79 @@ public int Indent } } +#if NET11_0_OR_GREATER + /// + /// Gets or sets the per-node vertical leading factor applied on top of the live height. + /// Default 0.0f selects legacy behavior. + /// + /// + /// A non-negative . 0.0f (default) selects legacy behavior. + /// 1.0f opts in to the honest row-height calculation. Values greater than 1.0f add extra leading. + /// + /// + /// + /// This property applies uniformly to all nodes in the control. It is not a per-node knob, and per-node row + /// heights are not supported by the native control. + /// + /// + /// 0.0f keeps the legacy behavior, including the managed FontHeight + 3 + /// estimate and the default even-height rounding. 1.0f opts in to a row height derived from the live + /// height with TVS_NONEVENHEIGHT enabled. Other positive values scale the + /// honest base for denser or airier rows. + /// + /// + /// The factor is dimensionless. The resulting pixels scale with , whose height already + /// carries the current DPI, so the factor itself does not scale with DPI. + /// + /// + /// "Leading" (pronounced "ledding") is a typesetting term from the strips of lead metal once placed between + /// lines of type to add vertical spacing; it is unrelated to leading or guiding. + /// + /// + /// + /// The value is negative or not finite. + /// + [SRCategory(nameof(SR.CatAppearance))] + [DefaultValue(0.0f)] + [SRDescription(nameof(SR.TreeViewNodeLeadingDescr))] + public float NodeLeading + { + get => Properties.GetValueOrDefault(s_nodeLeadingProperty, 0.0f); + set + { + if (!float.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(value); + + float oldValue = NodeLeading; + if (oldValue != value) + { + bool oldUsesNodeLeading = oldValue != 0.0f; + bool newUsesNodeLeading = value != 0.0f; + + Properties.AddOrRemoveValue(s_nodeLeadingProperty, value, defaultValue: 0.0f); + + if (IsHandleCreated) + { + if (oldUsesNodeLeading != newUsesNodeLeading) + { + RecreateHandle(); + } + else if (newUsesNodeLeading) + { + ApplyItemHeightFromCurrentSettings(); + } + } + + OnNodeLeadingChanged(EventArgs.Empty); + } + } + } +#endif + /// /// The height of every item in the tree view, in pixels. /// @@ -769,6 +873,18 @@ public int ItemHeight { get { +#if NET11_0_OR_GREATER + if (UsesNodeLeading) + { + if (IsHandleCreated) + { + return (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + } + + return GetNodeLeadingItemHeight(); + } +#endif + if (_itemHeight != -1) { return _itemHeight; @@ -798,21 +914,28 @@ public int ItemHeight _itemHeight = value; if (IsHandleCreated) { - if (_itemHeight % 2 != 0) + if (UsesNodeLeading) { - _setOddHeight = true; - try - { - RecreateHandle(); - } - finally + ApplyItemHeightFromCurrentSettings(); + } + else + { + if (_itemHeight % 2 != 0) { - _setOddHeight = false; + _setOddHeight = true; + try + { + RecreateHandle(); + } + finally + { + _setOddHeight = false; + } } - } - PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)value); - _itemHeight = (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)value); + _itemHeight = (int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT); + } } } } @@ -1481,6 +1604,19 @@ public event EventHandler? RightToLeftLayoutChanged remove => _onRightToLeftLayoutChanged -= value; } +#if NET11_0_OR_GREATER + /// + /// Occurs when the value of changes. + /// + [SRCategory(nameof(SR.CatPropertyChanged))] + [SRDescription(nameof(SR.TreeViewOnNodeLeadingChangedDescr))] + public event EventHandler? NodeLeadingChanged + { + add => _onNodeLeadingChanged += value; + remove => _onNodeLeadingChanged -= value; + } +#endif + /// /// Disables redrawing of the tree view. A call to beginUpdate() must be /// balanced by a following call to endUpdate(). Following a call to @@ -1569,10 +1705,9 @@ protected override void Dispose(bool disposing) /// beginUpdate(), any redrawing caused by operations performed on the /// combo box is deferred until the call to endUpdate(). /// - public void EndUpdate() - { - EndUpdateInternal(); - } + public void EndUpdate() => EndUpdate(invalidate: true); + + private void EndUpdate(bool invalidate) => EndUpdateInternal(invalidate); /// /// Expands all nodes at the root level. @@ -1796,6 +1931,16 @@ private void StateImageListChangedHandle(object? sender, EventArgs e) } } +#if NET11_0_OR_GREATER + private void HandleNodeLeadingMetricChanged(object? sender, EventArgs e) + { + if (UsesNodeLeading) + { + ApplyItemHeightFromCurrentSettings(); + } + } +#endif + /// /// Overridden to handle RETURN key. /// @@ -1913,10 +2058,7 @@ protected override void OnHandleCreated(EventArgs e) PInvokeCore.SendMessage(this, PInvoke.TVM_SETINDENT, (WPARAM)_indent); } - if (_itemHeight != -1) - { - PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)ItemHeight); - } + ApplyItemHeightFromCurrentSettings(); // Essentially we are setting the width to be infinite so that the // TreeView never thinks it needs a scrollbar when the first node is created @@ -1954,6 +2096,7 @@ protected override void OnHandleCreated(EventArgs e) flags); } } + finally { _treeViewState[TREEVIEWSTATE_stopResizeWindowMsgs] = false; @@ -2319,6 +2462,23 @@ protected virtual void OnRightToLeftLayoutChanged(EventArgs e) _onRightToLeftLayoutChanged?.Invoke(this, e); } +#if NET11_0_OR_GREATER + /// + /// Raises the event. + /// + /// An that contains the event data. + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void OnNodeLeadingChanged(EventArgs e) + { + if (GetAnyDisposingInHierarchy()) + { + return; + } + + _onNodeLeadingChanged?.Invoke(this, e); + } +#endif + // Refresh the nodes by clearing the tree and adding the nodes back again // private void RefreshNodes() @@ -2349,6 +2509,13 @@ private void ResetItemHeight() RecreateHandle(); } +#if NET11_0_OR_GREATER + /// + /// This resets the node leading factor to the legacy default. + /// + private void ResetNodeLeading() => NodeLeading = 0.0f; +#endif + /// /// Retrieves true if the indent should be persisted in code gen. /// @@ -2359,6 +2526,10 @@ private void ResetItemHeight() /// private bool ShouldSerializeItemHeight() => (_itemHeight != -1); +#if NET11_0_OR_GREATER + private bool ShouldSerializeNodeLeading() => Properties.ContainsKey(s_nodeLeadingProperty); +#endif + private bool ShouldSerializeSelectedImageIndex() { if (_imageList is not null) @@ -2379,6 +2550,51 @@ private bool ShouldSerializeImageIndex() return ImageIndex != ImageList.Indexer.DefaultIndex; } + private void ApplyItemHeightFromCurrentSettings() + { + if (!IsHandleCreated) + { + return; + } + +#if NET11_0_OR_GREATER + if (UsesNodeLeading) + { + int itemHeight = GetNodeLeadingItemHeight(); + if ((int)PInvokeCore.SendMessage(this, PInvoke.TVM_GETITEMHEIGHT) != itemHeight) + { + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)itemHeight); + } + + return; + } +#endif + + if (_itemHeight != -1) + { + PInvokeCore.SendMessage(this, PInvoke.TVM_SETITEMHEIGHT, (WPARAM)ItemHeight); + } + } + +#if NET11_0_OR_GREATER + private int GetNodeLeadingItemHeight() + { + double itemHeight = Math.Ceiling(NodeLeading * Font.Height); + + if (itemHeight < 1) + { + return 1; + } + + if (itemHeight >= short.MaxValue) + { + return short.MaxValue - 1; + } + + return (int)itemHeight; + } +#endif + /// /// Updated the sorted order /// 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..5fd1a93bf3a 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,209 @@ private void PositionControls() } } + private int ModernContentInset + => LogicalToDeviceUnits( + (_borderStyle == BorderStyle.None + ? 0 + : ModernControlVisualStyles.BorderThickness) + + 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); + } + 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(Application.SystemVisualSettings.HighContrastEnabled); + + 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.RevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs new file mode 100644 index 00000000000..7742ad2a41b --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.RevealMode.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using Windows.Win32.Graphics.Dwm; + +namespace System.Windows.Forms; + +public partial class Form +{ +#if NET11_0_OR_GREATER + private static readonly int s_propFormRevealMode = PropertyStore.CreateKey(); + + /// + /// Gets or sets how this form is presented while its initial appearance is prepared. + /// + /// + /// A value. When not explicitly set, the effective value is + /// or , resolved from + /// . + /// + /// + /// + /// As an ambient property, a form that does not have this value set explicitly resolves it from + /// . Unlike a control-level ambient property that + /// chains through a parent hierarchy, this property does not chain through a parent hierarchy: + /// deferred reveal is a top-level-window-only concept (see remarks on + /// and the DWM cloaking mechanism it relies on), so only + /// itself, and the process-wide default, participate. + /// + /// + /// A splash screen or other form that should always appear instantly, even while the rest of the + /// application defers by default, can set this property on itself to + /// without affecting the process-wide default. + /// + /// + [SRCategory(nameof(SR.CatWindowStyle))] + [AmbientValue(FormRevealMode.Inherit)] + [SRDescription(nameof(SR.FormFormRevealModeDescr))] + public virtual FormRevealMode FormRevealMode + { + get + { + if (!Properties.TryGetValue(s_propFormRevealMode, out FormRevealMode value) + || value == FormRevealMode.Inherit) + { + value = Application.IsFormRevealDeferred ? FormRevealMode.Deferred : FormRevealMode.Classic; + } + + return value; + } + set + { + SourceGenerated.EnumValidator.Validate(value, nameof(value)); + + if (value == FormRevealMode.Inherit) + { + Properties.RemoveValue(s_propFormRevealMode); + } + else + { + Properties.AddValue(s_propFormRevealMode, value); + } + } + } + + private bool ShouldSerializeFormRevealMode() => Properties.ContainsKey(s_propFormRevealMode); + + private void ResetFormRevealMode() => Properties.RemoveValue(s_propFormRevealMode); + + private bool DeferredAppearanceCloaked + { + get => Properties.GetValueOrDefault(s_propFormAppearanceCloaked, false); + set => Properties.AddOrRemoveValue(s_propFormAppearanceCloaked, value, defaultValue: false); + } + + private void CloakForDeferredAppearanceIfNeeded() + { + if (!ShouldUseDeferredAppearanceCloak()) + { + return; + } + + if (SetDwmCloak(cloaked: true)) + { + DeferredAppearanceCloaked = true; + } + } + + private void UncloakDeferredAppearanceIfNeeded() + { + if (!DeferredAppearanceCloaked) + { + return; + } + + if (SetDwmCloak(cloaked: false)) + { + DeferredAppearanceCloaked = false; + } + } + + private void ClearDeferredAppearanceCloakState() => DeferredAppearanceCloaked = false; + + private bool ShouldUseDeferredAppearanceCloak() + // This is evaluated from OnHandleCreated, at which point a top-level form's window is + // always still hidden: Form clears WS_VISIBLE from the create params and defers the show + // (see CreateParams / s_formStateShowWindowOnCreate). The window's Visible state is only + // set later, while WM_SHOWWINDOW is processed. Checking Visible here would therefore never + // be true and the cloak would never engage, so the window is shown uncloaked and the + // default-background flash the mode is meant to prevent still occurs. Cloaking the + // already-hidden window now is exactly the intended behavior for both Show and ShowDialog. + => FormRevealMode == FormRevealMode.Deferred + && TopLevel + && !IsMdiChild + && IsHandleCreated; + + private unsafe bool SetDwmCloak(bool cloaked) + { + BOOL cloak = cloaked; + HRESULT result = PInvoke.DwmSetWindowAttribute( + HWND, + DWMWINDOWATTRIBUTE.DWMWA_CLOAK, + &cloak, + (uint)sizeof(BOOL)); + + return result.Succeeded; + } +#endif +} diff --git a/src/System.Windows.Forms/System/Windows/Forms/Form.cs b/src/System.Windows.Forms/System/Windows/Forms/Form.cs index c3900d09c6b..62055889de5 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Form.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Form.cs @@ -137,6 +137,9 @@ public partial class Form : ContainerControl private static readonly int s_propFormCaptionTextColor = PropertyStore.CreateKey(); private static readonly int s_propFormCaptionBackColor = PropertyStore.CreateKey(); private static readonly int s_propFormScreenCaptureMode = PropertyStore.CreateKey(); +#if NET11_0_OR_GREATER + private static readonly int s_propFormAppearanceCloaked = PropertyStore.CreateKey(); +#endif // Form per instance members // Note: Do not add anything to this list unless absolutely necessary. @@ -4209,6 +4212,10 @@ protected override void OnHandleCreated(EventArgs e) { SetScreenCaptureModeInternal(FormScreenCaptureMode); } + +#if NET11_0_OR_GREATER + CloakForDeferredAppearanceIfNeeded(); +#endif } /// @@ -4219,6 +4226,9 @@ protected override void OnHandleCreated(EventArgs e) [EditorBrowsable(EditorBrowsableState.Advanced)] protected override void OnHandleDestroyed(EventArgs e) { +#if NET11_0_OR_GREATER + ClearDeferredAppearanceCloakState(); +#endif base.OnHandleDestroyed(e); _formStateEx[s_formStateExUseMdiChildProc] = 0; @@ -7175,6 +7185,12 @@ protected override void WndProc(ref Message m) case PInvokeCore.WM_ERASEBKGND: WmEraseBkgnd(ref m); break; + case PInvokeCore.WM_PAINT: + base.WndProc(ref m); +#if NET11_0_OR_GREATER + UncloakDeferredAppearanceIfNeeded(); +#endif + break; case PInvokeCore.WM_NCDESTROY: WmNCDestroy(ref m); diff --git a/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs new file mode 100644 index 00000000000..076b339a558 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/FormRevealMode.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Specifies how WinForms presents a top-level while its initial appearance is +/// prepared. +/// +public enum FormRevealMode +{ + /// + /// The form inherits its effective reveal behavior from . + /// This is the ambient default and is never returned by after + /// resolution. + /// + /// + /// + /// This value is the ambient sentinel: assigning it to clears any + /// local override so the value is inherited from again. + /// + /// + Inherit = -1, + + /// + /// Uses the classic WinForms form presentation behavior. The form is never cloaked. + /// + Classic = 0, + + /// + /// Defers the initial top-level form presentation to help prevent default-background flash. + /// + /// + /// + /// Deferral applies to the form background. Deep child-control trees can still produce visible + /// updates after the form is shown. + /// + /// + Deferred = 1 +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs new file mode 100644 index 00000000000..77c3ea39ee5 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ISupportSuspendPainting.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Provides methods for temporarily suspending and resuming painting. +/// +public interface ISupportSuspendPainting +{ + /// + /// Begins a painting suspension region. + /// + void BeginSuspendPainting(); + + /// + /// Ends a painting suspension region. + /// + void EndSuspendPainting(); +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs b/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs index 98779fafb7c..177af418bf9 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Layout/PropertyNames.cs @@ -73,12 +73,14 @@ internal static class PropertyNames public const string ShowCheckMargin = "ShowCheckMargin"; public const string Spring = "Spring"; public const string Style = "Style"; + public const string SystemVisualSettings = "SystemVisualSettings"; public const string TableIndex = "TableIndex"; public const string Text = "Text"; public const string TextAlign = "TextAlign"; 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/LayoutSuspendTraversal.cs b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs new file mode 100644 index 00000000000..119c35d6cb4 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/LayoutSuspendTraversal.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Windows.Forms; + +#if NET11_0_OR_GREATER +/// +/// Specifies how layout suspension traverses a control tree while painting is suspended. +/// +public enum LayoutSuspendTraversal +{ + /// + /// Does not suspend layout. + /// + None = 0, + + /// + /// Suspends layout only for the target control. + /// + TopLevelOnly = 1, + + /// + /// Suspends layout for the target control and all its descendants. + /// + Traverse = 2, +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs new file mode 100644 index 00000000000..36957259b8e --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/ParentBackgroundRenderer.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; + +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); + } +} 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..8d6d21f44c3 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimatedControlRenderer.cs @@ -0,0 +1,216 @@ +// 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. +/// +internal abstract class AnimatedControlRenderer : IDisposable +{ + private const float InteractionShadeAmount = 0.08f; + + private readonly Control _control; + private Color _accentColor; + private bool _accentColorInitialized; + private bool _disposedValue; + protected float AnimationProgress = 1; + + /// + /// Initializes a renderer for the specified control. + /// + /// The control associated with the renderer. + protected AnimatedControlRenderer(Control control) + { + _control = control; + _control.SystemVisualSettingsChanged += OnSystemVisualSettingsChanged; + } + + /// + /// 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 before testing the system setting so a disabled transition + // settles against the same final target as an animated transition. + (int animationDuration, AnimationCycle animationCycle) = OnAnimationStarted(); + + if (!Application.SystemVisualSettings.ClientAreaAnimationEnabled) + { + CompleteAnimation(); + return; + } + + // 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() + => CompleteAnimation(); + + private void CompleteAnimation() + { + AnimationProc(1f); + AnimationManager.Suspend(this); + 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.SystemVisualSettings.AccentColor; + _accentColorInitialized = true; + } + + return _accentColor; + } + } + + internal bool IsAccentColorCached => _accentColorInitialized; + + internal void InvalidateAccentColor() + => _accentColorInitialized = false; + + private void OnSystemVisualSettingsChanged(object? sender, SystemVisualSettingsChangedEventArgs e) + { + if ((e.Changed & SystemVisualSettingsCategories.AccentColor) != 0) + { + InvalidateAccentColor(); + } + + if ((e.Changed & SystemVisualSettingsCategories.Animations) != 0 + && !e.NewSettings.ClientAreaAnimationEnabled + && IsRunning) + { + CompleteAnimation(); + } + } + + 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) + { + _control.SystemVisualSettingsChanged -= OnSystemVisualSettingsChanged; + + // 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..b9a88dd3fe5 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/Animation/AnimationManager.cs @@ -0,0 +1,265 @@ +// 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.TryRemove(animatedControlRenderer, out AnimationRendererItem? renderItem)) + { + renderItem.Renderer.StopAnimationInternal(); + return; + } + + animatedControlRenderer.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..1565dcc1514 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/Rendering/ModernControlVisualStyles.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; + +namespace System.Windows.Forms; + +/// +/// Defines shared device-independent metrics for modern control chrome. +/// +internal static class ModernControlVisualStyles +{ + internal const int BorderThickness = 1; + internal const int ComboBoxStyleInset = 1; + internal const int FieldCornerRadius = 15; + internal const int FocusBandHeight = 4; + internal const float GroupBoxCaptionFontScale = 1.15f; + internal const int GroupBoxCaptionGap = 4; + internal const int GroupBoxContentBottomInset = 4; + internal const int GroupBoxContentHorizontalInset = 8; + internal const int GroupBoxContentTopInset = 8; + internal const int GroupBoxCornerRadius = 8; + internal const int GroupBoxHeaderHorizontalPadding = 10; + internal const int GroupBoxHeaderVerticalPadding = 5; + internal const int InternalChromeInset = 2; + internal const int Fixed3DBorderPadding = 2; + internal const int FixedSingleBorderPadding = 1; + internal const int NoBorderPadding = 1; + 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/SuspendPaintingScope.cs b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs new file mode 100644 index 00000000000..e65deea5d20 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SuspendPaintingScope.cs @@ -0,0 +1,269 @@ +// 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; + +using System.ComponentModel; +using System.Runtime.ExceptionServices; + +#if NET11_0_OR_GREATER +/// +/// Suspends painting for a target until the scope is disposed. +/// +/// +/// +/// This is a sealed class rather than a ref struct so the scope can span an +/// in an asynchronous UI event handler (for example, suspending painting for +/// the duration of an async data reload). is idempotent: disposing the scope more +/// than once only resumes painting once. +/// +/// +public sealed class SuspendPaintingScope : IDisposable +{ + private ISupportSuspendPainting? _target; + private Control[]? _layoutControls; + + /// + /// Initializes a new instance of the class. + /// + /// The target whose painting should be suspended. + public SuspendPaintingScope(ISupportSuspendPainting? target) + { + _target = target; + + if (target is null) + { + return; + } + + int initialPaintingCount = target is Control control + ? control.SuspendPaintingCount + : 0; + + try + { + target.BeginSuspendPainting(); + } + catch (Exception exception) + { + _target = null; + List exceptions = [exception]; + UnwindFailedPaintingAcquisition(target, initialPaintingCount, exceptions); + ThrowExceptions(exceptions); + } + } + + internal SuspendPaintingScope( + ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + : this(target, GetLayoutControls(target, layoutSuspendTraversal)) + { + } + + internal SuspendPaintingScope( + ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + : this(target, GetLayoutControls(target, suspendLayoutContainerFilter)) + { + } + + private SuspendPaintingScope( + ISupportSuspendPainting target, + Control[] layoutControls) + { + _target = target; + _layoutControls = layoutControls; + int initialPaintingCount = ((Control)target).SuspendPaintingCount; + int suspendedLayoutCount = 0; + + try + { + target.BeginSuspendPainting(); + + foreach (Control control in layoutControls) + { + control.SuspendLayout(); + suspendedLayoutCount++; + } + } + catch (Exception exception) + { + List exceptions = [exception]; + ResumeLayouts(layoutControls, suspendedLayoutCount, exceptions); + UnwindFailedPaintingAcquisition(target, initialPaintingCount, exceptions); + + _target = null; + _layoutControls = null; + ThrowExceptions(exceptions); + } + } + + /// + /// Resumes layout and painting for the target associated with this scope. + /// + public void Dispose() + { + ISupportSuspendPainting? target = _target; + Control[] layoutControls = _layoutControls ?? []; + _target = null; + _layoutControls = null; + + if (target is null) + { + return; + } + + List exceptions = []; + ResumeLayouts(layoutControls, layoutControls.Length, exceptions); + EndPainting(target, exceptions, requestRecursiveInvalidate: target is Control); + + ThrowExceptions(exceptions); + } + + private static Control[] GetLayoutControls( + ISupportSuspendPainting target, + LayoutSuspendTraversal layoutSuspendTraversal) + { + Control control = GetControl(target); + + return layoutSuspendTraversal switch + { + LayoutSuspendTraversal.None => [], + LayoutSuspendTraversal.TopLevelOnly => [control], + LayoutSuspendTraversal.Traverse => GetLayoutControls(control, static _ => true), + _ => throw new InvalidEnumArgumentException( + nameof(layoutSuspendTraversal), + (int)layoutSuspendTraversal, + typeof(LayoutSuspendTraversal)) + }; + } + + private static Control[] GetLayoutControls( + ISupportSuspendPainting target, + Func suspendLayoutContainerFilter) + { + ArgumentNullException.ThrowIfNull(suspendLayoutContainerFilter); + + return GetLayoutControls(GetControl(target), suspendLayoutContainerFilter); + } + + private static Control GetControl(ISupportSuspendPainting target) + { + ArgumentNullException.ThrowIfNull(target); + + return target as Control + ?? throw new InvalidOperationException(SR.ControlMutationExtensionsLayoutSuspensionRequiresControl); + } + + private static Control[] GetLayoutControls( + Control target, + Func suspendLayoutContainerFilter) + { + List layoutControls = []; + Stack controlsToVisit = new(); + controlsToVisit.Push(target); + + while (controlsToVisit.TryPop(out Control? control)) + { + if (suspendLayoutContainerFilter(control)) + { + layoutControls.Add(control); + } + + if (control.ChildControls is not { } children) + { + continue; + } + + for (int i = children.Count - 1; i >= 0; i--) + { + controlsToVisit.Push(children[i]); + } + } + + return [.. layoutControls]; + } + + private static void ResumeLayouts( + Control[] layoutControls, + int suspendedLayoutCount, + List exceptions) + { + for (int i = suspendedLayoutCount - 1; i >= 0; i--) + { + try + { + layoutControls[i].ResumeLayoutAfterSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + } + } + } + + private static void EndPainting( + ISupportSuspendPainting target, + List exceptions, + bool requestRecursiveInvalidate = false) + { + try + { + if (requestRecursiveInvalidate) + { + ((Control)target).RequestRecursiveInvalidateAfterSuspendPainting(); + } + + target.EndSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + } + } + + private static void UnwindFailedPaintingAcquisition( + ISupportSuspendPainting target, + int initialPaintingCount, + List exceptions) + { + if (target is not Control control) + { + return; + } + + while (control.SuspendPaintingCount > initialPaintingCount) + { + int paintingCount = control.SuspendPaintingCount; + + try + { + target.EndSuspendPainting(); + } + catch (Exception exception) + { + exceptions.Add(exception); + return; + } + + if (control.SuspendPaintingCount >= paintingCount) + { + return; + } + } + } + + private static void ThrowExceptions(List exceptions) + { + if (exceptions.Count == 1) + { + ExceptionDispatchInfo.Throw(exceptions[0]); + } + + if (exceptions.Count > 1) + { + throw new AggregateException(exceptions); + } + } +} +#endif diff --git a/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.cs new file mode 100644 index 00000000000..9f8050bb11d --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettings.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; + +namespace System.Windows.Forms; + +/// +/// Represents an immutable snapshot of visual and accessibility settings that Windows owns. +/// +/// +/// +/// Applications must not override these values. Renderers can combine this snapshot with their +/// own state to honor the user's Windows personalization and accessibility preferences. +/// +/// +public sealed class SystemVisualSettings +{ + internal SystemVisualSettings( + Color accentColor, + float textScaleFactor, + bool highContrastEnabled, + bool clientAreaAnimationEnabled, + bool keyboardCuesVisible, + Size focusBorderMetrics) + { + AccentColor = accentColor; + TextScaleFactor = textScaleFactor; + HighContrastEnabled = highContrastEnabled; + ClientAreaAnimationEnabled = clientAreaAnimationEnabled; + KeyboardCuesVisible = keyboardCuesVisible; + FocusBorderMetrics = focusBorderMetrics; + } + + /// + /// Gets the user's current Windows accent color. + /// + public Color AccentColor { get; } + + /// + /// Gets the Windows Accessibility text-scale factor. + /// + /// + /// + /// The factor is independent of display DPI and is in the range 1.0 through 2.25. + /// + /// + public float TextScaleFactor { get; } + + /// + /// Gets a value indicating whether the user has enabled Windows high-contrast mode. + /// + /// + /// + /// When this value is , controls use the classic effective + /// so custom modern chrome does not bypass the High Contrast palette. + /// + /// + public bool HighContrastEnabled { get; } + + /// + /// Gets a value indicating whether Windows enables client-area animations. + /// + /// + /// + /// When this value changes to , active client-area animations complete immediately. + /// Re-enabling animations affects later transitions only. + /// + /// + public bool ClientAreaAnimationEnabled { get; } + + /// + /// Gets a value indicating whether the system default displays keyboard cues. + /// + /// + /// + /// This is the system default. A window can temporarily override the effective cue state + /// through WM_UPDATEUISTATE. + /// + /// + public bool KeyboardCuesVisible { get; } + + /// + /// Gets the Windows focus-border width and height, in pixels. + /// + /// + /// + /// Renderers can use these metrics as a baseline for border and focus prominence, scaling + /// them for DPI and rather than relying on fixed constants. + /// + /// + public Size FocusBorderMetrics { get; } +} + +/// +/// Specifies the categories that changed in a transition. +/// +[Flags] +public enum SystemVisualSettingsCategories +{ + /// + /// No visual settings changed. + /// + None = 0, + + /// + /// The Windows accent color changed. + /// + AccentColor = 1 << 0, + + /// + /// The Windows Accessibility text-scale factor changed. + /// + TextScale = 1 << 1, + + /// + /// The Windows high-contrast setting changed. + /// + HighContrast = 1 << 2, + + /// + /// The Windows client-area animation setting changed. + /// + Animations = 1 << 3, + + /// + /// The Windows keyboard-cue default changed. + /// + KeyboardCues = 1 << 4, + + /// + /// The Windows focus-border metrics changed. + /// + FocusMetrics = 1 << 5 +} + +/// +/// Provides data for a or +/// event. +/// +public class SystemVisualSettingsChangedEventArgs : EventArgs +{ + internal SystemVisualSettingsChangedEventArgs( + SystemVisualSettings oldSettings, + SystemVisualSettings newSettings, + SystemVisualSettingsCategories changed) + { + OldSettings = oldSettings; + NewSettings = newSettings; + Changed = changed; + } + + /// + /// Gets the settings before the transition. + /// + public SystemVisualSettings OldSettings { get; } + + /// + /// Gets the settings after the transition. + /// + public SystemVisualSettings NewSettings { get; } + + /// + /// Gets the categories that changed in the transition. + /// + public SystemVisualSettingsCategories Changed { get; } +} + +/// +/// Represents the method that handles a system visual settings change. +/// +/// +/// The source of the event, or for +/// . +/// +/// The event data. +public delegate void SystemVisualSettingsChangedEventHandler(object? sender, SystemVisualSettingsChangedEventArgs e); diff --git a/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs new file mode 100644 index 00000000000..cf70da7e243 --- /dev/null +++ b/src/System.Windows.Forms/System/Windows/Forms/SystemVisualSettingsTracker.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using Windows.Win32.System.WinRT; +using Windows.Win32.UI.Accessibility; +using Windows.Win32.UI.ViewManagement; +using static Windows.Win32.UI.WindowsAndMessaging.SYSTEM_PARAMETERS_INFO_ACTION; + +namespace System.Windows.Forms; + +/// +/// Maintains the process-wide system visual settings snapshot. +/// +internal static class SystemVisualSettingsTracker +{ + private static Func s_snapshotProvider = GetNativeSnapshot; + private static SystemVisualSettings s_currentSettings = GetNativeSnapshot(); + private static long s_transitionVersion; + + /// + /// Gets the current immutable snapshot. + /// + internal static SystemVisualSettings CurrentSettings + => Volatile.Read(ref s_currentSettings); + + /// + /// Gets the version of the latest settings transition. + /// + internal static long TransitionVersion + => Interlocked.Read(ref s_transitionVersion); + + /// + /// Reads the settings source and publishes an event for an effective transition. + /// + internal static SystemVisualSettings Refresh() + { + while (true) + { + SystemVisualSettings current = CurrentSettings; + SystemVisualSettings next = Volatile.Read(ref s_snapshotProvider).Invoke(); + SystemVisualSettingsCategories changed = GetChangedCategories(current, next); + + if (changed == SystemVisualSettingsCategories.None) + { + return current; + } + + if (ReferenceEquals(Interlocked.CompareExchange(ref s_currentSettings, next, current), current)) + { + Interlocked.Increment(ref s_transitionVersion); + Application.OnSystemVisualSettingsChanged( + new SystemVisualSettingsChangedEventArgs(current, next, changed)); + + return next; + } + } + } + + /// + /// Gets the categories that differ between two snapshots. + /// + internal static SystemVisualSettingsCategories GetChangedCategories( + SystemVisualSettings oldSettings, + SystemVisualSettings newSettings) + { + SystemVisualSettingsCategories changed = SystemVisualSettingsCategories.None; + + if (oldSettings.AccentColor != newSettings.AccentColor) + { + changed |= SystemVisualSettingsCategories.AccentColor; + } + + if (oldSettings.TextScaleFactor != newSettings.TextScaleFactor) + { + changed |= SystemVisualSettingsCategories.TextScale; + } + + if (oldSettings.HighContrastEnabled != newSettings.HighContrastEnabled) + { + changed |= SystemVisualSettingsCategories.HighContrast; + } + + if (oldSettings.ClientAreaAnimationEnabled != newSettings.ClientAreaAnimationEnabled) + { + changed |= SystemVisualSettingsCategories.Animations; + } + + if (oldSettings.KeyboardCuesVisible != newSettings.KeyboardCuesVisible) + { + changed |= SystemVisualSettingsCategories.KeyboardCues; + } + + if (oldSettings.FocusBorderMetrics != newSettings.FocusBorderMetrics) + { + changed |= SystemVisualSettingsCategories.FocusMetrics; + } + + return changed; + } + + /// + /// Creates a snapshot from values returned by the native settings source. + /// + internal static SystemVisualSettings CreateSnapshot(SystemVisualSettingsNativeValues values) + => new( + values.AccentColor, + values.TextScaleFactor, + values.HighContrastEnabled, + values.ClientAreaAnimationEnabled, + values.KeyboardCuesVisible, + values.FocusBorderMetrics); + + /// + /// Sets a deterministic settings source for unit tests. + /// + internal static void ResetForTesting( + SystemVisualSettings currentSettings, + Func? snapshotProvider = null) + { + ArgumentNullException.ThrowIfNull(currentSettings); + + Volatile.Write(ref s_snapshotProvider, snapshotProvider ?? GetNativeSnapshot); + Interlocked.Exchange(ref s_currentSettings, currentSettings); + Interlocked.Exchange(ref s_transitionVersion, 0); + } + + /// + /// Restores the native settings source after a unit test. + /// + internal static void ResetForTesting() + { + SystemVisualSettings nativeSettings = GetNativeSnapshot(); + ResetForTesting(nativeSettings); + } + + private static SystemVisualSettings GetNativeSnapshot() + { + HIGHCONTRASTW highContrast = default; + bool highContrastEnabled = PInvokeCore.SystemParametersInfo(ref highContrast) + && highContrast.dwFlags.HasFlag(HIGHCONTRASTW_FLAGS.HCF_HIGHCONTRASTON); + + SystemVisualSettingsNativeValues values = new( + GetAccentColor(), + (float)ScaleHelper.GetSystemTextScaleFactor(), + highContrastEnabled, + PInvokeCore.SystemParametersInfoBool(SPI_GETCLIENTAREAANIMATION), + PInvokeCore.SystemParametersInfoBool(SPI_GETKEYBOARDCUES), + new Size( + PInvokeCore.SystemParametersInfoInt(SPI_GETFOCUSBORDERWIDTH), + PInvokeCore.SystemParametersInfoInt(SPI_GETFOCUSBORDERHEIGHT))); + + return CreateSnapshot(values); + } + + private static Color GetAccentColor() + { + try + { + return GetAccentColorCore(); + } + catch (Exception ex) when (!ex.IsCriticalException()) + { + Debug.Fail($"Unable to query the Windows accent color: {ex.Message}"); + return SystemColors.Highlight; + } + } + + private static unsafe Color GetAccentColorCore() + { + HSTRING className = default; + + fixed (char* pClassName = "Windows.UI.ViewManagement.UISettings") + { + PInvokeCore.WindowsCreateString( + sourceString: (PCWSTR)pClassName, + length: 36u, + @string: &className).ThrowOnFailure(); + } + + try + { + using ComScope inspectable = new(null); + PInvokeCore.RoActivateInstance(className, inspectable).ThrowOnFailure(); + + using ComScope settings = inspectable.TryQuery(out HRESULT hr); + hr.ThrowOnFailure(); + + UIColor color; + settings.Value->GetColorValue(UIColorType.Accent, &color).ThrowOnFailure(); + + return Color.FromArgb(color.A, color.R, color.G, color.B); + } + finally + { + PInvokeCore.WindowsDeleteString(className); + } + } +} + +/// +/// Contains values obtained from the native Windows settings APIs. +/// +internal readonly record struct SystemVisualSettingsNativeValues( + Color AccentColor, + float TextScaleFactor, + bool HighContrastEnabled, + bool ClientAreaAnimationEnabled, + bool KeyboardCuesVisible, + Size FocusBorderMetrics); diff --git a/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs b/src/System.Windows.Forms/System/Windows/Forms/VisualStylesMode.cs new file mode 100644 index 00000000000..6c1c8807ea5 --- /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 and is never returned by after resolution. + /// + /// + /// + /// This value is the ambient sentinel: assigning it to + /// clears any local override so the value is inherited 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/NumericUpDownTests.cs b/src/test/integration/UIIntegrationTests/NumericUpDownTests.cs index f835831c3f0..218db3f7fee 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.PreferredHeight); + Assert.Equal(control.LogicalToDeviceUnits(3), control.TextBox.Left); + Assert.Equal(control.LogicalToDeviceUnits(3), control.UpDownButtonsInternal.Top); + Assert.True(control.UpDownButtonsInternal.Bounds.Left >= control.TextBox.Bounds.Right); + }); + } } diff --git a/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs new file mode 100644 index 00000000000..df29d20c6e5 --- /dev/null +++ b/src/test/integration/UIIntegrationTests/VisualStylesCrossFeatureTests.cs @@ -0,0 +1,226 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; +using System.Drawing.Drawing2D; + +namespace System.Windows.Forms.UITests; + +public class VisualStylesCrossFeatureTests : ControlTestBase +{ + public VisualStylesCrossFeatureTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + [WinFormsFact] + public async Task VisualStyles_InheritedControls_RenderAgainstPatternedParentAcrossModesAndDpiAsync() + { + List samples = []; + + await RunFormWithoutControlAsync( + () => + { + Form form = new() + { + AutoScaleMode = AutoScaleMode.Dpi, + RightToLeft = RightToLeft.Yes, + RightToLeftLayout = true, + Size = new Size(900, 600) + }; + + PatternedGradientPanel parent = new() + { + Dock = DockStyle.Fill, + Padding = new Padding(8), + RightToLeft = RightToLeft.Yes, + VisualStylesMode = VisualStylesMode.Classic + }; + form.Controls.Add(parent); + + TableLayoutPanel table = new() + { + AutoScroll = true, + BackColor = Color.Transparent, + ColumnCount = 3, + Dock = DockStyle.Fill, + RowCount = 11 + }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 32)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 34)); + parent.Controls.Add(table); + + table.Controls.Add(new Label { Text = "Control / font", AutoSize = true }, 0, 0); + table.Controls.Add(new Label { Text = "AutoSize", AutoSize = true }, 1, 0); + table.Controls.Add(new Label { Text = "Fixed small", AutoSize = true }, 2, 0); + + string[] controlKinds = ["TextBox", "MaskedTextBox", "RichTextBox", "NumericUpDown", "DomainUpDown"]; + int row = 1; + foreach (float fontSize in new[] { 9f, 11f }) + { + foreach (string controlKind in controlKinds) + { + table.Controls.Add(new Label { Text = $"{controlKind} ({fontSize:0} pt)", AutoSize = true }, 0, row); + Control autoSized = CreateSampleControl(controlKind, fontSize, autoSize: true); + Control fixedSmall = CreateSampleControl(controlKind, fontSize, autoSize: false); + samples.Add(autoSized); + samples.Add(fixedSmall); + table.Controls.Add(autoSized, 1, row); + table.Controls.Add(fixedSmall, 2, row); + row++; + } + } + + return form; + }, + async form => + { + Panel parent = (Panel)form.Controls[0]; + Assert.Equal(form.DeviceDpi, parent.DeviceDpi); + Assert.All(samples, sample => Assert.Equal(RightToLeft.Yes, sample.RightToLeft)); + + foreach (Control sample in samples) + { + Assert.Equal(VisualStylesMode.Classic, sample.VisualStylesMode); + Assert.True(sample.Width > 0); + Assert.True(sample.Height > 0); + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Net11; + form.PerformLayout(); + Assert.All(samples, sample => Assert.Equal(VisualStylesMode.Net11, sample.VisualStylesMode)); + + foreach (Control sample in samples) + { + using Bitmap bitmap = new(sample.Width, sample.Height); + sample.DrawToBitmap(bitmap, new Rectangle(Point.Empty, sample.Size)); + } + + parent.VisualStylesMode = VisualStylesMode.Classic; + await Task.Yield(); + Assert.All(samples, sample => Assert.Equal(VisualStylesMode.Classic, sample.VisualStylesMode)); + }); + } + + [WinFormsFact] + public async Task VisualStyles_StandardSystemAndPopup_RenderFocusedDefaultAndPressedStatesAsync() + { + await RunFormWithoutControlAsync( + () => + { + Form form = new() { Size = new Size(500, 220) }; + FlowLayoutPanel panel = new() + { + Dock = DockStyle.Fill, + RightToLeft = RightToLeft.Yes, + FlowDirection = FlowDirection.LeftToRight + }; + form.Controls.Add(panel); + form.RightToLeft = RightToLeft.Yes; + form.RightToLeftLayout = true; + form.AutoScaleMode = AutoScaleMode.Dpi; + + Button standard = CreateButton(FlatStyle.Standard, "Standard"); + standard.NotifyDefault(true); + form.AcceptButton = standard; + panel.Controls.Add(standard); + foreach (FlatStyle style in new[] { FlatStyle.System, FlatStyle.Popup }) + { + Button button = CreateButton(style, style.ToString()); + button.NotifyDefault(true); + panel.Controls.Add(button); + } + + return form; + }, + async form => + { + FlowLayoutPanel panel = (FlowLayoutPanel)form.Controls[0]; + foreach (Button button in panel.Controls.OfType