-
Notifications
You must be signed in to change notification settings - Fork 283
Windows: Controller.scroll() fires on_scroll twice (NotifierMixin._emit + WH_MOUSE_LL hook) #672
Description
Summary
On Windows, calling Controller.scroll() causes active Listener instances to receive two on_scroll callbacks per scroll call — one from NotifierMixin._emit() and one from the WH_MOUSE_LL hook. This does not happen for clicks (_press/_release don't call _emit).
Root cause
In pynput/mouse/_win32.py, Controller._scroll() does:
SendInput(MOUSEINPUT.WHEEL, ...)— injects the scroll event into the OSself._emit('on_scroll', px, py, dx, dy, True)— directly invokes all listeners viaNotifierMixin._listener_cache
On Windows, WH_MOUSE_LL hooks do receive SendInput events (the LLMHF_INJECTED flag confirms this). So step 2 is redundant — the listener already receives the event from step 1 via the hook.
By contrast, _press() and _release() only call SendInput() without _emit, and they work correctly (no duplication). The _emit on _scroll appears to be an inconsistency.
Minimal reproduction
import queue
import threading
import time
from pynput import mouse
events = queue.Queue()
def on_scroll(x, y, dx, dy):
events.put(("scroll", dx, dy, threading.get_ident()))
listener = mouse.Listener(on_scroll=on_scroll)
listener.start()
listener.wait()
time.sleep(0.2)
# Clear any startup noise
while not events.empty():
events.get_nowait()
mc = mouse.Controller()
mc.scroll(0, 2)
time.sleep(0.5)
results = []
while not events.empty():
results.append(events.get_nowait())
print(f"Scroll events received: {len(results)} (expected 1, got {len(results)})")
unique_tids = set(r[3] for r in results)
print(f"From {len(unique_tids)} different threads: {unique_tids}")
for i, r in enumerate(results):
print(f" [{i}] dx={r[1]} dy={r[2]} tid={r[3]}")
listener.stop()Output on Windows 11 (pynput 1.8.1, Python 3.14):
Scroll events received: 2 (expected 1, got 2)
From 2 different threads: {41872, 40488}
[0] dx=0 dy=2 tid=41872 # from WH_MOUSE_LL hook
[1] dx=0 dy=2 tid=40488 # from NotifierMixin._emit
Suggested fix
Remove the _emit('on_scroll', ...) call from Controller._scroll() in pynput/mouse/_win32.py (line 103), matching the pattern used by _press() and _release().
Alternatively, guard it with a check so it only fires on platforms where SendInput doesn't reach the hook.
Related
- Distinguish between real mouse inputs, and pynput-generated events #410 — describes the same symptom (Controller-generated scrolls appearing in Listener) but framed as a feature request