Skip to content

feat: translate Shift+wheel into horizontal scroll on macOS/Windows/Linux#191

Open
FunJim wants to merge 3 commits into
TomBadash:masterfrom
FunJim:feat/macos-shift-wheel-hscroll
Open

feat: translate Shift+wheel into horizontal scroll on macOS/Windows/Linux#191
FunJim wants to merge 3 commits into
TomBadash:masterfrom
FunJim:feat/macos-shift-wheel-hscroll

Conversation

@FunJim
Copy link
Copy Markdown

@FunJim FunJim commented May 30, 2026

Summary

Hold Shift while turning the mouse wheel to scroll horizontally in every app — on macOS, Windows, and Linux. Each platform implements the translation in its own mouse hook so the behavior is consistent across the trio.

Why

The OS doesn't translate Shift+wheel to horizontal scroll system-wide — only some apps (mostly browsers) do it themselves. With Mouser active, the user-expected "Shift+wheel = horizontal scroll" behavior is now consistent across every app.

Behavior

  • Only triggers on a real vertical wheel event with Shift held — a hardware horizontal tilt-wheel still goes through the existing HSCROLL_LEFT/RIGHT dispatch path untouched.
  • Trackpad / continuous scroll events are not affected.
  • Direction is controlled by the existing Invert horizontal scroll setting (invert_hscroll), so no new UI is required.

Per-platform implementation

macOS (core/mouse_hook_macos.py)

  • New _SHIFT_WHEEL_HSCROLL_MARKER so the synthetic horizontal scroll we post passes through our own CGEventTap on re-entry without being reprocessed.
  • New _post_shift_hscroll_event helper copies axis-1 (vertical) deltas onto axis-2 (horizontal), zeros axis-1, copies scroll/momentum phases, and strips the Shift modifier from the new event's flags so apps that already do their own Shift+scroll translation (browsers, NSScrollView) don't double-translate.

Windows (core/mouse_hook_windows.py)

  • New WM_APP_INJECT_SHIFT_HSCROLL deferred-injection message, mirroring the existing WM_APP_INJECT_HSCROLL invert path.
  • GetAsyncKeyState(VK_SHIFT) is queried in the LL hook for every WM_MOUSEWHEEL; if Shift is held the original is blocked and a MOUSEEVENTF_HWHEEL is queued for the raw-input window thread to inject via SendInput.

Linux (core/mouse_hook_linux.py)

  • Opens keyboard evdev devices (read-only, no grab) on first use to query active_keys() for KEY_LEFTSHIFT / KEY_RIGHTSHIFT.
  • In _handle_rel, when Shift is held during REL_WHEEL, writes a translated REL_HWHEEL on the virtual mouse and swallows the original vertical scroll; matching REL_WHEEL_HI_RES events are also suppressed so vertical scroll doesn't double-fire.
  • The virtual uinput device always exposes REL_HWHEEL so the translation works even on basic mice without a tilt wheel.

Test plan

  • python -m py_compile main_qml.py core/*.py ui/*.py — passes
  • python -m unittest discover -s tests -p "test_*.py" — 510 passed, 1 skipped (existing skip)
  • New MacOSShiftWheelHScrollTests (7 cases) — translation, Shift stripping, marker passthrough, no-Shift / tilt-wheel no-ops, invert_hscroll both directions.
  • New LinuxShiftWheelHScrollTests (7 cases) — keyboard discovery, _shift_held state, REL_WHEEL→REL_HWHEEL translation, passthrough without Shift, invert_hscroll direction, hi-res suppression, virtual-uinput REL_HWHEEL injection.
  • Manual macOS verification by the author: Shift+wheel scrolls horizontally system-wide; toggling Invert horizontal scroll flips the direction.
  • Windows / Linux manual verification — the Windows hook lacks a unit-test harness in this repo (existing pattern), so the change is best confirmed on hardware before merging.

Notes

  • No config schema change.
  • Keyboard discovery on Linux is one-shot and best-effort; if a keyboard is hot-plugged after start, it won't participate in Shift detection until the hook restarts. Acceptable for a first pass.

FunJim added 3 commits May 31, 2026 01:55
Hold Shift while turning the wheel to scroll horizontally in every app.
The CGEventTap rewrites a vertical scroll into a horizontal one with the
Shift modifier stripped so apps that already do their own translation
don't double it. Honors the existing `invert_hscroll` setting to flip
the direction.
Use GetAsyncKeyState to detect Shift on every WM_MOUSEWHEEL event, queue
a MOUSEEVENTF_HWHEEL injection through the existing raw-input wndproc,
and block the original vertical wheel event. The translated direction
honors `invert_hscroll`, matching the macOS path.
Open evdev keyboards read-only at first use to query active_keys(), and
when Shift is held during REL_WHEEL forward a REL_HWHEEL on the virtual
mouse and suppress the vertical scroll (REL_WHEEL_HI_RES is also
swallowed so the original vertical scroll doesn't leak through). The
direction follows `invert_hscroll` to match the macOS path.

REL_HWHEEL is added to the uinput device's capabilities so the
translation works even on basic mice without a tilt wheel.
@FunJim FunJim changed the title feat(macos): translate Shift+wheel into horizontal scroll feat: translate Shift+wheel into horizontal scroll on macOS/Windows/Linux May 30, 2026
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