diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 68c1e2dcf1..fa3234c3a5 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1117,6 +1117,7 @@ MSIRESTARTMANAGERCONTROL MSIs msixbundle MSIXCA +msll MSLLHOOKSTRUCT Mso mspub diff --git a/doc/devdocs/modules/powerdisplay/design.md b/doc/devdocs/modules/powerdisplay/design.md index 0c3c0abeaf..764f89b872 100644 --- a/doc/devdocs/modules/powerdisplay/design.md +++ b/doc/devdocs/modules/powerdisplay/design.md @@ -14,6 +14,7 @@ 6. [Component Design](#component-design) - [PowerDisplay Module Internal Structure](#powerdisplay-module-internal-structure) - [DisplayChangeWatcher - Monitor Hot-Plug Detection](#displaychangewatcher---monitor-hot-plug-detection) + - [Tray Icon Mouse Wheel Control](#tray-icon-mouse-wheel-control) - [DDC/CI and WMI Interaction Architecture](#ddcci-and-wmi-interaction-architecture) - [IMonitorController Interface Methods](#imonitorcontroller-interface-methods) - [Why WmiLight Instead of System.Management](#why-wmilight-instead-of-systemmanagement) @@ -415,6 +416,66 @@ _deviceWatcher.Updated += OnDeviceUpdated; // Monitor properties changed --- +### Tray Icon Mouse Wheel Control + +Scrolling the mouse wheel over the notification-area icon adjusts brightness without opening the +flyout. The scope comes from the **Tray icon mouse wheel** setting +(`PowerDisplayProperties.MouseWheelControlMode`): `Disabled` (default), `PrimaryDisplay` or +`AllDisplays`. The per-notch step reuses the existing **Mouse wheel increment** setting. + +**Off by default, on purpose.** The gesture claims a wheel notch that would otherwise reach the +window under the pointer, and acting on it installs a system-wide `WH_MOUSE_LL` hook. Neither is +something an existing installation should acquire silently on upgrade, so the feature is opt-in: +`Disabled` also means the hook is never installed at all. + +The setting is scoped to the tray icon. The flyout sliders accept wheel input regardless, as they +always have. + +**No feedback UI, on purpose.** Brightness is self-evidencing: the screen changes as you scroll, so +there is nothing for a readout to add that the display itself does not already show. The tray icon +keeps the standard Shell tooltip and its existing text, and the notification icon stays on the +legacy protocol. This is a deliberate departure from volume-style tray controls, where an on-screen +readout is the only feedback available. + +**Why a low-level hook.** The Shell does not forward `WM_MOUSEWHEEL` to a notification icon's +callback window under any `NOTIFYICON_VERSION`, and a click-through overlay placed over the icon +cannot receive wheel input either. `TrayIconMouseWheelListener` therefore installs a `WH_MOUSE_LL` +hook - but only transiently: + +- The hook is installed in `EnsureHook()` when the UI thread confirms the pointer is inside the + rectangle returned by `Shell_NotifyIconGetRect` **and** `CanAdjustBrightnessFromTrayWheel` says + some monitor can accept a brightness write. +- It is removed in `DisarmCore()` as soon as either condition stops holding, the pointer leaves the + rectangle, or the mode changes. +- A notch is consumed (the hook proc returns non-zero) only while armed and only for points inside + the armed rectangle, so a wheel event PowerDisplay will not act on still reaches the window under + the cursor. + +The hook runs on a dedicated background thread with its own message loop. Deltas are queued as +`TrayWheelSample` values and marshalled to the UI thread in batches; `WheelDeltaAccumulator` folds +high-resolution deltas (precision wheels, touchpads) into whole notches. Each sample carries the +hover generation it was captured under, so samples from a hover the UI thread has already retired +are discarded rather than applied late. + +**Hover detection.** The Shell sends `WM_MOUSEMOVE` to the icon's callback window while the pointer +is over it. `TrayIconService.HandleTrayMouseMove` resolves the icon rectangle with +`Shell_NotifyIconGetRect`, caching it for a second because that message repeats for every pixel of +travel. + +**Linked brightness.** While linked brightness is on, a wheel notch must move the whole group, so it +is routed through `MainViewModel.LinkedBrightness` rather than the individual monitor setters. The +new master value is derived from `TrayWheelAdjustmentPlanner`'s value for the monitor the wheel +named, not from the current master: the master is positional only (`SeedInitialLinkedBrightness` +takes it from the lowest-numbered linked monitor and never writes hardware, and every monitor-list +rebuild re-seeds it), so stepping it relative to itself would apply a wrong-sized or wrong-signed +change. + +**Testing.** Target selection and wheel accumulation are pure logic in `PowerDisplay.Lib/Services` +with unit tests in `PowerDisplay.Lib.UnitTests`. The Win32 glue in `TrayIconService` and +`TrayIconMouseWheelListener` is not unit tested and needs manual verification. + +--- + ### DDC/CI and WMI Interaction Architecture ```mermaid diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MouseWheelControlModeSettingsTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MouseWheelControlModeSettingsTests.cs new file mode 100644 index 0000000000..61f34315c3 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MouseWheelControlModeSettingsTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Models; + +namespace PowerDisplay.UnitTests; + +[TestClass] +public class MouseWheelControlModeSettingsTests +{ + [TestMethod] + public void Default_IsDisabled() + { + var properties = new PowerDisplayProperties(); + + Assert.AreEqual(MouseWheelControlMode.Disabled, properties.MouseWheelControlMode); + } + + [TestMethod] + public void Deserialize_LegacyJsonMissingField_DefaultsToDisabled() + { + const string legacyJson = """ + { + "monitor_refresh_delay": 5, + "mouse_wheel_increment": 5, + "show_system_tray_icon": true + } + """; + + var properties = JsonSerializer.Deserialize(legacyJson); + + Assert.IsNotNull(properties); + Assert.AreEqual(MouseWheelControlMode.Disabled, properties.MouseWheelControlMode); + } + + [TestMethod] + public void RoundTrip_PreservesEverySupportedMode() + { + MouseWheelControlMode[] modes = + [ + MouseWheelControlMode.Disabled, + MouseWheelControlMode.PrimaryDisplay, + MouseWheelControlMode.AllDisplays, + ]; + + foreach (var mode in modes) + { + var json = JsonSerializer.Serialize(new PowerDisplayProperties { MouseWheelControlMode = mode }); + var restored = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(restored); + Assert.AreEqual(mode, restored.MouseWheelControlMode); + } + } + + [TestMethod] + public void Serialize_UsesSnakeCaseJsonKey() + { + var properties = new PowerDisplayProperties + { + MouseWheelControlMode = MouseWheelControlMode.AllDisplays, + }; + + var json = JsonSerializer.Serialize(properties); + + StringAssert.Contains(json, "\"mouse_wheel_control_mode\":2"); + } + + [TestMethod] + public void Normalize_UnsupportedValue_ReturnsDisabled() + { + var unsupported = (MouseWheelControlMode)99; + + Assert.AreEqual(MouseWheelControlMode.Disabled, unsupported.Normalize()); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayIconBoundsTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayIconBoundsTests.cs new file mode 100644 index 0000000000..cfcdb02441 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayIconBoundsTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Common.Services; + +namespace PowerDisplay.UnitTests; + +[TestClass] +public class TrayIconBoundsTests +{ + [TestMethod] + public void Contains_UsesLeftTopInclusiveAndRightBottomExclusive() + { + var bounds = new TrayIconBounds(10, 20, 30, 40); + + Assert.IsTrue(bounds.Contains(10, 20)); + Assert.IsTrue(bounds.Contains(29, 39)); + Assert.IsFalse(bounds.Contains(30, 39)); + Assert.IsFalse(bounds.Contains(29, 40)); + } + + [TestMethod] + public void IsValid_RejectsEmptyOrInvertedRectangles() + { + Assert.IsTrue(new TrayIconBounds(10, 20, 30, 40).IsValid); + Assert.IsFalse(new TrayIconBounds(10, 20, 10, 40).IsValid); + Assert.IsFalse(new TrayIconBounds(10, 20, 30, 20).IsValid); + Assert.IsFalse(new TrayIconBounds(30, 40, 10, 20).IsValid); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayWheelAdjustmentPlannerTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayWheelAdjustmentPlannerTests.cs new file mode 100644 index 0000000000..2e86f06fee --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/TrayWheelAdjustmentPlannerTests.cs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Common.Services; +using PowerDisplay.Models; +using static PowerDisplay.Common.Services.TrayWheelAdjustmentPlanner; + +namespace PowerDisplay.UnitTests; + +[TestClass] +public class TrayWheelAdjustmentPlannerTests +{ + private static Target Monitor( + string id, + string gdi, + int brightness, + bool supportsBrightness = true, + bool hasBrightnessReading = true) + => new(id, gdi, supportsBrightness, hasBrightnessReading, brightness); + + [TestMethod] + public void Plan_Disabled_ReturnsNoAdjustments() + { + var result = Plan( + MouseWheelControlMode.Disabled, + [Monitor("a", @"\\.\DISPLAY1", 50)], + @"\\.\DISPLAY1", + 5); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void Plan_PrimaryDisplay_SelectsByGdiNameNotMonitorOrder() + { + Target[] targets = + [ + Monitor("first", @"\\.\DISPLAY2", 40), + Monitor("primary", @"\\.\DISPLAY7", 60), + ]; + + var result = Plan( + MouseWheelControlMode.PrimaryDisplay, + targets, + @"\\.\display7", + 5); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(new Adjustment("primary", 65), result[0]); + } + + [TestMethod] + public void Plan_PrimaryDisplay_SelectsEveryMirroredPhysicalTarget() + { + Target[] targets = + [ + Monitor("mirror-a", @"\\.\DISPLAY1", 30), + Monitor("mirror-b", @"\\.\DISPLAY1", 70), + Monitor("other", @"\\.\DISPLAY2", 50), + ]; + + var result = Plan( + MouseWheelControlMode.PrimaryDisplay, + targets, + @"\\.\DISPLAY1", + -10); + + CollectionAssert.AreEqual( + new[] { new Adjustment("mirror-a", 20), new Adjustment("mirror-b", 60) }, + result.ToArray()); + } + + [TestMethod] + public void Plan_PrimaryDisplayWithoutResolvedGdi_ReturnsNoAdjustments() + { + var result = Plan( + MouseWheelControlMode.PrimaryDisplay, + [Monitor("a", @"\\.\DISPLAY1", 50)], + null, + 5); + + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void Plan_AllDisplays_PreservesPerMonitorOffsets() + { + Target[] targets = + [ + Monitor("a", @"\\.\DISPLAY1", 20), + Monitor("b", @"\\.\DISPLAY2", 80), + ]; + + var result = Plan( + MouseWheelControlMode.AllDisplays, + targets, + null, + 5); + + CollectionAssert.AreEqual( + new[] { new Adjustment("a", 25), new Adjustment("b", 85) }, + result.ToArray()); + } + + [TestMethod] + public void Plan_SkipsUnsupportedUnreadAndEmptyIdTargets() + { + Target[] targets = + [ + Monitor("valid", @"\\.\DISPLAY1", 50), + Monitor("unsupported", @"\\.\DISPLAY2", 50, supportsBrightness: false), + Monitor("unread", @"\\.\DISPLAY3", 0, hasBrightnessReading: false), + Monitor(string.Empty, @"\\.\DISPLAY4", 50), + ]; + + var result = Plan(MouseWheelControlMode.AllDisplays, targets, null, 5); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(new Adjustment("valid", 55), result[0]); + } + + [TestMethod] + public void Plan_ClampsEachTargetAtBrightnessBoundaries() + { + Target[] targets = + [ + Monitor("low", @"\\.\DISPLAY1", 2), + Monitor("high", @"\\.\DISPLAY2", 100), + ]; + + var result = Plan(MouseWheelControlMode.AllDisplays, targets, null, 10); + + CollectionAssert.AreEqual( + new[] { new Adjustment("low", 12), new Adjustment("high", 100) }, + result.ToArray()); + } + + [TestMethod] + public void Plan_LargeDeltaCannotOverflow() + { + var result = Plan( + MouseWheelControlMode.AllDisplays, + [Monitor("a", @"\\.\DISPLAY1", 50)], + null, + long.MaxValue); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(new Adjustment("a", 100), result[0]); + } + + [TestMethod] + public void Plan_LargeNegativeDeltaCannotOverflow() + { + var result = Plan( + MouseWheelControlMode.AllDisplays, + [Monitor("a", @"\\.\DISPLAY1", 50)], + null, + long.MinValue); + + Assert.AreEqual(1, result.Count); + Assert.AreEqual(new Adjustment("a", 0), result[0]); + } + + [TestMethod] + public void IsEligible_Disabled_IsFalse() + { + Assert.IsFalse(IsEligible( + MouseWheelControlMode.Disabled, + Monitor("a", @"\\.\DISPLAY1", 50), + @"\\.\DISPLAY1")); + } + + [TestMethod] + public void IsEligible_RejectsMonitorsWithoutAUsableBrightnessReading() + { + Assert.IsFalse(IsEligible( + MouseWheelControlMode.AllDisplays, + Monitor("a", @"\\.\DISPLAY1", 50, supportsBrightness: false), + null)); + Assert.IsFalse(IsEligible( + MouseWheelControlMode.AllDisplays, + Monitor("a", @"\\.\DISPLAY1", 50, hasBrightnessReading: false), + null)); + Assert.IsFalse(IsEligible( + MouseWheelControlMode.AllDisplays, + Monitor(string.Empty, @"\\.\DISPLAY1", 50), + null)); + } + + [TestMethod] + public void IsEligible_PrimaryDisplay_MatchesGdiNameCaseInsensitively() + { + Assert.IsTrue(IsEligible( + MouseWheelControlMode.PrimaryDisplay, + Monitor("a", @"\\.\DISPLAY7", 50), + @"\\.\display7")); + Assert.IsFalse(IsEligible( + MouseWheelControlMode.PrimaryDisplay, + Monitor("a", @"\\.\DISPLAY2", 50), + @"\\.\DISPLAY7")); + } + + [TestMethod] + public void IsEligible_PrimaryDisplay_WithoutAKnownPrimaryIsFalse() + { + // Guards the gate against pairing a monitor that reports no GDI name with an unresolved + // primary and calling that a match. + Assert.IsFalse(IsEligible( + MouseWheelControlMode.PrimaryDisplay, + Monitor("a", string.Empty, 50), + null)); + } + + [TestMethod] + public void IsEligible_AgreesWithPlanForEveryMode() + { + Target[] targets = + [ + Monitor("primary", @"\\.\DISPLAY1", 50), + Monitor("secondary", @"\\.\DISPLAY2", 40), + Monitor("no-reading", @"\\.\DISPLAY3", 30, hasBrightnessReading: false), + Monitor("no-brightness", @"\\.\DISPLAY4", 20, supportsBrightness: false), + ]; + + MouseWheelControlMode[] modes = + [ + MouseWheelControlMode.Disabled, + MouseWheelControlMode.PrimaryDisplay, + MouseWheelControlMode.AllDisplays, + ]; + + foreach (var mode in modes) + { + var planned = Plan(mode, targets, @"\\.\DISPLAY1", 5) + .Select(adjustment => adjustment.Id) + .ToArray(); + var eligible = targets + .Where(target => IsEligible(mode, target, @"\\.\DISPLAY1")) + .Select(target => target.Id) + .ToArray(); + + CollectionAssert.AreEqual(planned, eligible, $"mode {mode}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/WheelDeltaAccumulatorTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/WheelDeltaAccumulatorTests.cs new file mode 100644 index 0000000000..afab18ee47 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/WheelDeltaAccumulatorTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Common.Services; + +namespace PowerDisplay.UnitTests; + +[TestClass] +public class WheelDeltaAccumulatorTests +{ + [TestMethod] + public void Add_FullPositiveNotch_ReturnsOne() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(1, accumulator.Add(120)); + } + + [TestMethod] + public void Add_FullNegativeNotch_ReturnsMinusOne() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(-1, accumulator.Add(-120)); + } + + [TestMethod] + public void Add_MultipleNotchesInOnePacket_ReturnsAllNotches() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(3, accumulator.Add(360)); + } + + [TestMethod] + public void Add_PartialPackets_EmitsOnlyAfterCompleteNotch() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(0, accumulator.Add(30)); + Assert.AreEqual(0, accumulator.Add(30)); + Assert.AreEqual(0, accumulator.Add(30)); + Assert.AreEqual(1, accumulator.Add(30)); + } + + [TestMethod] + public void Add_NegativePartialPackets_EmitsOnlyAfterCompleteNotch() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(0, accumulator.Add(-30)); + Assert.AreEqual(0, accumulator.Add(-30)); + Assert.AreEqual(0, accumulator.Add(-30)); + Assert.AreEqual(-1, accumulator.Add(-30)); + } + + [TestMethod] + public void Add_OneDeltaShortOfNotch_EmitsOnlyOnFinalDelta() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(0, accumulator.Add(119)); + Assert.AreEqual(1, accumulator.Add(1)); + } + + [TestMethod] + public void Add_DirectionReversal_CancelsPartialRemainder() + { + var accumulator = new WheelDeltaAccumulator(); + + Assert.AreEqual(0, accumulator.Add(80)); + Assert.AreEqual(0, accumulator.Add(-40)); + Assert.AreEqual(0, accumulator.Add(-40)); + } + + [TestMethod] + public void Reset_DropsPartialRemainder() + { + var accumulator = new WheelDeltaAccumulator(); + Assert.AreEqual(0, accumulator.Add(60)); + + accumulator.Reset(); + + Assert.AreEqual(0, accumulator.Add(60)); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayIconBounds.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayIconBounds.cs new file mode 100644 index 0000000000..3de91fb222 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayIconBounds.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Common.Services; + +/// +/// Represents a notification icon rectangle in virtual-screen coordinates. +/// +public readonly record struct TrayIconBounds(int Left, int Top, int Right, int Bottom) +{ + /// + /// Gets a value indicating whether the rectangle has positive width and height. + /// + public bool IsValid => Right > Left && Bottom > Top; + + /// + /// Determines whether a screen point is inside the rectangle. + /// + /// The virtual-screen X coordinate. + /// The virtual-screen Y coordinate. + /// when the point is inside the rectangle. + public bool Contains(int x, int y) + => IsValid && x >= Left && x < Right && y >= Top && y < Bottom; +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayWheelAdjustmentPlanner.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayWheelAdjustmentPlanner.cs new file mode 100644 index 0000000000..61892cf682 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/TrayWheelAdjustmentPlanner.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using PowerDisplay.Models; + +namespace PowerDisplay.Common.Services; + +/// +/// Plans relative brightness changes for validated tray wheel input. +/// +public static class TrayWheelAdjustmentPlanner +{ + /// + /// Describes a visible monitor's state needed for tray wheel planning. + /// + public readonly record struct Target( + string Id, + string GdiDeviceName, + bool SupportsBrightness, + bool HasBrightnessReading, + int CurrentBrightness); + + /// + /// Describes one monitor brightness update. + /// + public readonly record struct Adjustment(string Id, int Brightness); + + /// + /// Determines whether one monitor would receive an update for the supplied mode. Split out of + /// so a caller that only needs to know whether any target exists at all can + /// answer that without materializing the adjustment list. + /// + /// The effective mouse-wheel mode. + /// The monitor state to test. + /// The primary logical display's GDI name. + /// when the monitor is a valid target. + public static bool IsEligible( + MouseWheelControlMode mode, + Target target, + string? primaryGdiDeviceName) + { + if (mode.Normalize() == MouseWheelControlMode.Disabled || + string.IsNullOrEmpty(target.Id) || + !target.SupportsBrightness || + !target.HasBrightnessReading) + { + return false; + } + + if (mode != MouseWheelControlMode.PrimaryDisplay) + { + return true; + } + + return !string.IsNullOrWhiteSpace(primaryGdiDeviceName) && + string.Equals( + target.GdiDeviceName, + primaryGdiDeviceName, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Selects eligible targets and computes clamped brightness values. + /// + /// The effective mouse-wheel mode. + /// Visible monitor states. + /// The primary logical display's GDI name. + /// The signed relative brightness delta. + /// Brightness updates in target enumeration order. + public static IReadOnlyList Plan( + MouseWheelControlMode mode, + IEnumerable targets, + string? primaryGdiDeviceName, + long delta) + { + ArgumentNullException.ThrowIfNull(targets); + + mode = mode.Normalize(); + if (mode == MouseWheelControlMode.Disabled || delta == 0) + { + return []; + } + + if (mode == MouseWheelControlMode.PrimaryDisplay && + string.IsNullOrWhiteSpace(primaryGdiDeviceName)) + { + return []; + } + + var boundedDelta = Math.Clamp(delta, -100L, 100L); + var adjustments = new List(); + + foreach (var target in targets) + { + if (!IsEligible(mode, target, primaryGdiDeviceName)) + { + continue; + } + + var brightness = (int)Math.Clamp( + target.CurrentBrightness + boundedDelta, + 0, + 100); + + adjustments.Add(new Adjustment(target.Id, brightness)); + } + + return adjustments; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/WheelDeltaAccumulator.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/WheelDeltaAccumulator.cs new file mode 100644 index 0000000000..2b67a47dde --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/WheelDeltaAccumulator.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Common.Services; + +/// +/// Accumulates high-resolution wheel deltas into complete wheel notches. +/// +public sealed class WheelDeltaAccumulator +{ + /// + /// The Win32 delta value for one complete wheel notch. + /// + public const int WheelDelta = 120; + + private int _remainder; + + /// + /// Adds a signed wheel delta and returns the number of newly completed notches. + /// + /// The signed Win32 wheel delta. + /// The number of newly completed notches. + public int Add(int delta) + { + var total = (long)_remainder + delta; + var notches = (int)(total / WheelDelta); + _remainder = (int)(total % WheelDelta); + return notches; + } + + /// + /// Clears any incomplete wheel-notch remainder. + /// + public void Reset() + { + _remainder = 0; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlMode.cs b/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlMode.cs new file mode 100644 index 0000000000..df7a6d2443 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlMode.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Models; + +/// +/// Defines how PowerDisplay handles mouse-wheel input. +/// +public enum MouseWheelControlMode +{ + /// + /// Disables tray-icon and flyout-slider mouse-wheel adjustment. + /// + Disabled = 0, + + /// + /// Enables flyout-slider adjustment and targets the primary display from the tray icon. + /// + PrimaryDisplay = 1, + + /// + /// Enables flyout-slider adjustment and targets all visible displays from the tray icon. + /// + AllDisplays = 2, +} diff --git a/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlModeExtensions.cs b/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlModeExtensions.cs new file mode 100644 index 0000000000..1ee6907a6b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Models/MouseWheelControlModeExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Models; + +/// +/// Provides validation helpers for . +/// +public static class MouseWheelControlModeExtensions +{ + /// + /// Returns a supported mode, or for an + /// unsupported persisted numeric value. + /// + /// The persisted mode value. + /// A supported mode value. + public static MouseWheelControlMode Normalize(this MouseWheelControlMode mode) + => mode is MouseWheelControlMode.Disabled + or MouseWheelControlMode.PrimaryDisplay + or MouseWheelControlMode.AllDisplays + ? mode + : MouseWheelControlMode.Disabled; +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconMouseWheelListener.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconMouseWheelListener.cs new file mode 100644 index 0000000000..62106dafbb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconMouseWheelListener.cs @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using ManagedCommon; +using PowerDisplay.Common.Services; + +namespace PowerDisplay.Helpers +{ + internal sealed partial class TrayIconMouseWheelListener : IDisposable + { + private const int WhMouseLl = 14; + private const int HcAction = 0; + private const uint WmMouseMove = 0x0200; + private const uint WmMouseWheel = 0x020A; + private const uint WmApp = 0x8000; + private const uint WmArm = WmApp + 1; + private const uint WmDisarm = WmApp + 2; + private const uint WmDrainSamples = WmApp + 3; + private const uint WmShutdown = WmApp + 4; + private const uint PmNoRemove = 0; + private const int MaxQueuedSamples = 32; + + // Non-zero LRESULT from a WH_MOUSE_LL proc blocks the message for the rest of the hook + // chain and for the target window. + private const nint HookHandled = 1; + + private readonly Action _sampleBatchHandler; + private readonly Action _disarmedHandler; + private readonly ManualResetEventSlim _ready = new(); + private readonly object _pendingStateLock = new(); + private readonly Queue _samples = new(MaxQueuedSamples); + private readonly Thread _thread; + + private LowLevelMouseProc? _hookProc; + private uint _threadId; + private nint _hookHandle; + private volatile bool _armed; + private int _drainSamplesPosted; + private bool _hookInstallFailureLogged; + private bool _hookReleaseFailureLogged; + private bool _postFailureLogged; + private volatile bool _threadStopped; + private TrayIconBounds _pendingBounds; + private long _pendingGeneration; + private TrayIconBounds _activeBounds; + private long _activeGeneration; + private int _disposed; + + public TrayIconMouseWheelListener( + Action sampleBatchHandler, + Action disarmedHandler) + { + ArgumentNullException.ThrowIfNull(sampleBatchHandler); + ArgumentNullException.ThrowIfNull(disarmedHandler); + + _sampleBatchHandler = sampleBatchHandler; + _disarmedHandler = disarmedHandler; + _thread = new Thread(ThreadMain) + { + IsBackground = true, + Name = "PowerDisplay.TrayMouseWheel", + }; + _thread.Start(); + + if (!_ready.Wait(TimeSpan.FromSeconds(5))) + { + // The thread has not published its id yet, so WmShutdown has nowhere to go. Latch + // the request instead; ThreadMain checks it before entering the message loop. + Volatile.Write(ref _disposed, 1); + throw new InvalidOperationException("Timed out starting the tray mouse-wheel thread."); + } + } + + /// + /// Gets a value indicating whether the low-level hook is armed for the current hover. + /// + public bool IsArmed => _armed; + + public void Arm(TrayIconBounds bounds, long hoverGeneration) + { + if (Volatile.Read(ref _disposed) != 0 || !bounds.IsValid) + { + return; + } + + lock (_pendingStateLock) + { + _pendingBounds = bounds; + _pendingGeneration = hoverGeneration; + } + + PostCommand(WmArm, 0); + } + + public void Disarm() + { + if (Volatile.Read(ref _disposed) == 0) + { + PostCommand(WmDisarm, 0); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (!PostThreadMessageNative(_threadId, WmShutdown, 0, 0)) + { + Logger.LogWarning( + $"[TrayWheel] Failed to request hook thread shutdown with error {Marshal.GetLastPInvokeError()}"); + } + + if (!_thread.Join(TimeSpan.FromSeconds(5))) + { + Logger.LogError("[TrayWheel] Timed out stopping the hook thread"); + } + + _ready.Dispose(); + GC.SuppressFinalize(this); + } + + private void ThreadMain() + { + _hookProc = HookCallback; + _ = PeekMessageNative(out _, 0, 0, 0, PmNoRemove); + _threadId = GetCurrentThreadIdNative(); + _ready.Set(); + + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + + try + { + RunMessageLoop(); + } + catch (Exception ex) + { + // Nothing sits above this thread to contain a failure - the WinUI unhandled + // exception handler only covers the UI thread - so an escaping exception would end + // the process. Wheel control is optional: log it, drop the hook in the finally + // below, and let the thread retire. + Logger.LogError($"[TrayWheel] Hook thread stopped after an unhandled failure: {ex.Message}"); + } + finally + { + DisarmCore(notify: false); + _threadStopped = true; + } + } + + private void RunMessageLoop() + { + // This thread owns no windows, so the only messages it can retrieve are the commands + // posted to it here and WM_QUIT. There is nothing to translate or dispatch: the hook + // proc is called by the system during message retrieval, not through DispatchMessage. + var running = true; + while (running) + { + var result = GetMessageNative(out var message, 0, 0, 0); + if (result == 0) + { + break; + } + + if (result < 0) + { + Logger.LogError( + $"[TrayWheel] GetMessage failed with error {Marshal.GetLastPInvokeError()}"); + break; + } + + switch (message.Message) + { + case WmArm: + HandleArm(); + break; + case WmDisarm: + DisarmCore(notify: true); + break; + case WmDrainSamples: + DrainSamples(); + break; + case WmShutdown: + running = false; + break; + } + } + } + + private void HandleArm() + { + lock (_pendingStateLock) + { + _activeBounds = _pendingBounds; + _activeGeneration = _pendingGeneration; + } + + _armed = _activeBounds.IsValid && _activeGeneration != 0; + if (_armed && !EnsureHook()) + { + DisarmCore(notify: true); + } + } + + private bool EnsureHook() + { + if (_hookHandle != 0) + { + return true; + } + + var hookPointer = Marshal.GetFunctionPointerForDelegate(_hookProc!); + _hookHandle = SetWindowsHookExNative( + WhMouseLl, + hookPointer, + GetModuleHandleNative(null), + 0); + + if (_hookHandle != 0) + { + _hookInstallFailureLogged = false; + return true; + } + + if (!_hookInstallFailureLogged) + { + Logger.LogWarning( + $"[TrayWheel] SetWindowsHookEx failed with error {Marshal.GetLastPInvokeError()}"); + _hookInstallFailureLogged = true; + } + + return false; + } + + private void DisarmCore(bool notify) + { + var generation = _activeGeneration; + _armed = false; + _activeGeneration = 0; + _activeBounds = default; + _samples.Clear(); + Interlocked.Exchange(ref _drainSamplesPosted, 0); + + if (_hookHandle != 0) + { + var hook = _hookHandle; + _hookHandle = 0; + if (!UnhookWindowsHookExNative(hook)) + { + if (!_hookReleaseFailureLogged) + { + Logger.LogWarning( + $"[TrayWheel] UnhookWindowsHookEx failed with error {Marshal.GetLastPInvokeError()}"); + _hookReleaseFailureLogged = true; + } + } + else + { + _hookReleaseFailureLogged = false; + } + } + + if (notify && generation != 0) + { + _disarmedHandler(generation); + } + } + + private void DrainSamples() + { + Interlocked.Exchange(ref _drainSamplesPosted, 0); + if (_samples.Count == 0) + { + return; + } + + var batch = _samples.ToArray(); + _samples.Clear(); + _sampleBatchHandler(batch); + } + + private unsafe nint HookCallback(int nCode, nuint wParam, nint lParam) + { + if (nCode == HcAction && _armed) + { + var data = *(MsllHookStruct*)lParam; + var message = (uint)wParam; + + if (message == WmMouseMove && !_activeBounds.Contains(data.Point.X, data.Point.Y)) + { + _armed = false; + _ = PostThreadMessageNative(_threadId, WmDisarm, 0, 0); + } + else if (message == WmMouseWheel) + { + var delta = unchecked((short)(data.MouseData >> 16)); + if (delta != 0) + { + if (_samples.Count == MaxQueuedSamples) + { + _ = _samples.Dequeue(); + } + + _samples.Enqueue(new TrayWheelSample( + data.Point.X, + data.Point.Y, + data.Time, + delta, + _activeGeneration)); + + if (Interlocked.Exchange(ref _drainSamplesPosted, 1) == 0 && + !PostThreadMessageNative(_threadId, WmDrainSamples, 0, 0)) + { + Interlocked.Exchange(ref _drainSamplesPosted, 0); + } + + if (_activeBounds.Contains(data.Point.X, data.Point.Y)) + { + // Consume the notch. Arming already required the UI thread to confirm + // that it will turn this into a brightness change, so forwarding it as + // well would scroll the focused window at the same time. Out-of-bounds + // samples are still queued so the UI thread can retire the hover, but + // they are not ours to swallow. + return HookHandled; + } + } + } + } + + return CallNextHookExNative(_hookHandle, nCode, wParam, lParam); + } + + private void PostCommand(uint message, nuint wParam) + { + if (_threadStopped) + { + // The message loop is gone, so there is nothing to post to. Stay quiet: this is + // reached from the tray hover path, once per mouse-move message. + return; + } + + if (!PostThreadMessageNative(_threadId, message, wParam, 0) && !_postFailureLogged) + { + _postFailureLogged = true; + Logger.LogWarning( + $"[TrayWheel] PostThreadMessage failed with error {Marshal.GetLastPInvokeError()}"); + } + } + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + private delegate nint LowLevelMouseProc(int nCode, nuint wParam, nint lParam); + + [StructLayout(LayoutKind.Sequential)] + private struct NativePoint + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MsllHookStruct + { + public NativePoint Point; + public uint MouseData; + public uint Flags; + public uint Time; + public nuint ExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeMessage + { + public nint HWnd; + public uint Message; + public nuint WParam; + public nint LParam; + public uint Time; + public NativePoint Point; + public uint Private; + } + + [LibraryImport("user32.dll", EntryPoint = "SetWindowsHookExW", SetLastError = true)] + private static partial nint SetWindowsHookExNative( + int hookType, + nint hookProc, + nint module, + uint threadId); + + [LibraryImport("user32.dll", EntryPoint = "UnhookWindowsHookEx", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UnhookWindowsHookExNative(nint hook); + + [LibraryImport("user32.dll", EntryPoint = "CallNextHookEx")] + private static partial nint CallNextHookExNative( + nint hook, + int code, + nuint wParam, + nint lParam); + + [LibraryImport("user32.dll", EntryPoint = "GetMessageW", SetLastError = true)] + private static partial int GetMessageNative( + out NativeMessage message, + nint window, + uint minimumMessage, + uint maximumMessage); + + [LibraryImport("user32.dll", EntryPoint = "PeekMessageW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool PeekMessageNative( + out NativeMessage message, + nint window, + uint minimumMessage, + uint maximumMessage, + uint removeMessage); + + [LibraryImport("user32.dll", EntryPoint = "PostThreadMessageW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool PostThreadMessageNative( + uint threadId, + uint message, + nuint wParam, + nint lParam); + + [LibraryImport("kernel32.dll", EntryPoint = "GetCurrentThreadId")] + private static partial uint GetCurrentThreadIdNative(); + + [LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW", StringMarshalling = StringMarshalling.Utf16)] + private static partial nint GetModuleHandleNative(string? moduleName); + } + + internal readonly record struct TrayWheelSample( + int X, + int Y, + uint Timestamp, + int Delta, + long HoverGeneration); +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs index 88b9b5b64d..8d49bc8947 100644 --- a/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs @@ -8,7 +8,10 @@ using System.IO; using System.Runtime.InteropServices; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; +using PowerDisplay.Common.Services; +using PowerDisplay.Models; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.UI.Shell; @@ -32,12 +35,22 @@ namespace PowerDisplay.Helpers { private const uint MyNotifyId = 1001; private const uint WmTrayIcon = PInvoke.WM_USER + 1; + private const uint WmMouseMove = 0x0200; + + // The Shell repeats WM_MOUSEMOVE for every pixel of travel across the icon. Serving a + // still-fresh rectangle from this cache keeps the hover path off Shell_NotifyIconGetRect. + private const long BoundsCacheLifetimeMs = 1000; + + // Input that queued up behind a stalled UI thread should not move brightness late. + private const uint MaxSampleAgeMs = 500; private readonly SettingsUtils _settingsUtils; private readonly Action _toggleWindowAction; private readonly Action _exitAction; private readonly Action _openSettingsAction; private readonly uint _wmTaskbarRestart; + private readonly DispatcherQueue _dispatcherQueue; + private readonly WheelDeltaAccumulator _wheelDeltaAccumulator = new(); private Window? _window; private nint _hwnd; @@ -46,6 +59,35 @@ namespace PowerDisplay.Helpers private NOTIFYICONDATAW? _trayIconData; private nint _largeIcon; private nint _popupMenu; + private TrayIconMouseWheelListener? _mouseWheelListener; + private MouseWheelControlMode _mouseWheelControlMode; + private TrayIconBounds? _cachedBounds; + private long _boundsCacheTimestamp; + private long _hoverGeneration; + private bool _mouseWheelListenerConstructionFailed; + private bool _sampleDispatchFailureLogged; + private bool _boundsFailureLogged; + + /// + /// Raised on the UI thread with the signed number of complete wheel notches delivered over + /// the icon. + /// + internal event Action? MouseWheelScrolled; + + /// + /// Gets or sets the gate checked before wheel deltas enter the accumulator: the UI must be + /// interactive and some monitor must be able to accept the resulting brightness write. + /// + internal Func? CanProcessMouseWheel { get; set; } + + /// + /// Gets a value indicating whether a wheel notch delivered over the icon right now would be + /// turned into a brightness change. The hook only arms while this holds, which is what lets + /// the hook consume the notch instead of forwarding it to the window under the pointer. + /// + private bool IsMouseWheelAdjustmentReady => + _mouseWheelControlMode != MouseWheelControlMode.Disabled && + CanProcessMouseWheel?.Invoke() == true; public TrayIconService( SettingsUtils settingsUtils, @@ -57,6 +99,7 @@ namespace PowerDisplay.Helpers _toggleWindowAction = toggleWindowAction; _exitAction = exitAction; _openSettingsAction = openSettingsAction; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); // TaskbarCreated is the message that's broadcast when explorer.exe // restarts. We need to know when that happens to be able to bring our @@ -68,6 +111,7 @@ namespace PowerDisplay.Helpers { var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); bool shouldShow = showSystemTrayIcon ?? settings.Properties.ShowSystemTrayIcon; + UpdateMouseWheelMode(settings.Properties.MouseWheelControlMode.Normalize()); if (shouldShow) { @@ -129,6 +173,8 @@ namespace PowerDisplay.Helpers InsertMenuNative(_popupMenu, 0, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 1, GetString("TrayMenu_Settings")); InsertMenuNative(_popupMenu, 1, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 2, GetString("TrayMenu_Exit")); } + + EnsureMouseWheelListener(); } else { @@ -138,6 +184,9 @@ namespace PowerDisplay.Helpers public void Destroy() { + DisposeMouseWheelListener(); + InvalidateMouseWheelHover(disarm: false); + if (_trayIconData is not null) { var d = (NOTIFYICONDATAW)_trayIconData; @@ -182,6 +231,277 @@ namespace PowerDisplay.Helpers } } + /// + /// Applies the persisted mouse-wheel mode, starting or stopping the hook thread with it. + /// + private void UpdateMouseWheelMode(MouseWheelControlMode mode) + { + mode = mode.Normalize(); + if (_mouseWheelControlMode == mode) + { + return; + } + + _mouseWheelControlMode = mode; + InvalidateMouseWheelHover(disarm: true); + + if (mode == MouseWheelControlMode.Disabled) + { + DisposeMouseWheelListener(); + } + else if (_trayIconData is not null) + { + EnsureMouseWheelListener(); + } + } + + private void EnsureMouseWheelListener() + { + // Reached from the window procedure, where an escaping exception takes the process down. + // The hook thread is optional, so latch a failed start instead of retrying it per hover. + if (_mouseWheelControlMode == MouseWheelControlMode.Disabled || + _mouseWheelListenerConstructionFailed) + { + return; + } + + try + { + _mouseWheelListener ??= new TrayIconMouseWheelListener( + OnWheelSampleBatch, + OnMouseWheelListenerDisarmed); + } + catch (Exception ex) + { + // Deliberately broad: starting a thread can also fail with ThreadStateException or + // OutOfMemoryException, and anything that escapes here ends the process. Wheel + // control is optional, so latch and carry on with a plain tray icon. + _mouseWheelListenerConstructionFailed = true; + Logger.LogWarning($"[TrayWheel] Unable to start the hook thread: {ex.Message}"); + } + } + + private void DisposeMouseWheelListener() + { + _mouseWheelListener?.Dispose(); + _mouseWheelListener = null; + _mouseWheelListenerConstructionFailed = false; + _cachedBounds = null; + _boundsCacheTimestamp = 0; + _wheelDeltaAccumulator.Reset(); + } + + /// + /// Retires the current hover: any sample still in flight is stamped with the old generation + /// and will be discarded, and the partial notch it belonged to is no longer meaningful. + /// + private void InvalidateMouseWheelHover(bool disarm) + { + unchecked + { + _hoverGeneration++; + } + + _cachedBounds = null; + _boundsCacheTimestamp = 0; + _wheelDeltaAccumulator.Reset(); + + if (disarm) + { + _mouseWheelListener?.Disarm(); + } + } + + /// + /// Arms the hook while the pointer is confirmed to be over the icon and a notch would + /// actually produce a brightness change. Both conditions are what lets the hook consume the + /// notch rather than forwarding it on. + /// + private void HandleTrayMouseMove() + { + if (_mouseWheelControlMode == MouseWheelControlMode.Disabled) + { + return; + } + + if (!GetCursorPos(out var cursor)) + { + if (!_boundsFailureLogged) + { + Logger.LogWarning("[TrayWheel] GetCursorPos failed while arming tray hover"); + _boundsFailureLogged = true; + } + + return; + } + + var now = Environment.TickCount64; + var previousBounds = _cachedBounds; + + TrayIconBounds bounds; + if (previousBounds is TrayIconBounds cached && + now - _boundsCacheTimestamp <= BoundsCacheLifetimeMs && + cached.Contains(cursor.X, cursor.Y)) + { + bounds = cached; + } + else if (!TryQueryTrayIconBounds(out bounds) || !bounds.Contains(cursor.X, cursor.Y)) + { + // The Shell only notifies while the pointer is over the icon, so a rectangle that + // excludes the cursor means either the pointer already moved on or the Shell + // reported a stand-in rectangle for an icon in the notification overflow. + InvalidateMouseWheelHover(disarm: true); + return; + } + + _cachedBounds = bounds; + _boundsCacheTimestamp = now; + + if (!IsMouseWheelAdjustmentReady) + { + _mouseWheelListener?.Disarm(); + return; + } + + // The Shell repeats this message for every pixel of travel, so only touch the hook + // thread when something it cares about actually changed. Re-arming on an unchanged + // rectangle would post a thread message per pixel for no effect. + if (_mouseWheelListener?.IsArmed == true && + previousBounds.HasValue && + previousBounds.Value == bounds) + { + return; + } + + unchecked + { + _hoverGeneration++; + } + + _wheelDeltaAccumulator.Reset(); + EnsureMouseWheelListener(); + _mouseWheelListener?.Arm(bounds, _hoverGeneration); + } + + private unsafe bool TryQueryTrayIconBounds(out TrayIconBounds bounds) + { + bounds = default; + if (_hwnd == 0 || _trayIconData is null) + { + return false; + } + + var identifier = new NotifyIconIdentifier + { + CbSize = (uint)sizeof(NotifyIconIdentifier), + HWnd = _hwnd, + Id = MyNotifyId, + GuidItem = Guid.Empty, + }; + + var result = ShellNotifyIconGetRectNative(ref identifier, out var rect); + if (result < 0) + { + if (!_boundsFailureLogged) + { + Logger.LogWarning( + $"[TrayWheel] Shell_NotifyIconGetRect failed with HRESULT 0x{result:X8}"); + _boundsFailureLogged = true; + } + + return false; + } + + _boundsFailureLogged = false; + bounds = new TrayIconBounds(rect.Left, rect.Top, rect.Right, rect.Bottom); + return bounds.IsValid; + } + + private void OnWheelSampleBatch(TrayWheelSample[] samples) + { + if (!_dispatcherQueue.TryEnqueue(() => ProcessWheelSampleBatch(samples)) && + !_sampleDispatchFailureLogged) + { + Logger.LogWarning("[TrayWheel] Failed to enqueue wheel samples to the UI thread"); + _sampleDispatchFailureLogged = true; + } + } + + private void ProcessWheelSampleBatch(TrayWheelSample[] samples) + { + _sampleDispatchFailureLogged = false; + + if (_mouseWheelControlMode == MouseWheelControlMode.Disabled || + CanProcessMouseWheel?.Invoke() != true || + !TryQueryTrayIconBounds(out var currentBounds)) + { + // The gate can go false after the hook was armed - a monitor rescan, for instance. + // Retire the hover rather than only dropping the partial notch: the pointer may be + // parked, in which case no further tray mouse-move would arrive to re-evaluate this + // and the hook would keep swallowing notches nobody acts on. + InvalidateMouseWheelHover(disarm: true); + return; + } + + var now = unchecked((uint)Environment.TickCount); + var totalNotches = 0; + var retireHover = false; + foreach (var sample in samples) + { + // The hook only swallows notches that landed inside the rectangle it was armed + // with, so a sample stamped with a retired hover - or one the Shell has since moved + // the icon out from under - was never ours. Retire the hover for those, but keep + // applying the samples in the same batch that were swallowed on our behalf: + // dropping those would consume a notch without adjusting anything. + if (sample.HoverGeneration != _hoverGeneration || + !currentBounds.Contains(sample.X, sample.Y)) + { + retireHover = true; + continue; + } + + if (unchecked(now - sample.Timestamp) > MaxSampleAgeMs) + { + _wheelDeltaAccumulator.Reset(); + continue; + } + + totalNotches += _wheelDeltaAccumulator.Add(sample.Delta); + } + + if (!retireHover) + { + _cachedBounds = currentBounds; + _boundsCacheTimestamp = Environment.TickCount64; + } + + if (totalNotches != 0) + { + MouseWheelScrolled?.Invoke(totalNotches); + } + + if (retireHover) + { + InvalidateMouseWheelHover(disarm: true); + } + } + + private void OnMouseWheelListenerDisarmed(long generation) + { + if (!_dispatcherQueue.TryEnqueue(() => + { + if (generation == _hoverGeneration) + { + InvalidateMouseWheelHover(disarm: false); + } + }) && + !_sampleDispatchFailureLogged) + { + Logger.LogWarning("[TrayWheel] Failed to enqueue hover cleanup to the UI thread"); + _sampleDispatchFailureLogged = true; + } + } + private nint GetAppIconHandle() { var exePath = Path.Combine(AppContext.BaseDirectory, "PowerToys.PowerDisplay.exe"); @@ -257,6 +577,9 @@ namespace PowerDisplay.Helpers case PInvoke.WM_LBUTTONUP: _toggleWindowAction?.Invoke(); break; + case WmMouseMove: + HandleTrayMouseMove(); + break; } } @@ -291,6 +614,11 @@ namespace PowerDisplay.Helpers [LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)] private static partial uint ExtractIconExNative(string lpszFile, int nIconIndex, out nint phiconLarge, out nint phiconSmall, uint nIcons); + [LibraryImport("shell32.dll", EntryPoint = "Shell_NotifyIconGetRect")] + private static partial int ShellNotifyIconGetRectNative( + ref NotifyIconIdentifier identifier, + out NativeRect iconLocation); + // Menu APIs [LibraryImport("user32.dll")] private static partial nint CreatePopupMenu(); @@ -318,6 +646,24 @@ namespace PowerDisplay.Helpers public int Y; } + [StructLayout(LayoutKind.Sequential)] + private struct NotifyIconIdentifier + { + public uint CbSize; + public nint HWnd; + public uint Id; + public Guid GuidItem; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + private const int GwlWndproc = -4; } } diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs index 30b28631b7..7f727c1cc1 100644 --- a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs @@ -168,7 +168,8 @@ namespace PowerDisplay // Create main window Logger.LogInfo("OnLaunched: Creating MainWindow"); - _mainWindow = new MainWindow(); + var mainWindow = new MainWindow(); + _mainWindow = mainWindow; Logger.LogInfo("OnLaunched: MainWindow created"); // Initialize tray icon service @@ -178,6 +179,10 @@ namespace PowerDisplay ToggleMainWindow, Shutdown, OpenSettings); + _trayIconService.MouseWheelScrolled += + notches => mainWindow.ViewModel.AdjustBrightnessFromTrayWheel(notches); + _trayIconService.CanProcessMouseWheel = + () => mainWindow.ViewModel.CanAdjustBrightnessFromTrayWheel; _trayIconService.SetupTrayIcon(); Logger.LogTrace("OnLaunched: TrayIconService initialized"); diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.TrayWheel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.TrayWheel.cs new file mode 100644 index 0000000000..a346bf1bd6 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.TrayWheel.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ManagedCommon; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Services; +using PowerDisplay.Models; +using MouseWheelMode = PowerDisplay.Models.MouseWheelControlMode; + +namespace PowerDisplay.ViewModels; + +public partial class MainViewModel +{ + private const uint MonitorDefaultToPrimary = 1; + + private bool _trayWheelNoTargetLogged; + + /// + /// Gets a value indicating whether a wheel notch delivered right now would produce a brightness + /// change. The tray hook arms only while this holds, so it never consumes a notch that no + /// monitor can accept. The tray service re-reads this for every hover message, so it answers + /// the question by scanning for the first eligible monitor instead of planning the full set. + /// + public bool CanAdjustBrightnessFromTrayWheel + { + get + { + var mode = MouseWheelControlMode.Normalize(); + if (!TryGetTrayWheelScope(mode, out var primaryGdiDeviceName)) + { + return false; + } + + // Indexed rather than foreach: ObservableCollection hands out a boxed enumerator, and + // this runs on every tray hover message. + for (var i = 0; i < Monitors.Count; i++) + { + if (TrayWheelAdjustmentPlanner.IsEligible( + mode, + CreateTrayWheelTarget(Monitors[i]), + primaryGdiDeviceName)) + { + return true; + } + } + + return false; + } + } + + /// + /// Applies complete tray wheel notches to the configured brightness targets. The brightness + /// change is its own feedback, so nothing is reported back to the caller. + /// + /// The signed number of complete wheel notches. + public void AdjustBrightnessFromTrayWheel(int notches) + { + var mode = MouseWheelControlMode.Normalize(); + var adjustments = PlanTrayWheelAdjustments(mode, notches); + + if (adjustments.Count == 0) + { + if (!_trayWheelNoTargetLogged) + { + Logger.LogWarning("[TrayWheel] No valid brightness target was available"); + _trayWheelNoTargetLogged = true; + } + + return; + } + + _trayWheelNoTargetLogged = false; + + // Linked monitors are driven by the master value, not their own setter: a per-VM commit + // would leave the master slider stale and the next broadcast would revert the wheel + // adjustment. Excluded monitors keep their own value and are adjusted individually. + var linkedBrightness = 0; + var hasLinkedTarget = false; + + foreach (var adjustment in adjustments) + { + foreach (var monitor in Monitors) + { + if (!MonitorIdComparer.Equal(monitor.Id, adjustment.Id)) + { + continue; + } + + if (LinkedLevelsActive && IsLinkedTarget(monitor)) + { + // The group moves as one, so only the first linked target sets the master. + // Step from the planner's value for this monitor rather than from + // LinkedBrightness: the master is positional only - SeedInitialLinkedBrightness + // takes it from the lowest-numbered linked monitor and never writes hardware, + // and every monitor-list rebuild re-seeds it - so it can sit arbitrarily far + // from the monitor the wheel actually named. Stepping the master relative to + // itself turns that drift into a wrong-sized or wrong-signed change, and a + // master already clamped at 0/100 would swallow the notch while writing + // nothing at all. The planner already clamped this value against the target's + // real brightness, so the group snaps to the target's level and the gesture + // always moves that monitor in the direction the user scrolled. + if (!hasLinkedTarget) + { + linkedBrightness = adjustment.Brightness; + hasLinkedTarget = true; + } + } + else + { + monitor.Brightness = adjustment.Brightness; + } + + break; + } + } + + if (hasLinkedTarget) + { + LinkedBrightness = linkedBrightness; + } + } + + private IReadOnlyList PlanTrayWheelAdjustments( + MouseWheelMode mode, + int notches) + { + if (!TryGetTrayWheelScope(mode, out var primaryGdiDeviceName)) + { + return []; + } + + var targets = new List(Monitors.Count); + foreach (var monitor in Monitors) + { + targets.Add(CreateTrayWheelTarget(monitor)); + } + + return TrayWheelAdjustmentPlanner.Plan( + mode, + targets, + primaryGdiDeviceName, + (long)notches * MouseWheelIncrement); + } + + /// + /// Validates the preconditions shared by and + /// , and resolves the primary display's GDI name for the + /// modes that need it. + /// + /// The normalized mouse-wheel mode. + /// The resolved primary GDI name, or + /// when the mode does not target the primary display. + /// when a tray wheel adjustment is possible in principle. + private bool TryGetTrayWheelScope( + MouseWheelMode mode, + out string? primaryGdiDeviceName) + { + primaryGdiDeviceName = null; + + if (mode == MouseWheelMode.Disabled || + MouseWheelIncrement <= 0 || + !IsInitialized || + !IsInteractionEnabled) + { + return false; + } + + if (mode != MouseWheelMode.PrimaryDisplay) + { + return true; + } + + primaryGdiDeviceName = GetPrimaryGdiDeviceName(); + return !string.IsNullOrWhiteSpace(primaryGdiDeviceName); + } + + private static TrayWheelAdjustmentPlanner.Target CreateTrayWheelTarget(MonitorViewModel monitor) + => new( + monitor.Id, + monitor.GdiDeviceName, + monitor.SupportsBrightness, + monitor.HasValidBrightnessReading, + monitor.Brightness); + + private static unsafe string? GetPrimaryGdiDeviceName() + { + var monitor = MonitorFromPointNative( + new NativePoint(0, 0), + MonitorDefaultToPrimary); + if (monitor == 0) + { + return null; + } + + var monitorInfo = new MonitorInfoEx + { + CbSize = (uint)sizeof(MonitorInfoEx), + }; + + return GetMonitorInfo(monitor, ref monitorInfo) + ? monitorInfo.GetDeviceName() + : null; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct NativePoint + { + public NativePoint(int x, int y) + { + X = x; + Y = y; + } + + public readonly int X; + + public readonly int Y; + } + + [LibraryImport("user32.dll", EntryPoint = "MonitorFromPoint")] + private static partial nint MonitorFromPointNative( + NativePoint point, + uint flags); +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs index 1eb19560ae..e350ac072b 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs @@ -98,6 +98,7 @@ public partial class MainViewModel : ObservableObject, IDisposable ShowProfileSwitcher = true; ShowIdentifyMonitorsButton = true; MouseWheelIncrement = 5; + MouseWheelControlMode = PowerDisplay.Models.MouseWheelControlMode.Disabled; // Initialize settings utils _settingsUtils = SettingsUtils.Default; @@ -138,6 +139,12 @@ public partial class MainViewModel : ObservableObject, IDisposable [ObservableProperty] public partial int MouseWheelIncrement { get; set; } + /// + /// Gets or sets the mouse-wheel mode used for the tray icon, loaded from PowerDisplay settings. + /// + [ObservableProperty] + public partial MouseWheelControlMode MouseWheelControlMode { get; set; } + /// /// Gets or sets a value indicating whether brightness slider changes are broadcast to all /// non-excluded monitors as one linked level. Persisted in PowerDisplaySettings so @@ -529,6 +536,7 @@ public partial class MainViewModel : ObservableObject, IDisposable ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher; ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton; MouseWheelIncrement = settings.Properties.MouseWheelIncrement; + MouseWheelControlMode = settings.Properties.MouseWheelControlMode.Normalize(); // Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this // method runs after monitors are already discovered, the toggle hook can seed the master diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs index 5045a398b3..17c8fa06e2 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs @@ -263,6 +263,11 @@ public partial class MonitorViewModel : ObservableObject, IDisposable /// public int MonitorNumber => _monitor.MonitorNumber; + /// + /// Gets the GDI display source name used to match the Windows primary display. + /// + public string GdiDeviceName => _monitor.GdiDeviceName; + /// /// Gets the display name - includes monitor number when multiple monitors exist. /// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency. @@ -309,6 +314,12 @@ public partial class MonitorViewModel : ObservableObject, IDisposable public bool SupportsBrightness => _monitor.SupportsBrightness; + /// + /// Gets a value indicating whether discovery read a trustworthy current brightness. + /// + public bool HasValidBrightnessReading + => _monitor.ReadValues.HasFlag(MonitorReadFlags.Brightness); + /// /// Gets a value indicating whether this monitor's brightness is currently driven by linked /// mode rather than its own slider. True when the parent has link mode on, this monitor diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs index b9ec6534bc..c2398071ea 100644 --- a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ActivationShortcut = DefaultActivationShortcut; MonitorRefreshDelay = 5; MouseWheelIncrement = 5; + MouseWheelControlMode = MouseWheelControlMode.Disabled; Monitors = new List(); RestoreSettingsOnStartup = false; ShowSystemTrayIcon = true; @@ -56,6 +57,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("mouse_wheel_increment")] public int MouseWheelIncrement { get; set; } + /// + /// Gets or sets which displays a mouse-wheel notch over the PowerDisplay tray icon adjusts. + /// Scoped to the tray icon: the flyout sliders accept wheel input regardless. + /// + [JsonPropertyName("mouse_wheel_control_mode")] + public MouseWheelControlMode MouseWheelControlMode { get; set; } + [JsonPropertyName("monitors")] public List Monitors { get; set; } diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerDisplay.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerDisplay.cs new file mode 100644 index 0000000000..4b09fe4473 --- /dev/null +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerDisplay.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.UnitTests.BackwardsCompatibility; +using Microsoft.PowerToys.Settings.UI.UnitTests.Mocks; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Models; + +namespace ViewModelTests; + +// SA1649: file is named PowerDisplay.cs to match the module naming convention used throughout +// ViewModelTests (FancyZones.cs, ColorPicker.cs, etc.). +[TestClass] +public class PowerDisplay +{ + [TestMethod] + public void MouseWheelMode_DefaultsToDisabled() + { + using var viewModel = CreateViewModel(out _); + + Assert.AreEqual( + (int)MouseWheelControlMode.Disabled, + viewModel.MouseWheelControlModeIndex); + } + + [TestMethod] + public void MouseWheelMode_SetPrimaryDisplay_PersistsAndRoundTrips() + { + using var viewModel = CreateViewModel(out var settings); + + viewModel.MouseWheelControlModeIndex = (int)MouseWheelControlMode.PrimaryDisplay; + + Assert.AreEqual( + MouseWheelControlMode.PrimaryDisplay, + settings.Properties.MouseWheelControlMode); + Assert.AreEqual( + (int)MouseWheelControlMode.PrimaryDisplay, + viewModel.MouseWheelControlModeIndex); + } + + [TestMethod] + public void MouseWheelMode_SetAllDisplays_PersistsAndRoundTrips() + { + using var viewModel = CreateViewModel(out var settings); + + viewModel.MouseWheelControlModeIndex = (int)MouseWheelControlMode.AllDisplays; + + Assert.AreEqual( + MouseWheelControlMode.AllDisplays, + settings.Properties.MouseWheelControlMode); + Assert.AreEqual( + (int)MouseWheelControlMode.AllDisplays, + viewModel.MouseWheelControlModeIndex); + } + + // The ComboBox in PowerDisplayPage.xaml binds SelectedIndex straight to the enum value, so the + // declared item order (Off, Primary display, All displays) is load-bearing. Pin the numbering + // here: inserting a new mode anywhere but at the end would silently remap existing settings. + [TestMethod] + public void MouseWheelMode_EnumValues_MatchComboBoxItemOrder() + { + Assert.AreEqual(0, (int)MouseWheelControlMode.Disabled); + Assert.AreEqual(1, (int)MouseWheelControlMode.PrimaryDisplay); + Assert.AreEqual(2, (int)MouseWheelControlMode.AllDisplays); + } + + [TestMethod] + public void MouseWheelMode_UnsupportedIndex_IsIgnored() + { + using var viewModel = CreateViewModel(out var settings); + var changedProperties = new List(); + viewModel.PropertyChanged += (_, args) => changedProperties.Add(args.PropertyName); + + viewModel.MouseWheelControlModeIndex = 99; + + Assert.AreEqual( + MouseWheelControlMode.Disabled, + settings.Properties.MouseWheelControlMode); + CollectionAssert.Contains( + changedProperties, + nameof(PowerDisplayViewModel.MouseWheelControlModeIndex)); + } + + private static PowerDisplayViewModel CreateViewModel(out PowerDisplaySettings settings) + { + var powerDisplaySettingsUtils = + ISettingsUtilsMocks.GetStubSettingsUtils(); + var generalSettingsUtils = + ISettingsUtilsMocks.GetStubSettingsUtils(); + + settings = powerDisplaySettingsUtils.Object.GetSettingsOrDefault( + PowerDisplaySettings.ModuleName); + + return new PowerDisplayViewModel( + powerDisplaySettingsUtils.Object, + new BackCompatTestProperties.MockSettingsRepository( + generalSettingsUtils.Object), + new BackCompatTestProperties.MockSettingsRepository( + powerDisplaySettingsUtils.Object), + _ => 0, + (_, _) => { }); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml index e6d7c0252e..ed75ec7cd0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml @@ -84,6 +84,22 @@ ItemsSource="{x:Bind ViewModel.MonitorRefreshDelayOptions}" SelectedItem="{x:Bind ViewModel.MonitorRefreshDelay, Mode=TwoWay}" /> + + + + + + + + Number of seconds to wait after display changes before refreshing monitors. Increase if monitors are not detected after hot-plug. + + Tray icon mouse wheel + + + Choose which displays are adjusted when scrolling over the Power Display tray icon. While linked brightness is on, adjusting any display in the linked group moves the whole group. + {Locked="Power Display"} + + + Tray icon mouse wheel mode + + + Off + + + Primary display + + + All displays + Mouse wheel increment - How much brightness, contrast, and volume sliders change per mouse wheel notch. + How much brightness changes from tray scrolling, and how much brightness, contrast, and volume sliders change per mouse wheel notch. Advanced diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs index 7fa05b8dae..8bb7643ead 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs @@ -390,6 +390,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public List MonitorRefreshDelayOptions => _monitorRefreshDelayOptions; + /// + /// Gets or sets the selected mouse-wheel mode as the ComboBox index. + /// Enum values intentionally match the displayed item order. + /// + public int MouseWheelControlModeIndex + { + get => (int)_settings.Properties.MouseWheelControlMode.Normalize(); + set + { + var mode = ((MouseWheelControlMode)value).Normalize(); + if ((int)mode != value) + { + OnPropertyChanged(nameof(MouseWheelControlModeIndex)); + return; + } + + if (SetSettingsProperty( + _settings.Properties.MouseWheelControlMode, + mode, + v => _settings.Properties.MouseWheelControlMode = v)) + { + SignalSettingsUpdated(); + } + } + } + /// /// Gets or sets the per-mouse-wheel-notch step shared by all PowerDisplay flyout sliders. ///