PowerDisplay: Adjust brightness by scrolling over the tray icon - #49446
Merged
Conversation
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
- Add MouseWheelControlMode and IsMouseWheelControlEnabled to MainViewModel - Load and normalize the mode in LoadUIDisplaySettings - Proxy IsMouseWheelControlEnabled to MonitorViewModel; forward notifications - Replace four literal IsMouseWheelEnabled=True in XAML with OneWay bindings Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
moooyo
marked this pull request as draft
July 22, 2026 10:17
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Co-authored-by: Copilot <[email protected]> Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
Addresses review feedback on the tray-icon mouse wheel work. Notification icon version 4 The icon was registered with only NIF_MESSAGE|NIF_ICON so the Shell would not draw its standard tooltip over the custom overlay. That also dropped szTip, which is what names the icon in the hidden-icon overflow flyout, in Settings > Personalization > Taskbar and for UI Automation, so the icon lost its accessible name and could not be identified by keyboard users. Register NIF_TIP with szTip again and opt into NOTIFYICON_VERSION_4 without NIF_SHOWTIP, which is the supported way to keep the name while suppressing the standard tooltip. Callback packing differs between versions, so WindowProc now decodes according to whether NIM_SETVERSION succeeded and falls back to the legacy layout if it did not. Version 4 also delivers NIN_POPUPOPEN/NIN_POPUPCLOSE for hover, NIN_SELECT and NIN_KEYSELECT for activation (so the flyout can now be opened from the keyboard) and WM_CONTEXTMENU with the Shell-chosen anchor point. Dismiss the hover overlay on click Neither activation nor the context menu stopped the feedback session, so the card stayed up around the menu and outlived it until the pointer left the icon. The Shell tooltip it replaced disappeared on click. Consume the wheel notch The low-level hook always forwarded WM_MOUSEWHEEL through CallNextHookEx, so with "Scroll inactive windows when I hover over them" turned off a notch changed brightness and scrolled the focused window at the same time. The hook now returns a non-zero LRESULT for notches taken over the icon. Arming moved behind CanProcessMouseWheel so an armed hover always means the notch will be acted on; out-of-bounds samples are still queued but not swallowed. Overlay teardown and retry DisposeFeedbackWindow routed through HideFeedback, but TransparentWindow.Hide defers to the dispatcher, so the queued callback ran against a window the same method had already closed. Close directly instead. A single overlay construction failure also latched permanently because the retry gate and the log-once flag were the same field; they are now separate and the gate clears when the hover session ends. _feedbackPresentationFailureLogged now resets on success like every other log-once flag in the file. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The hover overlay was kept alive by a repeating 100 ms DispatcherQueueTimer whose tick did a GetCursorPos plus a cross-process Shell_NotifyIconGetRect, so a resting pointer cost ten shell round-trips per second for as long as it sat on the icon. That poll existed for two reasons: notification icons get no WM_MOUSELEAVE, and the presentation has two time-driven transitions (the 500 ms hover reveal and the 2 s adjustment readout expiry). NOTIFYICON_VERSION_4 now reports the departure through NIN_POPUPCLOSE, which leaves only the two deadlines. TrayWheelFeedbackSession.NextTransitionDelay reports how long until the next one, or null when the presentation is stable until the pointer leaves, so the service arms a single non-repeating tick for exactly that moment and arms nothing at all in the steady state. A hover now costs at most two ticks instead of ten per second. The 100 ms cadence is kept only when NIM_SETVERSION failed and the legacy callback protocol is in use, since that protocol has no departure notification and polling the cursor remains the only way to notice. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Fixes found while reviewing the tray mouse-wheel feature: - Tray wheel bypassed linked-brightness mode. AdjustBrightnessFromTrayWheel assigned MonitorViewModel.Brightness per monitor, which schedules a per-VM debounced hardware write, violating the invariant documented on UpdateBrightnessDisplay that the master broadcast is the single source of hardware writes while link mode is on. The master slider was left stale and the next master gesture silently reverted the adjustment. Linked targets now drive LinkedBrightness so the existing broadcast fans out and the master stays in sync; excluded monitors keep the per-monitor path. - The hover overlay could stay on screen indefinitely. Once the presentation was stable the feedback timer stopped, leaving NIN_POPUPCLOSE as the only way to hide an always-on-top window; the Shell only sends it as the partner of a NIN_POPUPOPEN emitted after its own hover dwell, which can outlast the overlay delay. The tick now keeps watching for the pointer leaving. - The hook armed without checking that any monitor could accept a brightness write, so on hardware with DDC/CI unavailable it consumed the wheel notch with no effect. The readiness gate now mirrors the planner's eligibility rule, which is what CanProcessMouseWheel already claimed to guarantee. - Destroy() gated NIM_DELETE on _isTrayIconRegistered, which the health probe clears while the icon is still registered, orphaning the tray icon on exit. Always attempt the delete; it is a harmless no-op when the icon is gone. - TrayIconMouseWheelListener construction could throw out of the window procedure and leaked its thread. Latch the failure like the feedback window already does, and stop the abandoned thread before entering its message loop. - Log once when a tray hover falls outside the reported icon rectangle, so the notification-overflow case is diagnosable instead of silent. - Cover negative sub-notch wheel accumulation and the WHEEL_DELTA boundary, and assert the adjustment text replayed by Tick. - Spell the product name "Power Display" in the new settings description, to match the rest of the page. Co-Authored-By: Claude <[email protected]>
While link mode is on the wheel drives the master value, so every linked
display moves regardless of the selected scope. The overlay still read
"Primary display · N%", under-reporting what changed.
Add a TrayWheelFeedbackLinkedFormat template ("Linked displays · {0}") and
select it whenever linked brightness drove the adjustment. The linked group
now contributes a single value to the readout instead of repeating the same
percentage per monitor; monitors excluded from the group keep their own entry.
Co-Authored-By: Claude <[email protected]>
… off The mouse wheel increment card was gated on the new mouse wheel mode, but that value is not wheel-only: CliRequestHandler passes it to the CLI as the default relative-adjust step (AdjustCommandExecutor uses it whenever a request omits --step). Selecting "Off" therefore left the command line stuck on whatever step happened to be saved, with no way to change it. Ungate the card and record why it must stay ungated.
…llowed ProcessWheelSampleBatch dropped the whole batch as soon as one sample failed validation. The hook swallows a notch (returns a non-zero LRESULT) whenever it lands inside the rectangle it was armed with, so a batch that mixes swallowed samples with a retired-generation or moved-icon sample consumed input and then adjusted nothing - the notch reached neither PowerDisplay nor the window under the cursor. The generation gap is real because Arm/Disarm reach the hook thread through PostThreadMessage, so the UI thread can retire a hover while the hook is still armed with the previous one. Partition the batch instead: apply the samples that were swallowed on our behalf, drop only the ones that were not, and retire the hover afterwards. Stale samples now clear the accumulator without taking the rest of the batch with them. Also in this file, from the same review pass: - Drop the second IsTrayIconRegistrationHealthy() call. It re-invoked a pure function with no delay or state change in between, so it could not observe the transient failure its comment described. - Stop polling a healthy registration every five seconds. Recovery is already event driven - the TaskbarCreated broadcast, a failing Shell_NotifyIconGetRect on the next hover, and the settings-update path each schedule their own check, and TrayIconRegistrationBackoff keeps retrying indefinitely while NIM_ADD fails. The poll only added a cross-process call and a pointless PostThreadMessage(WmSetEnabled) every five seconds for the process lifetime. - Do not let WM_WINDOWPOSCHANGING shorten a pending backoff retry to 1 ms; a window-position message is not evidence that Explorer came back. - Subscribe the timer Tick handlers once at creation instead of detaching and reattaching them on every schedule, which allocated two delegates per tray mouse-move message. - Cache the localized app name instead of calling ResourceLoader.GetString on every tray mouse-move message once the hover text is up.
CanAdjustBrightnessFromTrayWheel is read from IsMouseWheelAdjustmentReady on every tray mouse-move message, and it answered by running the full planner: a List<Target> for the monitors, a List<Adjustment> for the result and a boxed enumerator, all to look at whether the count was greater than zero. Split the per-monitor eligibility rule out of TrayWheelAdjustmentPlanner.Plan into IsEligible so the gate can scan for the first eligible monitor over the collection's indexer and allocate nothing, while Plan keeps using the same predicate - the gate and the planner cannot drift apart. IsEligible also rejects PrimaryDisplay with an unresolved primary name, so a monitor that reports no GDI name cannot be paired with a null primary and called a match. Hoist the preconditions and the primary lookup shared by the gate and the planner into TryGetTrayWheelScope. Add planner tests for IsEligible, including one that asserts it selects exactly the monitors Plan produces adjustments for, in every mode.
The constructor threw after InitializeComponent had already realized the window, and TrayIconService only latches the failure - nothing ever received the instance, so the HWND stayed alive for the process lifetime. Close it before rethrowing. Also record why TrayWheelFeedbackFormatter bounds its output and why LimitUtf16 strips a trailing lone high surrogate even from a string that already fits, so neither reads as a leftover from the szTip the overlay replaced.
PowerDisplayViewModel.IsMouseWheelControlEnabled existed only to gate the mouse wheel increment card, which no longer gates on the mode. Remove the property and the change notification that fed it, and narrow the two tests that asserted on it to what they still cover: the default index, and that a disabled mode persists and round-trips through the index.
UpdateAdjustmentFeedback read seven resource strings and allocated a TrayWheelFeedbackTemplates record on every adjustment, which a fast wheel produces several times a second. The strings cannot change without an app restart, so build the record once. The construction stays inside the try block so a failed lookup still reaches the existing handler instead of silently substituting placeholder text. While in the method, drop two branches that could not be taken: both tested _feedbackIconBounds for a value it had been assigned a few lines earlier, on a path the pointer-outside early return already guarantees. Use the local rectangle directly, matching how OnFeedbackTimerTick reads it. Also reword three comments to stay inside the spell-check dictionary.
Picking "Primary display" does not keep the adjustment to one display when linked brightness is on: AdjustBrightnessFromTrayWheel writes LinkedBrightness whenever the selected target belongs to the linked group, and that broadcasts to the whole group. The feedback readout already says "Linked displays", but the setting that promises a single display said nothing about it.
TrayWheelFeedbackPlacement computes pure integer geometry, but it took and returned Windows.Graphics.RectInt32 - the only WinRT projection type anywhere in PowerDisplay.Lib, which is otherwise a UI-free, AOT-compatible assembly shared with PowerDisplay.Ipc and the test project. (The remaining Windows.* usings there are CsWin32-generated P/Invoke structs, not WinRT.) Introduce PixelRect next to TrayIconBounds and let TrayWheelFeedbackWindow convert at the boundary, where the UI type belongs. No behaviour change: the struct mirrors RectInt32's shape, so the placement tests only change type name.
added 2 commits
July 29, 2026 14:03
Review follow-ups on the tray icon mouse wheel work. Linked brightness. AdjustBrightnessFromTrayWheel stepped LinkedBrightness relative to itself, but that master value 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 named. With the master at 100 and the wheel target at 40, scrolling up clamped to 100, assigned the same value, and OnLinkedBrightnessChanged never fired: the hook swallowed every notch while nothing moved and the overlay reported 100%. Scrolling down moved that target from 40 to 95 instead. Derive the new master from the planner's value for the monitor the wheel named - already clamped against its real brightness - so the gesture always moves that monitor in the direction the user scrolled. Off is now a real opt-out. NIM_SETVERSION and the hover overlay were not gated on the mode, so a user who turned mouse wheel control off still lost the standard Shell tooltip and still got a XAML overlay plus hover polling. Keep NIF_SHOWTIP while the mode is Disabled, re-apply it through NIM_MODIFY when the setting changes, and return early from HandleTrayMouseMove. This also restores hover text for keyboard and touch, which never reach the cursor-gated overlay. Hover state machine: - ProcessWheelSampleBatch now retires the hover when CanProcessMouseWheel goes false. It was the only early-out that left the hook armed, so a parked pointer had notches swallowed and dropped for as long as the gate was false (a monitor rescan, for instance). - OnFeedbackTimerTick disarms as well as hiding. The icon rectangle can move out from under a pointer that never moved. - ScheduleFeedbackTick takes the earlier of the pending transition and the watchdog. A two-second adjustment readout used to disable departure detection for two seconds, stranding a topmost overlay when no NIN_POPUPCLOSE followed. - Activation only hides the overlay and keeps the wheel armed, and the context menu re-evaluates the hover once its modal loop returns. Both left wheel control dead until the user nudged the mouse. Hardening and cleanup: - ShowFeedbackOverlay rejects blank text and also catches ArgumentException. A missing AppName resource threw out of the tray window procedure. - The hook thread guards its message loop, and stops posting to a dead one. - EnsureMouseWheelListener catches more than InvalidOperationException; Thread.Start can fail in other ways and this runs on the window procedure. - Drop the write-only _feedbackIconBounds field. - Cover the AllDisplays index and pin the enum-to-ComboBox order in tests. - Document the tray hover and wheel path in the module design doc.
Reverts the NIF_SHOWTIP handover added in the previous commit and makes the overlay the single owner of tray hover text, on the grounds that two presentations means two things to maintain, test and keep from diverging. The overlay has to exist regardless of the setting: the wheel readout must appear on demand and the standard Shell tooltip cannot be shown programmatically. Given that, handing the app name back to the Shell only when the wheel is off buys a second hover experience for a mode that is otherwise identical. So NIF_SHOWTIP is never set, HandleTrayMouseMove runs in every mode, and Mouse wheel control set to Disabled now only disables the wheel. BuildTrayIconFlags and ApplyTrayIconTooltipMode go away with it. Two consequences are deliberate, and are now called out in the module design doc and in EnsureTrayIconIdentity so they are not rediscovered later: - Turning mouse wheel control off still replaces the standard tooltip with the overlay, and still pays for the lazily created XAML window and the hover watchdog. - A hover that never involves the cursor - keyboard or touch focus in the notification area - shows no text at all, because the overlay is only presented for a cursor inside the icon rectangle. szTip is still supplied, so the UI Automation name and the overflow flyout label are unaffected. The second one is a real gap in what the icon offers today and wants a maintainer's call before this ships.
11 tasks
added 2 commits
July 29, 2026 15:29
The brightness change is the feedback. Unlike a volume control - where an on-screen readout is the only signal available, and which is what the reference app in #49410 is - brightness is self-evidencing: you scroll and the screen changes, immediately and continuously. The numeric percentage adds little on top of that, and it was carrying the entire cost of this change. Showing it the instant you scroll meant the standard Shell tooltip would not do, because it cannot be shown on demand. That forced an own window, which forced suppressing the Shell tooltip so the two did not collide, which forced NOTIFYICON_VERSION_4, which changed the callback packing, which meant owning all hover text including the app name, hover timing, placement across four taskbar edges and mixed DPI, and a hover watchdog - and left keyboard and touch users with no visible tooltip at all, whether or not they ever scroll. Roughly half of this PR was that chain. Removed: - TrayWheelFeedbackWindow, TrayWheelFeedbackPlacement, TrayWheelFeedbackSession, TrayWheelFeedbackFormatter, TrayWheelFeedbackTemplates, PixelRect, TrayWheelAdjustmentFeedback and their tests - The NOTIFYICON_VERSION_4 migration and the NIN_* callback handling - The registration-recovery rewrite and TrayIconRegistrationBackoff; the pre-existing TaskbarCreated and WM_WINDOWPOSCHANGING retry is enough now that nothing needs a rectangle to place a window with - The TrayWheelFeedback* resource strings and the spell-check entries that only the version-4 code needed The notification icon is left exactly as it is on main: legacy protocol, standard tooltip, unchanged text. TrayIconService gains only what arming the hook needs - a WM_MOUSEMOVE case, a cached Shell_NotifyIconGetRect lookup, and the listener's lifetime. AdjustBrightnessFromTrayWheel returns void. Settings and behaviour are otherwise unchanged: Off / Primary display / All displays, defaulting to Primary display, with Off also disabling wheel input on the flyout sliders.
…nything TrayIconMouseWheelListener.SetEnabled is only ever called with true, from EnsureMouseWheelListener, immediately after construction. The listener is created when the mode is enabled and disposed when it is not, so _enabled could only ever go false -> true and the guard it feeds in HandleArm never fired. Removed the method, the WmSetEnabled message, HandleSetEnabled and the field. TryGetTrayWheelScope took a notches argument only to reject zero, which forced CanAdjustBrightnessFromTrayWheel to pass a made-up notches: 1 to probe for targets. The check was also unreachable: TrayWheelAdjustmentPlanner.Plan already returns nothing for a zero delta, and MouseWheelScrolled is only raised for a non-zero notch count. Dropped the parameter, so the method is about scope and nothing else.
added 5 commits
July 29, 2026 15:58
The setting also gated mouse-wheel input on the flyout sliders, which had been unconditional. That bundled two unrelated things behind one switch: a user who turns tray scrolling off to stop nudging brightness from the taskbar would also lose wheel adjustment on the contrast and volume sliders inside the flyout, which have nothing to do with the tray icon or with brightness. The four SliderExtensions.IsMouseWheelEnabled bindings go back to True, and MainViewModel.IsMouseWheelControlEnabled, MonitorViewModel.IsMouseWheelControlEnabled and the property-changed relay that fed them are gone with them. MainWindow.xaml is now identical to main. Renamed the setting to "Tray icon mouse wheel" so its name matches what it actually controls, and dropped the sentence about the flyout from its description. The resource keys, the enum and the JSON key are unchanged.
The Shell repeats WM_MOUSEMOVE for every pixel of travel across the icon, and HandleTrayMouseMove posted an Arm command for each one. Arming with a rectangle and generation the hook thread already holds does nothing, so that was a cross-thread PostThreadMessage per pixel. Skip it unless the listener is not armed or the rectangle actually changed. Also dropped the message loop's default branch. That thread owns no windows, so the only messages it can retrieve are the commands posted to it and WM_QUIT - the hook proc is invoked by the system during message retrieval, not through DispatchMessage - which makes TranslateMessage and DispatchMessage no-ops there. The two P/Invokes they needed go with them, and a comment says why nothing else is dispatched. HandleTrayMouseMove's bounds resolution is now a plain if/else if rather than a bool plus a null-forgiving dereference of the field the pattern already matched.
Routing the tray context menu's Exit action through Shutdown() fixes a pre-existing leak - Exit skipped TrayIconService.Destroy() and MainWindow.Dispose(), so the CLI named-pipe server was never stopped - and it stands on its own without this feature. Take it out of here so it can be reviewed and merged independently. Nothing in this branch depends on it: the hook thread is a background thread and the process is ending either way, so the only cost of exiting without it is that the thread is torn down by process exit rather than joined.
Two things made PrimaryDisplay a poor default. The gesture consumes a wheel notch that would otherwise reach the window under the pointer, so an existing installation would silently change behaviour on upgrade for anyone who scrolls near the notification area. And acting on the notch means installing a system-wide WH_MOUSE_LL hook; with the setting Off no hook is ever installed, which is a materially different proposition to opt into than to be opted into. Off by default also matches what the feature actually is: a shortcut for people who want it, not a correction to how the tray icon behaved before. Update the settings default, the view-model default used before settings load, the design doc and the tests that pinned the old default. Everything else, including the enum numbering, is unchanged.
moooyo
marked this pull request as ready for review
July 31, 2026 06:24
Shawn Yuan (shuaiyuanxx)
approved these changes
Jul 31, 2026
Contributor
Author
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary of the Pull Request
Scrolling the mouse wheel over the Power Display tray icon adjusts brightness, without opening the flyout.
Off/Primary display/All displays, defaulting toOff. 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.WH_MOUSE_LLhook. Neither is something an existing installation should acquire silently on upgrade. With the settingOffno 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.PR Checklist
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_MOUSEWHEELto a notification icon's callback window under anyNOTIFYICON_VERSION, and a click-through overlay placed over the icon cannot receive wheel input either.TrayIconMouseWheelListenertherefore installs aWH_MOUSE_LLhook — but only transiently, and only when it will act on the result:Off, which is the default.EnsureHook()when the UI thread confirms the pointer is inside the rectangle fromShell_NotifyIconGetRectandCanAdjustBrightnessFromTrayWheelsays some monitor can accept a brightness write.DisarmCore()as soon as either condition stops holding, the pointer leaves the rectangle, or the mode changes.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
WheelDeltaAccumulatorfolds 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_MOUSEMOVEto the icon's callback window while the pointer is over it.TrayIconService.HandleTrayMouseMoveresolves the rectangle withShell_NotifyIconGetRectand caches it for a second, because that message repeats for every pixel of travel.TrayIconServicegains nothing else: no protocol change, no new hover UI, no polling. The rest of the file — andMainWindow.xaml— is untouched.Linked brightness
While linked brightness is on, a notch has to move the whole group, so it goes through
MainViewModel.LinkedBrightnessrather 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 —SeedInitialLinkedBrightnesstakes 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 displayis 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
PowerDisplay.Lib.UnitTests215 passed,Settings.UI.UnitTests165 passed.PowerDisplayand Settings UI, x64 Debug, no warnings.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 asOff.Settings.UI.UnitTestscovers the view-model index mapping and pins the enum values to the ComboBox item order.TrayIconServiceandTrayIconMouseWheelListeneris 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,
Offstopping 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:
Primary displaymode adjusts a monitor the user may not be looking at.Shell_NotifyIconGetRectrectangle and the hook's physical-pixel hit test.