Skip to content
Open
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
8 changes: 7 additions & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
}

DEFAULT_CONFIG = {
"version": 8,
"version": 9,
"active_profile": "default",
"profiles": {
"default": {
Expand Down Expand Up @@ -104,6 +104,7 @@
"debug_mode": False,
"device_layout_overrides": {},
"language": "en",
"ignore_trackpad": True,
},
}

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions core/mouse_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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
Expand Down Expand Up @@ -1298,13 +1303,58 @@ 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:
if not self._first_event_logged:
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

Expand Down
9 changes: 5 additions & 4 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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"
)
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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)
Expand All @@ -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"],
Expand Down
17 changes: 17 additions & 0 deletions ui/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)))
Expand Down
48 changes: 48 additions & 0 deletions ui/qml/ScrollPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
}

Expand Down Expand Up @@ -933,6 +980,7 @@ Item {
}
vscrollSwitch.checked = backend.invertVScroll
hscrollSwitch.checked = backend.invertHScroll
ignoreTrackpadSwitch.checked = backend.ignoreTrackpad
}
}
}