diff --git a/core/config.py b/core/config.py index 7a6805e..f6194c8 100644 --- a/core/config.py +++ b/core/config.py @@ -65,7 +65,7 @@ } DEFAULT_CONFIG = { - "version": 8, + "version": 9, "active_profile": "default", "profiles": { "default": { @@ -104,6 +104,7 @@ "debug_mode": False, "device_layout_overrides": {}, "language": "en", + "ignore_trackpad": True, }, } @@ -319,6 +320,11 @@ def _migrate(cfg): mappings["mode_shift"] = "switch_scroll_mode" cfg["version"] = 8 + if version < 9: + settings = cfg.setdefault("settings", {}) + settings.setdefault("ignore_trackpad", True) + cfg["version"] = 9 + cfg.setdefault("settings", {}) cfg["settings"].setdefault("appearance_mode", "system") cfg["settings"].setdefault("debug_mode", False) diff --git a/core/engine.py b/core/engine.py index aedc77c..7b86997 100644 --- a/core/engine.py +++ b/core/engine.py @@ -72,6 +72,8 @@ def _setup_hooks(self): settings = self.cfg.get("settings", {}) self.hook.invert_vscroll = settings.get("invert_vscroll", False) self.hook.invert_hscroll = settings.get("invert_hscroll", False) + if hasattr(self.hook, "ignore_trackpad"): + self.hook.ignore_trackpad = settings.get("ignore_trackpad", True) self.hook.debug_mode = self._debug_events_enabled self.hook.configure_gestures( enabled=any(mappings.get(key, "none") != "none" diff --git a/core/mouse_hook.py b/core/mouse_hook.py index 82efa9e..0c3b507 100644 --- a/core/mouse_hook.py +++ b/core/mouse_hook.py @@ -934,6 +934,10 @@ def stop(self): _BTN_BACK = 3 _BTN_FORWARD = 4 _SCROLL_INVERT_MARKER = 0x4D4F5553 + # kCGScrollWheelEventIsContinuous (field 88) distinguishes input source: + # 0 = line-based / discrete (physical mouse scroll wheel) + # 1 = pixel-based / continuous (trackpad / Magic Mouse) + _SCROLL_IS_CONTINUOUS = Quartz.kCGScrollWheelEventIsContinuous class MouseHook: """ @@ -953,6 +957,7 @@ def __init__(self): self.debug_mode = False self.invert_vscroll = False self.invert_hscroll = False + self.ignore_trackpad = True self._gesture_active = False self._hid_gesture = None self._wake_observer = None @@ -1298,6 +1303,31 @@ def _dispatch_worker(self): except queue.Empty: continue + def _is_trackpad_event(self, cg_event, event_type): + """Return True if the event originated from a trackpad rather than a mouse. + + For scroll events we inspect kCGScrollWheelEventScrollType + (raw field 1): + 0 = line-based / discrete (physical scroll wheel) + 1 = pixel-based / continuous (trackpad / Magic Mouse) + For non-scroll events (mouse moved, dragged) we check the + mouse sub-type field (field 109). Trackpad touch events set + this to NX_SUBTYPE_MOUSE_TOUCH (non-zero). + """ + if event_type == Quartz.kCGEventScrollWheel: + scroll_type = Quartz.CGEventGetIntegerValueField( + cg_event, _SCROLL_IS_CONTINUOUS + ) + return scroll_type == 1 + # For move / drag events during gesture tracking, check the + # mouse sub-type field (field 109). Trackpad touch events set + # this to NX_SUBTYPE_MOUSE_TOUCH (non-zero). + try: + subtype = Quartz.CGEventGetIntegerValueField(cg_event, 109) + return subtype != 0 + except Exception: + return False + def _event_tap_callback(self, proxy, event_type, cg_event, refcon): """CGEventTap callback. Return the event to pass through, or None to suppress.""" try: @@ -1305,6 +1335,26 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon): self._first_event_logged = True print("[MouseHook] CGEventTap: first event received", flush=True) + # ── Trackpad filter ────────────────────────────────── + # When enabled, pass trackpad / Magic Mouse events + # through unmodified so Mouser only acts on real mouse + # input. Skip our own synthetic scroll-inversion events + # (they use pixel units and would look like trackpad). + if self.ignore_trackpad: + if event_type == Quartz.kCGEventScrollWheel: + is_own_event = ( + Quartz.CGEventGetIntegerValueField( + cg_event, Quartz.kCGEventSourceUserData + ) + == _SCROLL_INVERT_MARKER + ) + if not is_own_event and self._is_trackpad_event( + cg_event, event_type + ): + return cg_event + elif self._is_trackpad_event(cg_event, event_type): + return cg_event + mouse_event = None should_block = False diff --git a/tests/test_config.py b/tests/test_config.py index 15cef13..49bbdb7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -31,7 +31,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): migrated = config._migrate(legacy) - self.assertEqual(migrated["version"], 8) + self.assertEqual(migrated["version"], 9) self.assertEqual(migrated["profiles"]["default"]["apps"], []) self.assertFalse(migrated["settings"]["invert_hscroll"]) self.assertFalse(migrated["settings"]["invert_vscroll"]) @@ -45,6 +45,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): self.assertEqual(migrated["settings"]["device_layout_overrides"], {}) self.assertFalse(migrated["settings"]["start_at_login"]) self.assertNotIn("start_with_windows", migrated["settings"]) + self.assertTrue(migrated["settings"]["ignore_trackpad"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["gesture"], "none" ) @@ -73,7 +74,7 @@ def test_migrate_updates_media_player_profile_apps(self): migrated = config._migrate(cfg) - self.assertEqual(migrated["version"], 8) + self.assertEqual(migrated["version"], 9) self.assertEqual( migrated["profiles"]["media"]["apps"], ["Microsoft.Media.Player.exe", "VLC.exe"], @@ -112,7 +113,7 @@ def test_load_config_merges_missing_defaults_from_disk(self): ): loaded = config.load_config() - self.assertEqual(loaded["version"], 8) + self.assertEqual(loaded["version"], 9) self.assertEqual(loaded["settings"]["dpi"], 800) self.assertFalse(loaded["settings"]["start_at_login"]) self.assertEqual(loaded["settings"]["gesture_threshold"], 50) @@ -136,7 +137,7 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self): migrated = config._migrate(legacy) - self.assertEqual(migrated["version"], 8) + self.assertEqual(migrated["version"], 9) self.assertTrue(migrated["settings"]["start_at_login"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["mode_shift"], diff --git a/ui/backend.py b/ui/backend.py index 0edd013..8a33a9b 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -152,6 +152,7 @@ def buttons(self): def actionCategories(self): """Actions grouped by category — for the action picker chips.""" from collections import OrderedDict + cats = OrderedDict() for aid in sorted( ACTIONS, @@ -235,6 +236,14 @@ def invertVScroll(self): def invertHScroll(self): return self._cfg.get("settings", {}).get("invert_hscroll", False) + @Property(bool, notify=settingsChanged) + def ignoreTrackpad(self): + return self._cfg.get("settings", {}).get("ignore_trackpad", True) + + @Property(bool, constant=True) + def isMacOS(self): + return sys.platform == "darwin" + @Property(int, notify=settingsChanged) def gestureThreshold(self): return int(self._cfg.get("settings", {}).get("gesture_threshold", 50)) @@ -552,6 +561,14 @@ def setInvertHScroll(self, value): self._engine.reload_mappings() self.settingsChanged.emit() + @Slot(bool) + def setIgnoreTrackpad(self, value): + self._cfg.setdefault("settings", {})["ignore_trackpad"] = value + save_config(self._cfg) + if self._engine: + self._engine.reload_mappings() + self.settingsChanged.emit() + @Slot(int) def setGestureThreshold(self, value): snapped = max(20, min(400, int(round(value / 5.0) * 5))) diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index 211733e..9246fea 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -866,6 +866,53 @@ Item { } } } + + Rectangle { + width: parent.width + height: 52 + radius: 10 + color: scrollPage.theme.bgSubtle + visible: backend.isMacOS + + RowLayout { + anchors { + fill: parent + leftMargin: 16 + rightMargin: 16 + } + + Column { + Layout.fillWidth: true + spacing: 2 + + Text { + text: "Ignore trackpad" + font { + family: uiState.fontFamily + pixelSize: 13 + } + color: scrollPage.theme.textPrimary + } + + Text { + text: "Only respond to mouse events, not trackpad or Magic Mouse" + font { + family: uiState.fontFamily + pixelSize: 11 + } + color: scrollPage.theme.textSecondary + } + } + + Switch { + id: ignoreTrackpadSwitch + checked: backend.ignoreTrackpad + Material.accent: scrollPage.theme.accent + Accessible.name: "Ignore trackpad" + onToggled: backend.setIgnoreTrackpad(checked) + } + } + } } } @@ -933,6 +980,7 @@ Item { } vscrollSwitch.checked = backend.invertVScroll hscrollSwitch.checked = backend.invertHScroll + ignoreTrackpadSwitch.checked = backend.ignoreTrackpad } } }