mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 10:09:43 +02:00
PowerDisplay: Adjust brightness by scrolling over the tray icon (#49446)
## Summary of the Pull Request Scrolling the mouse wheel over the Power Display tray icon adjusts brightness, without opening the flyout. - New **Tray icon mouse wheel** setting: `Off` / `Primary display` / `All displays`, defaulting to **`Off`**. It is scoped to the tray icon — the flyout sliders accept wheel input regardless, as they always have. The existing **Mouse wheel increment** setting supplies the per-notch step. - **Off by default.** The gesture consumes 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. With the setting `Off` no hook is ever installed and no notch is ever consumed, so this PR changes no existing behaviour until the user opts in: 1958 insertions, 2 deletions, and both deletions are refactors of lines this feature reuses. - **No feedback UI.** Brightness is self-evidencing — you scroll and the screen changes — so the display itself is the feedback. The notification icon is untouched: same tooltip, same text, same legacy notification-icon protocol. ## PR Checklist - [x] Closes: #49410 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx No new binaries or projects — everything lands in existing assemblies. Communication is unchecked because #49410 is still Needs-Triage. ## Detailed Description of the Pull Request / Additional comments ### 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, and only when it will act on the result: - Nothing is installed at all while the setting is `Off`, which is the default. - Installed in `EnsureHook()` when the UI thread confirms the pointer is inside the rectangle from `Shell_NotifyIconGetRect` **and** `CanAdjustBrightnessFromTrayWheel` says some monitor can accept a brightness write. - 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 Power Display will not act on still reaches the window under the cursor. The hook runs on a dedicated background thread with its own message loop; the proc itself only enqueues a sample and posts a drain request. Deltas are marshalled to the UI thread in batches, and `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 rectangle with `Shell_NotifyIconGetRect` and caches it for a second, because that message repeats for every pixel of travel. `TrayIconService` gains nothing else: no protocol change, no new hover UI, no polling. The rest of the file — and `MainWindow.xaml` — is untouched. ### Linked brightness While linked brightness is on, a notch has to move the whole group, so it goes through `MainViewModel.LinkedBrightness` rather than the individual monitor setters. The new master value is taken from the planner'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 it can sit arbitrarily far from the monitor the wheel is aimed at. Stepping it relative to itself would apply 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 setting description calls out that linked brightness widens the scope, so `Primary display` is not literally a single display while it is on. ### What is deliberately not here An earlier revision of this PR showed the target and percentage in a custom overlay as you scrolled. Doing that meant the standard Shell tooltip would not do (it cannot be shown on demand), which meant an own window, which meant suppressing the Shell tooltip so the two did not collide, which meant `NOTIFYICON_VERSION_4`, which changed the callback packing and made the app responsible for all hover text — including for keyboard and touch users, who never reach a cursor-anchored overlay and would have been left with no visible tooltip at all. That chain was about half the diff, for a readout that adds little on top of watching the screen change. It is gone. If a readout is wanted later it can be argued on its own merits, separately from this feature. The same revision also gated the flyout sliders on this setting. That bundled two unrelated things behind one switch — turning off tray scrolling would also have stopped the contrast and volume sliders responding to the wheel — so the setting is now scoped to the tray icon and named accordingly. An earlier revision also routed the tray **Exit** action through `Shutdown()`. That fixes a pre-existing teardown leak which has nothing to do with this feature, so it now lives in #49580 and is out of scope here. This branch does not depend on it: the hook thread is a background thread and the process is ending either way. ## Validation Steps Performed - Unit tests: `PowerDisplay.Lib.UnitTests` 215 passed, `Settings.UI.UnitTests` 165 passed. - Builds: `PowerDisplay` and Settings UI, x64 Debug, no warnings. - Automated coverage is in `PowerDisplay.Lib.UnitTests`: target selection per mode, wheel accumulation including negative deltas, partial notches and direction reversal, half-open rectangle containment, and settings serialization and round-trip for the new mode, including that a settings file predating the feature loads as `Off`. `Settings.UI.UnitTests` covers the view-model index mapping and pins the enum values to the ComboBox item order. - The Win32 glue in `TrayIconService` and `TrayIconMouseWheelListener` is not unit tested. Manual passes performed: scrolling over the icon in both modes, the icon parked in the notification overflow, high-resolution wheel input, brightness boundaries, live monitor refresh while hovering, tray icon hidden and re-enabled, Explorer restart, the context menu and left-click, `Off` stopping tray scrolling while the flyout sliders keep working, and confirming a notch that Power Display will not act on still reaches the window under the cursor. Not verified, needing hardware this branch has not been run on: - Multiple taskbars, where the tray icon is on a secondary display and `Primary display` mode adjusts a monitor the user may not be looking at. - Mixed-DPI setups, for the `Shell_NotifyIconGetRect` rectangle and the hook's physical-pixel hit test. --------- Co-authored-by: Yu Leng <yuleng@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
This commit is contained in:
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -1117,6 +1117,7 @@ MSIRESTARTMANAGERCONTROL
|
||||
MSIs
|
||||
msixbundle
|
||||
MSIXCA
|
||||
msll
|
||||
MSLLHOOKSTRUCT
|
||||
Mso
|
||||
mspub
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<PowerDisplayProperties>(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<PowerDisplayProperties>(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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a notification icon rectangle in virtual-screen coordinates.
|
||||
/// </summary>
|
||||
public readonly record struct TrayIconBounds(int Left, int Top, int Right, int Bottom)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the rectangle has positive width and height.
|
||||
/// </summary>
|
||||
public bool IsValid => Right > Left && Bottom > Top;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a screen point is inside the rectangle.
|
||||
/// </summary>
|
||||
/// <param name="x">The virtual-screen X coordinate.</param>
|
||||
/// <param name="y">The virtual-screen Y coordinate.</param>
|
||||
/// <returns><see langword="true"/> when the point is inside the rectangle.</returns>
|
||||
public bool Contains(int x, int y)
|
||||
=> IsValid && x >= Left && x < Right && y >= Top && y < Bottom;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Plans relative brightness changes for validated tray wheel input.
|
||||
/// </summary>
|
||||
public static class TrayWheelAdjustmentPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Describes a visible monitor's state needed for tray wheel planning.
|
||||
/// </summary>
|
||||
public readonly record struct Target(
|
||||
string Id,
|
||||
string GdiDeviceName,
|
||||
bool SupportsBrightness,
|
||||
bool HasBrightnessReading,
|
||||
int CurrentBrightness);
|
||||
|
||||
/// <summary>
|
||||
/// Describes one monitor brightness update.
|
||||
/// </summary>
|
||||
public readonly record struct Adjustment(string Id, int Brightness);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether one monitor would receive an update for the supplied mode. Split out of
|
||||
/// <see cref="Plan"/> so a caller that only needs to know whether any target exists at all can
|
||||
/// answer that without materializing the adjustment list.
|
||||
/// </summary>
|
||||
/// <param name="mode">The effective mouse-wheel mode.</param>
|
||||
/// <param name="target">The monitor state to test.</param>
|
||||
/// <param name="primaryGdiDeviceName">The primary logical display's GDI name.</param>
|
||||
/// <returns><see langword="true"/> when the monitor is a valid target.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects eligible targets and computes clamped brightness values.
|
||||
/// </summary>
|
||||
/// <param name="mode">The effective mouse-wheel mode.</param>
|
||||
/// <param name="targets">Visible monitor states.</param>
|
||||
/// <param name="primaryGdiDeviceName">The primary logical display's GDI name.</param>
|
||||
/// <param name="delta">The signed relative brightness delta.</param>
|
||||
/// <returns>Brightness updates in target enumeration order.</returns>
|
||||
public static IReadOnlyList<Adjustment> Plan(
|
||||
MouseWheelControlMode mode,
|
||||
IEnumerable<Target> 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<Adjustment>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates high-resolution wheel deltas into complete wheel notches.
|
||||
/// </summary>
|
||||
public sealed class WheelDeltaAccumulator
|
||||
{
|
||||
/// <summary>
|
||||
/// The Win32 delta value for one complete wheel notch.
|
||||
/// </summary>
|
||||
public const int WheelDelta = 120;
|
||||
|
||||
private int _remainder;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a signed wheel delta and returns the number of newly completed notches.
|
||||
/// </summary>
|
||||
/// <param name="delta">The signed Win32 wheel delta.</param>
|
||||
/// <returns>The number of newly completed notches.</returns>
|
||||
public int Add(int delta)
|
||||
{
|
||||
var total = (long)_remainder + delta;
|
||||
var notches = (int)(total / WheelDelta);
|
||||
_remainder = (int)(total % WheelDelta);
|
||||
return notches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any incomplete wheel-notch remainder.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_remainder = 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Defines how PowerDisplay handles mouse-wheel input.
|
||||
/// </summary>
|
||||
public enum MouseWheelControlMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Disables tray-icon and flyout-slider mouse-wheel adjustment.
|
||||
/// </summary>
|
||||
Disabled = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Enables flyout-slider adjustment and targets the primary display from the tray icon.
|
||||
/// </summary>
|
||||
PrimaryDisplay = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Enables flyout-slider adjustment and targets all visible displays from the tray icon.
|
||||
/// </summary>
|
||||
AllDisplays = 2,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides validation helpers for <see cref="MouseWheelControlMode"/>.
|
||||
/// </summary>
|
||||
public static class MouseWheelControlModeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a supported mode, or <see cref="MouseWheelControlMode.Disabled"/> for an
|
||||
/// unsupported persisted numeric value.
|
||||
/// </summary>
|
||||
/// <param name="mode">The persisted mode value.</param>
|
||||
/// <returns>A supported mode value.</returns>
|
||||
public static MouseWheelControlMode Normalize(this MouseWheelControlMode mode)
|
||||
=> mode is MouseWheelControlMode.Disabled
|
||||
or MouseWheelControlMode.PrimaryDisplay
|
||||
or MouseWheelControlMode.AllDisplays
|
||||
? mode
|
||||
: MouseWheelControlMode.Disabled;
|
||||
}
|
||||
@@ -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<TrayWheelSample[]> _sampleBatchHandler;
|
||||
private readonly Action<long> _disarmedHandler;
|
||||
private readonly ManualResetEventSlim _ready = new();
|
||||
private readonly object _pendingStateLock = new();
|
||||
private readonly Queue<TrayWheelSample> _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<TrayWheelSample[]> sampleBatchHandler,
|
||||
Action<long> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the low-level hook is armed for the current hover.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Raised on the UI thread with the signed number of complete wheel notches delivered over
|
||||
/// the icon.
|
||||
/// </summary>
|
||||
internal event Action<int>? MouseWheelScrolled;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal Func<bool>? CanProcessMouseWheel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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>(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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the persisted mouse-wheel mode, starting or stopping the hook thread with it.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void InvalidateMouseWheelHover(bool disarm)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
_hoverGeneration++;
|
||||
}
|
||||
|
||||
_cachedBounds = null;
|
||||
_boundsCacheTimestamp = 0;
|
||||
_wheelDeltaAccumulator.Reset();
|
||||
|
||||
if (disarm)
|
||||
{
|
||||
_mouseWheelListener?.Disarm();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="notches">The signed number of complete wheel notches.</param>
|
||||
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<TrayWheelAdjustmentPlanner.Adjustment> PlanTrayWheelAdjustments(
|
||||
MouseWheelMode mode,
|
||||
int notches)
|
||||
{
|
||||
if (!TryGetTrayWheelScope(mode, out var primaryGdiDeviceName))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var targets = new List<TrayWheelAdjustmentPlanner.Target>(Monitors.Count);
|
||||
foreach (var monitor in Monitors)
|
||||
{
|
||||
targets.Add(CreateTrayWheelTarget(monitor));
|
||||
}
|
||||
|
||||
return TrayWheelAdjustmentPlanner.Plan(
|
||||
mode,
|
||||
targets,
|
||||
primaryGdiDeviceName,
|
||||
(long)notches * MouseWheelIncrement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the preconditions shared by <see cref="CanAdjustBrightnessFromTrayWheel"/> and
|
||||
/// <see cref="PlanTrayWheelAdjustments"/>, and resolves the primary display's GDI name for the
|
||||
/// modes that need it.
|
||||
/// </summary>
|
||||
/// <param name="mode">The normalized mouse-wheel mode.</param>
|
||||
/// <param name="primaryGdiDeviceName">The resolved primary GDI name, or <see langword="null"/>
|
||||
/// when the mode does not target the primary display.</param>
|
||||
/// <returns><see langword="true"/> when a tray wheel adjustment is possible in principle.</returns>
|
||||
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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the mouse-wheel mode used for the tray icon, loaded from PowerDisplay settings.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial MouseWheelControlMode MouseWheelControlMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
|
||||
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> 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
|
||||
|
||||
@@ -263,6 +263,11 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
/// </summary>
|
||||
public int MonitorNumber => _monitor.MonitorNumber;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the GDI display source name used to match the Windows primary display.
|
||||
/// </summary>
|
||||
public string GdiDeviceName => _monitor.GdiDeviceName;
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether discovery read a trustworthy current brightness.
|
||||
/// </summary>
|
||||
public bool HasValidBrightnessReading
|
||||
=> _monitor.ReadValues.HasFlag(MonitorReadFlags.Brightness);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
ActivationShortcut = DefaultActivationShortcut;
|
||||
MonitorRefreshDelay = 5;
|
||||
MouseWheelIncrement = 5;
|
||||
MouseWheelControlMode = MouseWheelControlMode.Disabled;
|
||||
Monitors = new List<MonitorInfo>();
|
||||
RestoreSettingsOnStartup = false;
|
||||
ShowSystemTrayIcon = true;
|
||||
@@ -56,6 +57,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("mouse_wheel_increment")]
|
||||
public int MouseWheelIncrement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mouse_wheel_control_mode")]
|
||||
public MouseWheelControlMode MouseWheelControlMode { get; set; }
|
||||
|
||||
[JsonPropertyName("monitors")]
|
||||
public List<MonitorInfo> Monitors { get; set; }
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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<PowerDisplaySettings>();
|
||||
var generalSettingsUtils =
|
||||
ISettingsUtilsMocks.GetStubSettingsUtils<GeneralSettings>();
|
||||
|
||||
settings = powerDisplaySettingsUtils.Object.GetSettingsOrDefault<PowerDisplaySettings>(
|
||||
PowerDisplaySettings.ModuleName);
|
||||
|
||||
return new PowerDisplayViewModel(
|
||||
powerDisplaySettingsUtils.Object,
|
||||
new BackCompatTestProperties.MockSettingsRepository<GeneralSettings>(
|
||||
generalSettingsUtils.Object),
|
||||
new BackCompatTestProperties.MockSettingsRepository<PowerDisplaySettings>(
|
||||
powerDisplaySettingsUtils.Object),
|
||||
_ => 0,
|
||||
(_, _) => { });
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,22 @@
|
||||
ItemsSource="{x:Bind ViewModel.MonitorRefreshDelayOptions}"
|
||||
SelectedItem="{x:Bind ViewModel.MonitorRefreshDelay, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard x:Uid="PowerDisplay_MouseWheelControlMode">
|
||||
<ComboBox
|
||||
x:Uid="PowerDisplay_MouseWheelControlModeComboBox"
|
||||
MinWidth="{StaticResource PowerDisplayCompactActionControlMinWidth}"
|
||||
SelectedIndex="{x:Bind ViewModel.MouseWheelControlModeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="PowerDisplay_MouseWheelControlMode_Off" />
|
||||
<ComboBoxItem x:Uid="PowerDisplay_MouseWheelControlMode_PrimaryDisplay" />
|
||||
<ComboBoxItem x:Uid="PowerDisplay_MouseWheelControlMode_AllDisplays" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<!--
|
||||
Deliberately not gated on the tray icon wheel mode: this step
|
||||
is shared by the flyout sliders and by the command-line
|
||||
relative adjust (AdjustCommandExecutor), both of which stay
|
||||
available when tray scrolling is off.
|
||||
-->
|
||||
<tkcontrols:SettingsCard x:Uid="PowerDisplay_MouseWheelIncrement">
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource PowerDisplayCompactActionControlMinWidth}"
|
||||
|
||||
@@ -5641,11 +5641,30 @@ The break timer font matches the text font.</value>
|
||||
<data name="PowerDisplay_MonitorRefreshDelay.Description" xml:space="preserve">
|
||||
<value>Number of seconds to wait after display changes before refreshing monitors. Increase if monitors are not detected after hot-plug.</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlMode.Header" xml:space="preserve">
|
||||
<value>Tray icon mouse wheel</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlMode.Description" xml:space="preserve">
|
||||
<value>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.</value>
|
||||
<comment>{Locked="Power Display"}</comment>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlModeComboBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Tray icon mouse wheel mode</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlMode_Off.Content" xml:space="preserve">
|
||||
<value>Off</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlMode_PrimaryDisplay.Content" xml:space="preserve">
|
||||
<value>Primary display</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelControlMode_AllDisplays.Content" xml:space="preserve">
|
||||
<value>All displays</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelIncrement.Header" xml:space="preserve">
|
||||
<value>Mouse wheel increment</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelIncrement.Description" xml:space="preserve">
|
||||
<value>How much brightness, contrast, and volume sliders change per mouse wheel notch.</value>
|
||||
<value>How much brightness changes from tray scrolling, and how much brightness, contrast, and volume sliders change per mouse wheel notch.</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_AdvancedSettings.Header" xml:space="preserve">
|
||||
<value>Advanced</value>
|
||||
|
||||
@@ -390,6 +390,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected mouse-wheel mode as the ComboBox index.
|
||||
/// Enum values intentionally match the displayed item order.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-mouse-wheel-notch step shared by all PowerDisplay flyout sliders.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user