-
Notifications
You must be signed in to change notification settings - Fork 283
Inconsistent scroll delta handling on Windows with granular touchpad/trackpoint inputs #665
Description
Description
When using the mouse.Listener with modern input devices like touchpads or trackpoints, an inconsistent behavior occurs during scrolling.
These input devices produce granular scroll delta values (e.g., 4, -16) that are significantly smaller than the Windows standard scroll unit of 120 (WHEEL_DELTA)
The bug is limited to these granular deltas that do not complete a full scroll unit. All subsequent references to delta values refer exclusively to those smaller than 120.
Due to a bug in processing:
- Negative delta values (scrolling down or to the left) are incorrectly floored by pynput as -1.
- Positive delta values (scrolling up or to the right) are ignored and being floored as 0.
This leads to asymmetric scrolling behavior, causing user confusion and bug reports.
Users incorrectly interpret a delta value of -1 as an intentional scroll event ('something happens'), while the technically correct 0 value for an incomplete scroll unit is misinterpreted as a failure ('nothing happens').
Cause
The root cause of the inconsistent scrolling lies in the calculation within the _win32 mouse backend. Specifically, the following lines in the Listener's processing logic attempts to convert the raw mouseData into full WHEEL_DELTA units:
pynput/lib/pynput/mouse/_win32.py
Lines 223 to 226 in 74c5220
| elif msg in self.SCROLL_BUTTONS: | |
| mx, my = self.SCROLL_BUTTONS[msg] | |
| dd = wintypes.SHORT(data.mouseData >> 16).value // WHEEL_DELTA | |
| self.on_scroll(data.pt.x, data.pt.y, dd * mx, dd * my, injected) |
The bug is rooted in Python's integer division (//), which always performs an implicit floor operation (rounding towards negative infinity). This leads to an asymmetrical result when processing granular delta values.
| Input | Python Calculation (X // 120) | Result after flooring | Consequence |
|---|---|---|---|
| +4 | +4 // 120 ≈ +0.033 | 0 | No Event: Correctly ignored as a full unit was not reached. |
| -4 | -4 // 120 ≈ -0.033 | -1 | False Event: Incorrectly floored to -1, triggering a full, unintended scroll event. |
Potential solution
I propose this minimal-invasive solution to correct the asymmetric floor division bug.
It replaces Python's integer division with a calculation that enforces truncation by using float division with int().
elif msg in self.SCROLL_BUTTONS:
mx, my = self.SCROLL_BUTTONS[msg]
raw_delta = wintypes.SHORT(data.mouseData >> 16).value
dd = int(raw_delta / WHEEL_DELTA)
self.on_scroll(data.pt.x, data.pt.y, dd * mx, dd * my, injected)If this solution satisfies the needs of pynput I would implement a pull request.
Workaround
As an alternative solution, this workaround can be implemented in the win32_event_filter to bypass the flooring bug by intentionally modifying the raw delta value before it reaches the pynput Listener logic.
WHEEL_DELTA = 120
def win32_event_filter(msg, data):
if msg in (0x020A, 0x020E):
mouseData = getattr(data, 'mouseData', 0)
delta = (mouseData >> 16) & 0xFFFF
if delta >= 0x8000:
delta -= 0x10000
if 0 < abs(delta) < WHEEL_DELTA:
delta = 0
new_mouseData = (mouseData & 0xFFFF) | ((delta & 0xFFFF) << 16)
setattr(data, 'mouseData', new_mouseData)
return TrueAlternatively, you can modify the line delta=0 to force a full scroll event for any granular input. By setting delta=int(math.copysign(WHEEL_DELTA,delta)), the granular delta is scaled up to ±120 before it reaches pynput. This ensures pynput registers a full ±1 scroll, though it consumes the granular input immediately, potentially leading to a less smooth scrolling experience.
Platform and pynput version
| Platform | Version |
|---|---|
| OS | Microsoft Windows 11 Home |
| pynput | 1.8.1 |
To Reproduce
from pynput import mouse
def on_scroll(x, y, dx, dy):
print(f"[PYNPUT] Scroll delta: ({dx}, {dy})")
def win32_event_filter(msg, data):
if msg in (0x020A, 0x020E):
delta = (data.mouseData >> 16) & 0xFFFF
if delta & 0x8000:
delta -= 0x10000
dx, dy = (delta, 0) if msg == 0x020E else (0, delta)
print(f"[WIN32] Scroll delta: ({dx}, {dy})")
return True
if __name__ == "__main__":
with mouse.Listener(
on_scroll=on_scroll,
win32_event_filter=win32_event_filter
) as listener:
listener.join()