Skip to content

Commit 3cb38df

Browse files
authored
fix: restore and harden MX Master gesture handling across macOS and Windows (#20)
* fix: restore macOS gesture rawxy and use idle-based timeout * fix: restore qml backend and mouse page bindings * fix: restore gesture swipe configuration * fix: restore battery reads on connect * fix: restore debug mode panel and hooks * fix: preserve dpi callback during startup * fix: prefer rawxy over event tap during gestures * feat: add HID backend preference support via CLI arguments for debugging * fix: finish UI merge cleanup * fix: restore battery refresh polling * refactor: use public engine control API * fix: make hook startup transactional * fix: avoid swallowing inverted scroll events * refactor: use structured gesture debug events * refactor: cache selected profile mappings * test: add CI and config migration coverage * fix: preserve remapped horizontal scroll when inverted
1 parent a96d78b commit 3cb38df

11 files changed

Lines changed: 2321 additions & 202 deletions

File tree

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
checks:
9+
runs-on: ubuntu-latest
10+
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: actions/setup-python@v5
15+
with:
16+
python-version: "3.12"
17+
18+
- name: Install dependencies
19+
run: python -m pip install -r requirements.txt
20+
21+
- name: Compile Python sources
22+
run: python -m py_compile main_qml.py core/*.py ui/*.py
23+
24+
- name: Run config tests
25+
run: python -m unittest discover -s tests -p "test_*.py"
26+
27+
- name: Lint QML
28+
run: |
29+
python - <<'PY'
30+
import subprocess
31+
from pathlib import Path
32+
import PySide6
33+
34+
qml_files = sorted(str(path) for path in Path("ui/qml").glob("*.qml"))
35+
qmllint = Path(PySide6.__file__).resolve().parent / "qmllint"
36+
subprocess.run([str(qmllint), *qml_files], check=True)
37+
PY

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ Mouser.bat
155155

156156
> **Tip:** To run without a console window, use `pythonw.exe main_qml.py` or the `.lnk` shortcut.
157157
158+
Temporary macOS transport override for debugging:
159+
160+
```bash
161+
python main_qml.py --hid-backend=iokit
162+
python main_qml.py --hid-backend=hidapi
163+
python main_qml.py --hid-backend=auto
164+
```
165+
166+
Use this only for troubleshooting. `auto` is the default behavior.
167+
158168
### Creating a Desktop Shortcut
159169

160170
A `Mouser.lnk` shortcut is included. To create one manually:

core/engine.py

Lines changed: 116 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
import threading
88
from core.mouse_hook import MouseHook, MouseEvent
9-
from core.key_simulator import execute_action
9+
from core.key_simulator import ACTIONS, execute_action
1010
from core.config import (
1111
load_config, get_active_mappings, get_profile_for_app,
12-
BUTTON_TO_EVENTS, save_config,
12+
BUTTON_TO_EVENTS, GESTURE_DIRECTION_BUTTONS, save_config,
1313
)
1414
from core.app_detector import AppDetector
1515

@@ -31,8 +31,16 @@ def __init__(self):
3131
self._profile_change_cb = None # UI callback
3232
self._connection_change_cb = None # UI callback for device status
3333
self._battery_read_cb = None # UI callback for battery level
34+
self._dpi_read_cb = None # UI callback for current DPI
35+
self._debug_cb = None # UI callback for debug messages
36+
self._gesture_event_cb = None # UI callback for structured gesture events
37+
self._debug_events_enabled = bool(
38+
self.cfg.get("settings", {}).get("debug_mode", False)
39+
)
3440
self._battery_poll_stop = threading.Event()
3541
self._lock = threading.Lock()
42+
self.hook.set_debug_callback(self._emit_debug)
43+
self.hook.set_gesture_callback(self._emit_gesture_event)
3644
self._setup_hooks()
3745
self.hook.set_connection_change_callback(self._on_connection_change)
3846
# Apply persisted DPI setting
@@ -54,6 +62,16 @@ def _setup_hooks(self):
5462
settings = self.cfg.get("settings", {})
5563
self.hook.invert_vscroll = settings.get("invert_vscroll", False)
5664
self.hook.invert_hscroll = settings.get("invert_hscroll", False)
65+
self.hook.debug_mode = self._debug_events_enabled
66+
self.hook.configure_gestures(
67+
enabled=any(mappings.get(key, "none") != "none"
68+
for key in GESTURE_DIRECTION_BUTTONS),
69+
threshold=settings.get("gesture_threshold", 50),
70+
deadzone=settings.get("gesture_deadzone", 40),
71+
timeout_ms=settings.get("gesture_timeout_ms", 3000),
72+
cooldown_ms=settings.get("gesture_cooldown_ms", 500),
73+
)
74+
self._emit_mapping_snapshot("Hook mappings refreshed", mappings)
5775

5876
for btn_key, action_id in mappings.items():
5977
events = list(BUTTON_TO_EVENTS.get(btn_key, ()))
@@ -75,13 +93,28 @@ def _setup_hooks(self):
7593
def _make_handler(self, action_id):
7694
def handler(event):
7795
if self._enabled:
96+
self._emit_debug(
97+
f"Mapped {event.event_type} -> {action_id} "
98+
f"({self._action_label(action_id)})"
99+
)
100+
if event.event_type.startswith("gesture_"):
101+
self._emit_gesture_event({
102+
"type": "mapped",
103+
"event_name": event.event_type,
104+
"action_id": action_id,
105+
"action_label": self._action_label(action_id),
106+
})
78107
execute_action(action_id)
79108
return handler
80109

81110
def _make_hscroll_handler(self, action_id):
82111
def handler(event):
83112
if not self._enabled:
84113
return
114+
self._emit_debug(
115+
f"Mapped {event.event_type} -> {action_id} "
116+
f"({self._action_label(action_id)})"
117+
)
85118
execute_action(action_id)
86119
return handler
87120

@@ -103,6 +136,7 @@ def _switch_profile(self, profile_name: str):
103136
# Lightweight: just re-wire callbacks, keep hook + HID++ alive
104137
self.hook.reset_bindings()
105138
self._setup_hooks()
139+
self._emit_debug(f"Active profile -> {profile_name}")
106140
# Notify UI (if connected)
107141
if self._profile_change_cb:
108142
try:
@@ -114,35 +148,98 @@ def set_profile_change_callback(self, cb):
114148
"""Register a callback ``cb(profile_name)`` invoked on auto-switch."""
115149
self._profile_change_cb = cb
116150

151+
def set_debug_callback(self, cb):
152+
"""Register ``cb(message: str)`` invoked for debug events."""
153+
self._debug_cb = cb
154+
155+
def set_gesture_event_callback(self, cb):
156+
"""Register ``cb(event: dict)`` invoked for structured gesture debug events."""
157+
self._gesture_event_cb = cb
158+
159+
def set_debug_enabled(self, enabled):
160+
enabled = bool(enabled)
161+
self.cfg.setdefault("settings", {})["debug_mode"] = enabled
162+
self._debug_events_enabled = enabled
163+
self.hook.debug_mode = enabled
164+
if enabled:
165+
self._emit_debug(f"Debug enabled on profile {self._current_profile}")
166+
self._emit_mapping_snapshot(
167+
"Current mappings", get_active_mappings(self.cfg)
168+
)
169+
170+
def set_debug_events_enabled(self, enabled):
171+
self._debug_events_enabled = bool(enabled)
172+
self.hook.debug_mode = self._debug_events_enabled
173+
174+
def _action_label(self, action_id):
175+
return ACTIONS.get(action_id, {}).get("label", action_id)
176+
177+
def _emit_debug(self, message):
178+
if not self._debug_events_enabled:
179+
return
180+
if self._debug_cb:
181+
try:
182+
self._debug_cb(message)
183+
except Exception:
184+
pass
185+
186+
def _emit_gesture_event(self, event):
187+
if not self._debug_events_enabled:
188+
return
189+
if self._gesture_event_cb:
190+
try:
191+
self._gesture_event_cb(event)
192+
except Exception:
193+
pass
194+
195+
def _emit_mapping_snapshot(self, prefix, mappings):
196+
if not self._debug_events_enabled:
197+
return
198+
interesting = [
199+
"gesture",
200+
"gesture_left",
201+
"gesture_right",
202+
"gesture_up",
203+
"gesture_down",
204+
"xbutton1",
205+
"xbutton2",
206+
]
207+
summary = ", ".join(f"{key}={mappings.get(key, 'none')}" for key in interesting)
208+
self._emit_debug(f"{prefix}: {summary}")
209+
117210
def _on_connection_change(self, connected):
211+
self._battery_poll_stop.set()
118212
if self._connection_change_cb:
119213
try:
120214
self._connection_change_cb(connected)
121215
except Exception:
122216
pass
123-
self._battery_poll_stop.set() # stop any existing poll loop
124217
if connected:
125218
self._battery_poll_stop = threading.Event()
126219
threading.Thread(
127-
target=self._battery_poll_loop, daemon=True, name="BatteryPoll"
220+
target=self._battery_poll_loop,
221+
args=(self._battery_poll_stop,),
222+
daemon=True,
223+
name="BatteryPoll",
128224
).start()
129225

130-
def _battery_poll_loop(self):
131-
"""Read battery on connect then every 5 minutes while connected."""
132-
import time
133-
time.sleep(1) # brief settle after connect
134-
stop = self._battery_poll_stop
135-
while not stop.is_set():
226+
def _battery_poll_loop(self, stop_event):
227+
"""Read battery on connect and refresh it periodically until disconnected."""
228+
if stop_event.wait(1):
229+
return
230+
while not stop_event.is_set():
136231
hg = self.hook._hid_gesture
137232
if hg:
138233
level = hg.read_battery()
234+
if stop_event.is_set():
235+
return
139236
if level is not None and self._battery_read_cb:
140237
try:
141238
self._battery_read_cb(level)
142239
except Exception:
143240
pass
144-
if stop.wait(300): # 5 minutes between polls; exits immediately if stopped
145-
break
241+
if stop_event.wait(300):
242+
return
146243

147244
def set_battery_callback(self, cb):
148245
"""Register ``cb(level: int)`` invoked when battery level is read (0-100)."""
@@ -156,6 +253,10 @@ def set_connection_change_callback(self, cb):
156253
def device_connected(self):
157254
return self.hook.device_connected
158255

256+
@property
257+
def enabled(self):
258+
return self._enabled
259+
159260
# ------------------------------------------------------------------
160261
# Public API
161262
# ------------------------------------------------------------------
@@ -180,15 +281,15 @@ def reload_mappings(self):
180281
self._current_profile = self.cfg.get("active_profile", "default")
181282
self.hook.reset_bindings()
182283
self._setup_hooks()
284+
self._emit_debug(f"reload_mappings profile={self._current_profile}")
183285

184286
def set_enabled(self, enabled):
185-
self._enabled = enabled
287+
self._enabled = bool(enabled)
186288

187289
def start(self):
188290
self.hook.start()
189291
self._app_detector.start()
190292
# Read current DPI from device on startup (don't overwrite it)
191-
self._dpi_read_cb = None # UI callback for initial DPI
192293
def _read_dpi():
193294
import time
194295
time.sleep(3) # give HID++ time to connect
@@ -210,6 +311,6 @@ def set_dpi_read_callback(self, cb):
210311
self._dpi_read_cb = cb
211312

212313
def stop(self):
314+
self._battery_poll_stop.set()
213315
self._app_detector.stop()
214316
self.hook.stop()
215-

0 commit comments

Comments
 (0)