Skip to content

PowerDisplay: Adjust brightness by scrolling over the tray icon - #49446

Merged
moooyo merged 46 commits into
mainfrom
yuleng/worktree/issue-49410-design
Jul 31, 2026
Merged

PowerDisplay: Adjust brightness by scrolling over the tray icon#49446
moooyo merged 46 commits into
mainfrom
yuleng/worktree/issue-49410-design

Conversation

@moooyo

@moooyo moooyo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

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.

Yu Leng and others added 13 commits July 21, 2026 16:55
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
moooyo marked this pull request as draft July 22, 2026 10:17
Yu Leng and others added 15 commits July 22, 2026 18:41
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]>
Yu Leng (from Dev Box) and others added 9 commits July 28, 2026 13:27
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.
@moooyo moooyo changed the title PowerDisplay: Add tray icon mouse wheel brightness control PowerDisplay: Add tray icon mouse wheel brightness control and hover overlay Jul 28, 2026
Yu Leng (from Dev Box) 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.
Yu Leng (from Dev Box) 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.
@moooyo moooyo changed the title PowerDisplay: Add tray icon mouse wheel brightness control and hover overlay PowerDisplay: Adjust brightness by scrolling over the tray icon Jul 29, 2026
Yu Leng (from Dev Box) 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
moooyo marked this pull request as ready for review July 31, 2026 06:24
@moooyo

moooyo commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@moooyo
moooyo merged commit 8f63402 into main Jul 31, 2026
9 checks passed
@moooyo
moooyo deleted the yuleng/worktree/issue-49410-design branch July 31, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for scroll wheel control of brightness, when hovering over taskbar icon

3 participants