Skip to content

Inconsistent scroll delta handling on Windows with granular touchpad/trackpoint inputs #665

@dnszlr

Description

@dnszlr

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:

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 True

Alternatively, 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()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions