From 3bb0d9349affe87f1510105ec7fd5c058fdf3b76 Mon Sep 17 00:00:00 2001 From: lucamene04 Date: Tue, 24 Mar 2026 00:29:50 +0100 Subject: [PATCH] fix(mouse): handle macOS gesture button in event tap --- core/mouse_hook.py | 7 +++++ tests/test_mouse_hook.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/core/mouse_hook.py b/core/mouse_hook.py index 06d7ca3..24cbb4f 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -915,6 +915,7 @@ def stop(self): _BTN_MIDDLE = 2 _BTN_BACK = 3 _BTN_FORWARD = 4 + _BTN_GESTURE = 6 _SCROLL_INVERT_MARKER = 0x4D4F5553 class MouseHook: @@ -1333,6 +1334,9 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): elif btn == _BTN_FORWARD: mouse_event = MouseEvent(MouseEvent.XBUTTON2_DOWN) should_block = MouseEvent.XBUTTON2_DOWN in self._blocked_events + elif btn == _BTN_GESTURE: + self._on_hid_gesture_down() + should_block = True elif event_type == Quartz.kCGEventOtherMouseUp: btn = Quartz.CGEventGetIntegerValueField( @@ -1351,6 +1355,9 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): elif btn == _BTN_FORWARD: mouse_event = MouseEvent(MouseEvent.XBUTTON2_UP) should_block = MouseEvent.XBUTTON2_UP in self._blocked_events + elif btn == _BTN_GESTURE: + self._on_hid_gesture_up() + should_block = True elif event_type == Quartz.kCGEventScrollWheel: if ( diff --git a/tests/test_mouse_hook.py b/tests/test_mouse_hook.py index e4d400b..cc7b949 100644 --- a/tests/test_mouse_hook.py +++ b/tests/test_mouse_hook.py @@ -40,5 +40,69 @@ def test_hid_reconnect_does_not_rescan_when_evdev_already_grabs_logitech(self): self.assertFalse(hook._rescan_requested.is_set()) +class _FakeQuartz: + kCGEventMouseMoved = 1 + kCGEventOtherMouseDragged = 2 + kCGEventOtherMouseDown = 3 + kCGEventOtherMouseUp = 4 + kCGEventScrollWheel = 5 + kCGMouseEventButtonNumber = 10 + kCGMouseEventDeltaX = 11 + kCGMouseEventDeltaY = 12 + kCGEventSourceUserData = 13 + kCGScrollWheelEventFixedPtDeltaAxis1 = 14 + kCGScrollWheelEventFixedPtDeltaAxis2 = 15 + + @staticmethod + def CGEventGetIntegerValueField(event, field): + return event.get(field, 0) + + +class MacMouseHookGestureButtonTests(unittest.TestCase): + def _reload_for_darwin(self): + fake_quartz = _FakeQuartz() + with ( + patch.object(sys, "platform", "darwin"), + patch.dict(sys.modules, {"Quartz": fake_quartz}), + ): + importlib.reload(mouse_hook) + self.addCleanup(importlib.reload, mouse_hook) + return mouse_hook, fake_quartz + + def test_gesture_button_is_blocked_and_dispatches_click(self): + module, quartz = self._reload_for_darwin() + hook = module.MouseHook() + dispatched = [] + hook.register( + module.MouseEvent.GESTURE_CLICK, + lambda event: dispatched.append(event.event_type), + ) + + down_event = {quartz.kCGMouseEventButtonNumber: module._BTN_GESTURE} + up_event = {quartz.kCGMouseEventButtonNumber: module._BTN_GESTURE} + + self.assertIsNone( + hook._event_tap_callback(None, quartz.kCGEventOtherMouseDown, down_event, None) + ) + self.assertTrue(hook._gesture_active) + + self.assertIsNone( + hook._event_tap_callback(None, quartz.kCGEventOtherMouseUp, up_event, None) + ) + self.assertFalse(hook._gesture_active) + self.assertEqual(dispatched, [module.MouseEvent.GESTURE_CLICK]) + + def test_non_gesture_button_still_passes_through(self): + module, quartz = self._reload_for_darwin() + hook = module.MouseHook() + event = {quartz.kCGMouseEventButtonNumber: 99} + + self.assertIs( + hook._event_tap_callback(None, quartz.kCGEventOtherMouseDown, event, None), + event, + ) + self.assertFalse(hook._gesture_active) + + if __name__ == "__main__": unittest.main()