Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 114 additions & 11 deletions core/mouse_hook_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def __init__(self):
self._evdev_grabbed = False
self._evdev_remap_ready = False
self._evdev_remap_status_state = (False, None)
self._keyboard_devices = []
self._keyboard_devices_initialised = False

@property
def evdev_ready(self):
Expand Down Expand Up @@ -695,25 +697,117 @@ def _filtered_uinput_events(self, dev):
caps.pop(filtered_type, None)
rel_type = _ecodes.EV_REL
rel_caps = list(caps.get(rel_type, []))
if not rel_caps:
return caps
hi_res_codes = {
getattr(_ecodes, "REL_WHEEL_HI_RES", None),
getattr(_ecodes, "REL_HWHEEL_HI_RES", None),
}
filtered_rel_caps = [code for code in rel_caps if code not in hi_res_codes]
if filtered_rel_caps == rel_caps:
return caps
if filtered_rel_caps:
# Ensure REL_HWHEEL is available so Shift+wheel translation can emit
# horizontal scroll even on mice without a tilt wheel. Only add when
# the source already exposes REL events (i.e. is a mouse-like device).
if rel_caps and _ecodes.REL_HWHEEL not in filtered_rel_caps:
filtered_rel_caps.append(_ecodes.REL_HWHEEL)
if filtered_rel_caps != rel_caps:
caps[rel_type] = filtered_rel_caps
else:
caps.pop(rel_type, None)
print(
"[MouseHook] Filtering REL_WHEEL_HI_RES / "
"REL_HWHEEL_HI_RES from Mouser Virtual Mouse"
)
if rel_caps and any(c in hi_res_codes for c in rel_caps):
print(
"[MouseHook] Filtering REL_WHEEL_HI_RES / "
"REL_HWHEEL_HI_RES from Mouser Virtual Mouse"
)
return caps

def _ensure_keyboard_devices(self):
"""Open keyboard evdev devices (read-only) for Shift detection."""
if self._keyboard_devices_initialised or not _EVDEV_OK:
return
self._keyboard_devices_initialised = True
try:
paths = list(_evdev_mod.list_devices())
except Exception as exc:
_log_once(
("evdev-keyboard-list-error", type(exc).__name__, str(exc)),
f"[MouseHook] Cannot list evdev devices for Shift detection: {exc}",
)
return
shift_codes = {
_ecodes.KEY_LEFTSHIFT,
_ecodes.KEY_RIGHTSHIFT,
}
keyboards = []
for path in paths:
try:
dev = _InputDevice(path)
except PermissionError:
continue
except Exception:
continue
try:
caps = dev.capabilities(absinfo=False)
key_caps = set(caps.get(_ecodes.EV_KEY, []))
if key_caps & shift_codes:
keyboards.append(dev)
continue
except Exception:
pass
try:
dev.close()
except Exception:
pass
self._keyboard_devices = keyboards
if keyboards:
print(
f"[MouseHook] Tracking {len(keyboards)} keyboard device(s) "
"for Shift+wheel detection"
)

def _close_keyboard_devices(self):
for dev in self._keyboard_devices:
try:
dev.close()
except Exception:
pass
self._keyboard_devices = []
self._keyboard_devices_initialised = False

def _shift_held(self):
"""Return True if any Shift key is currently held."""
if not self._keyboard_devices_initialised:
self._ensure_keyboard_devices()
if not self._keyboard_devices:
return False
shift_codes = (
_ecodes.KEY_LEFTSHIFT,
_ecodes.KEY_RIGHTSHIFT,
)
stale = []
for dev in self._keyboard_devices:
try:
active = dev.active_keys()
except OSError:
stale.append(dev)
continue
except Exception:
continue
for code in shift_codes:
if code in active:
if stale:
self._drop_stale_keyboards(stale)
return True
if stale:
self._drop_stale_keyboards(stale)
return False

def _drop_stale_keyboards(self, stale):
for dev in stale:
try:
dev.close()
except Exception:
pass
try:
self._keyboard_devices.remove(dev)
except ValueError:
pass

def _cleanup_evdev(self):
self._disable_evdev_remapping()
if self._evdev_device:
Expand Down Expand Up @@ -849,6 +943,14 @@ def _handle_rel(self, event):

rel_wheel_hi_res = getattr(_ecodes, "REL_WHEEL_HI_RES", 0x0B)
if code == _ecodes.REL_WHEEL or code == rel_wheel_hi_res:
if value != 0 and self._shift_held():
if code == _ecodes.REL_WHEEL:
h_value = -value if self.invert_hscroll else value
self._uinput.write(_ecodes.EV_REL, _ecodes.REL_HWHEEL, h_value)
# REL_WHEEL_HI_RES events are suppressed while Shift is held so
# the vertical scroll doesn't double-fire alongside the
# translated horizontal scroll.
return
if self.invert_vscroll:
self._uinput.write(_ecodes.EV_REL, code, -value)
else:
Expand Down Expand Up @@ -926,6 +1028,7 @@ def stop(self):
self._evdev_thread.join(timeout=2)
self._evdev_thread = None
self._cleanup_evdev()
self._close_keyboard_devices()


MouseHook._platform_module = sys.modules[__name__]
Expand Down
93 changes: 88 additions & 5 deletions core/mouse_hook_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def wrapper(*args, **kwargs):
_BTN_FORWARD = 4
_SCROLL_INVERT_MARKER = 0x4D4F5553
_INJECTED_EVENT_MARKER = 0x4D4F5554
_SHIFT_WHEEL_HSCROLL_MARKER = 0x4D4F5556
_kCGEventTapDisabledByTimeout = 0xFFFFFFFE
_kCGEventTapDisabledByUserInput = 0xFFFFFFFF

Expand Down Expand Up @@ -80,6 +81,77 @@ def _negate_scroll_axis(self, cg_event, axis):
if value:
Quartz.CGEventSetIntegerValueField(cg_event, field, -value)

def _post_shift_hscroll_event(self, cg_event):
"""Translate Shift+vertical-wheel into a horizontal scroll event.

The translated event has axis-1 zeroed and axis-1 deltas copied onto
axis-2. The Shift modifier is stripped so that apps which already
translate Shift+scroll themselves do not double-translate. The
`invert_hscroll` setting flips the direction.
"""
v_line = Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGScrollWheelEventDeltaAxis1
)
v_fixed = Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGScrollWheelEventFixedPtDeltaAxis1
)
v_point = Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis1
)

if self.invert_hscroll:
v_line = -v_line
v_fixed = -v_fixed
v_point = -v_point

is_continuous = Quartz.CGEventGetIntegerValueField(cg_event, 88)
if is_continuous:
unit = Quartz.kCGScrollEventUnitPixel
primary_delta = v_point
else:
unit = Quartz.kCGScrollEventUnitLine
primary_delta = v_line

new_event = Quartz.CGEventCreateScrollWheelEvent(
None, unit, 2, 0, primary_delta
)
if not new_event:
return False

flags = Quartz.CGEventGetFlags(cg_event)
Quartz.CGEventSetFlags(new_event, flags & ~Quartz.kCGEventFlagMaskShift)
Quartz.CGEventSetIntegerValueField(
new_event,
Quartz.kCGEventSourceUserData,
_SHIFT_WHEEL_HSCROLL_MARKER,
)

for field_name, value in (
("kCGScrollWheelEventDeltaAxis2", v_line),
("kCGScrollWheelEventFixedPtDeltaAxis2", v_fixed),
("kCGScrollWheelEventPointDeltaAxis2", v_point),
("kCGScrollWheelEventDeltaAxis1", 0),
("kCGScrollWheelEventFixedPtDeltaAxis1", 0),
("kCGScrollWheelEventPointDeltaAxis1", 0),
):
field = getattr(Quartz, field_name, None)
if field is None:
continue
Quartz.CGEventSetIntegerValueField(new_event, field, value)

for field_name in (
"kCGScrollWheelEventScrollPhase",
"kCGScrollWheelEventMomentumPhase",
):
field = getattr(Quartz, field_name, None)
if field is None:
continue
value = Quartz.CGEventGetIntegerValueField(cg_event, field)
Quartz.CGEventSetIntegerValueField(new_event, field, value)

Quartz.CGEventPost(Quartz.kCGHIDEventTap, new_event)
return True

def _post_inverted_scroll_event(self, cg_event):
v_point = Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGScrollWheelEventPointDeltaAxis1
Expand Down Expand Up @@ -365,11 +437,12 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
should_block = MouseEvent.XBUTTON2_UP in self._blocked_events

elif event_type == Quartz.kCGEventScrollWheel:
if (
Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGEventSourceUserData
)
== _SCROLL_INVERT_MARKER
source_marker = Quartz.CGEventGetIntegerValueField(
cg_event, Quartz.kCGEventSourceUserData
)
if source_marker in (
_SCROLL_INVERT_MARKER,
_SHIFT_WHEEL_HSCROLL_MARKER,
):
return cg_event
if self.ignore_trackpad:
Expand All @@ -392,6 +465,15 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
self._debug_callback(f"ScrollWheel v={v_delta} h={h_delta}")
except Exception:
pass
if h_delta == 0:
flags = Quartz.CGEventGetFlags(cg_event)
if flags & Quartz.kCGEventFlagMaskShift:
v_fixed = Quartz.CGEventGetIntegerValueField(
cg_event,
Quartz.kCGScrollWheelEventFixedPtDeltaAxis1,
)
if v_fixed != 0 and self._post_shift_hscroll_event(cg_event):
return None
if h_delta != 0:
if h_delta > 0:
mouse_event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(h_delta))
Expand Down Expand Up @@ -637,6 +719,7 @@ def stop(self):
"_BTN_FORWARD",
"_SCROLL_INVERT_MARKER",
"_INJECTED_EVENT_MARKER",
"_SHIFT_WHEEL_HSCROLL_MARKER",
"_kCGEventTapDisabledByTimeout",
"_kCGEventTapDisabledByUserInput",
]
36 changes: 35 additions & 1 deletion core/mouse_hook_windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ class MSLLHOOKSTRUCT(Structure):
GetMessageW = windll.user32.GetMessageW
PostThreadMessageW = windll.user32.PostThreadMessageW

GetAsyncKeyState = windll.user32.GetAsyncKeyState
GetAsyncKeyState.argtypes = [c_int]
GetAsyncKeyState.restype = ctypes.c_short

VK_SHIFT = 0x10

WM_QUIT = 0x0012
INJECTED_FLAG = 0x00000001

Expand Down Expand Up @@ -208,6 +214,7 @@ def hiword(dword):
WM_APP = 0x8000
WM_APP_INJECT_VSCROLL = WM_APP + 1
WM_APP_INJECT_HSCROLL = WM_APP + 2
WM_APP_INJECT_SHIFT_HSCROLL = WM_APP + 3

WM_DEVICECHANGE = 0x0219
DBT_DEVNODES_CHANGED = 0x0007
Expand All @@ -232,8 +239,10 @@ def __init__(self):
self._hook_proc = None
self._pending_vscroll = 0
self._pending_hscroll = 0
self._pending_shift_hscroll = 0
self._vscroll_posted = False
self._hscroll_posted = False
self._shift_hscroll_posted = False
self._ri_wndproc_ref = None
self._ri_hwnd = None
self._device_name_cache = {}
Expand Down Expand Up @@ -427,8 +436,25 @@ def _low_level_handler_inner(self, nCode, wParam, lParam):
should_block = MouseEvent.MIDDLE_UP in self._blocked_events

elif wParam == WM_MOUSEWHEEL:
delta = hiword(mouse_data)
if delta != 0 and (GetAsyncKeyState(VK_SHIFT) & 0x8000):
if self._ri_hwnd:
h_delta = -delta if self.invert_hscroll else delta
self._pending_shift_hscroll += h_delta
if self._shift_hscroll_posted:
return 1
if PostMessageW(
self._ri_hwnd, WM_APP_INJECT_SHIFT_HSCROLL, 0, 0
):
self._shift_hscroll_posted = True
return 1
self._pending_shift_hscroll -= h_delta
else:
self._emit_debug(
"Shift+wheel translation skipped: "
"raw input window unavailable"
)
if self.invert_vscroll:
delta = hiword(mouse_data)
if delta != 0 and self._ri_hwnd:
self._pending_vscroll += -delta
if self._vscroll_posted:
Expand Down Expand Up @@ -516,6 +542,14 @@ def _ri_wndproc(self, hwnd, msg, wParam, lParam):
_inject_scroll_impl(MOUSEEVENTF_HWHEEL, delta)
return 0

if msg == WM_APP_INJECT_SHIFT_HSCROLL:
delta = self._pending_shift_hscroll
self._pending_shift_hscroll = 0
self._shift_hscroll_posted = False
if delta != 0:
_inject_scroll_impl(MOUSEEVENTF_HWHEEL, delta)
return 0

if msg == WM_DEVICECHANGE:
if wParam == DBT_DEVNODES_CHANGED:
self._on_device_change()
Expand Down
Loading