Skip to content

fix(macos): correct inverted scroll direction (wl_pointer/libei sign) - #470

Open
rengers wants to merge 2 commits into
feschber:mainfrom
rengers:fix/macos-scroll-direction
Open

fix(macos): correct inverted scroll direction (wl_pointer/libei sign)#470
rengers wants to merge 2 commits into
feschber:mainfrom
rengers:fix/macos-scroll-direction

Conversation

@rengers

@rengers rengers commented Jul 18, 2026

Copy link
Copy Markdown

Problem

Scrolling from a non-macOS sender (e.g. Linux/Hyprland via the layer-shell or
libei capture backend, or Windows) onto a macOS receiver running the macos
emulation backend is inverted — vertically and horizontally, on both
mouse-wheel (discrete) and trackpad (continuous) input.

Symmetrically, a macOS sender scrolls backwards on non-macOS receivers,
because the two sides of the wire disagree on the scroll-sign convention.

Root cause

wl_pointer.axis / libei use positive = scroll down / right. That is the
convention the wire protocol and the Linux and Windows capture backends already
use (input-capture/src/windows/event_thread.rs even negates WM_MOUSEWHEEL
to match). CGEvent scroll deltas use the opposite sign, and two places
never reconcile it:

  1. Emulation (input-emulation/src/macos.rs) posts the wire delta straight
    into CGEvent, so a wl-convention sender scrolls backwards on the Mac.
  2. Capture (input-capture/src/macos.rs) forwards the raw CGEvent delta
    un-negated, so the wire sign is inconsistent across capture backends and a
    Mac sender scrolls backwards on everyone else.

Fix

  • Capture (commit 1): negate the macOS scroll delta so the wire stays
    source-agnostic (both PIXEL and v120 paths, both axes).
  • Emulation (commit 2): convert the wire sign back to the CGEvent sign
    with a single fixed negation (wire_scroll_to_cgevent), on both paths and
    both axes.

Together these make the wire consistent for every capture backend and fix
scrolling in every direction (non-mac → mac, mac → non-mac, and mac → mac
without double-inversion).

Why not read com.apple.swipescrolldirection?

An alternative (#460) flips the sign based on the receiver's natural scrolling
preference. That turns out to be unnecessary and only correct at one toggle
position: scroll events posted at the HID event tap bypass the window
server's natural-scroll transform
, so the receiver's preference has no effect
on injected scrolling. I confirmed this on hardware — a receiving Mac scrolls
identically with natural scrolling ON and OFF. Because the wire is
source-agnostic, one fixed conversion is correct for every sender regardless of
the receiver's setting, and no CoreFoundation preference read / FFI is needed.

Testing

  • cargo check and cargo clippy --all-targets -- -D warnings are clean for
    input-capture and input-emulation; the headless daemon
    (cargo check --no-default-features) builds.
  • Confirmed on hardware that a wl-convention sender (Linux/Hyprland) scrolls
    inverted on main, and that toggling the receiver's natural-scrolling
    setting makes no difference — which is what motivates the fixed conversion.

Manual matrix worth confirming before merge (non-mac sender → mac receiver):

path axis before after
trackpad (PIXEL) vertical inverted correct
trackpad (PIXEL) horizontal inverted correct
wheel (v120/LINE) vertical inverted correct
wheel (v120/LINE) horizontal inverted correct

With two Macs, mac → mac should no longer double-invert (the limitation
documented in #460).

Prior art / coordination

This consolidates scroll-direction work that's been split across several PRs:

Happy to adjust the approach (keep it emulation-only, add a config override,
etc.) if you'd prefer.

Ross Engers added 2 commits July 18, 2026 00:04
The macOS capture backend forwarded the raw CGEvent scroll delta, whose
sign is inverted relative to wl_pointer.axis / libei (where positive means
scroll down / right) -- the convention the wire protocol and the Linux and
Windows capture backends already use. A macOS sender therefore scrolled
backwards on non-macOS receivers, and the wire sign was inconsistent across
capture backends.

Negate the delta at capture time on both the continuous (PIXEL) and discrete
(v120) paths and both axes, so scroll direction stays source-agnostic on the
wire. This is the capture-side counterpart to the macOS emulation conversion
in the following commit; together they also resolve the both-macOS
double-inversion.

Same approach as the closed PR feschber#415 / commit bb17cf0 by @jondkinney.
The macOS emulation backend replayed the wire scroll delta straight into
CGEvent, but CGEvent's scroll sign is inverted relative to the wire's
wl_pointer/libei convention (where positive means scroll down / right).
Scroll from a non-macOS sender (e.g. a Linux/Hyprland layer-shell or libei
capture) was therefore reversed on the receiving Mac, on both the continuous
(PIXEL) and discrete (v120/LINE) paths and both axes.

Convert the wire sign to the CGEvent sign with a single fixed negation
(wire_scroll_to_cgevent). The receiver's own com.apple.swipescrolldirection
("natural scrolling") preference is deliberately NOT consulted: events posted
at the HID event tap bypass the window server's natural-scroll transform, so
toggling that preference has no effect on emulated scrolling (verified: a
receiver scrolls identically with natural scrolling ON and OFF). The wire
protocol is source-agnostic, so a single fixed sign is correct for every
sender.

This differs from the receiver-preference approach in the open PR feschber#460 and the
config toggle in PR feschber#347: since injected scroll bypasses the preference, a
fixed conversion is both simpler and correct regardless of the receiver's
setting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant