Skip to content

Commit 7f0482e

Browse files
David Noyesclaude
andcommitted
fix: actions ring dispatch, double-inversion guard, and desktop cycling fixes
Actions ring button (CID 0x00C3) was dispatching THUMB_BUTTON events instead of ACTIONS_RING events, so the engine handler never fired. Clarified the HID-to-config naming mismatch in comments across mouse_hook_base, mouse_hook_types, and logi_device_catalog. Guard OS-level scroll inversion with wheel_native_invert_active on all three platforms to prevent double-inversion when firmware handles it. Fix Windows Shift+wheel swallowing scroll when _ri_hwnd is unavailable. Pass CGSMainConnectionID() to CGSGetActiveSpace() to prevent ARM64 segfault. Move desktop_direction to in-memory state and add optimistic cache update to prevent rapid-press overshoot. Add v17 config migration for hscroll_threshold (int 1 → float 0.1). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a16a284 commit 7f0482e

10 files changed

Lines changed: 47 additions & 40 deletions

core/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def coerce_wheel_divert_setting(value: object) -> str:
101101
}
102102

103103
DEFAULT_CONFIG = {
104-
"version": 16,
104+
"version": 17,
105105
"active_profile": "default",
106106
"profiles": {
107107
"default": {
@@ -538,6 +538,13 @@ def _migrate(cfg):
538538
cfg.setdefault("settings", {}).setdefault("force_sensitivity", None)
539539
cfg["version"] = 16
540540

541+
if version < 17:
542+
# v16 -> v17: migrate hscroll_threshold from old default (int 1) to 0.1.
543+
s = cfg.setdefault("settings", {})
544+
if s.get("hscroll_threshold") == 1:
545+
s["hscroll_threshold"] = 0.1
546+
cfg["version"] = 17
547+
541548
cfg.setdefault("settings", {})
542549
cfg["settings"].setdefault("appearance_mode", "system")
543550
cfg["settings"].setdefault("debug_mode", False)

core/engine.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def __init__(self):
7171
self._hid_replay_requested_this_launch = False
7272
self._desktop_info_cache = None
7373
self._desktop_info_ts = 0.0
74+
self._desktop_direction = "right"
7475
self._replay_inflight = False
7576
self._replay_pending_rerun = False
7677
self._replay_lock = threading.Lock()
@@ -473,8 +474,11 @@ def _get_macos_desktop_info():
473474
# CGSGetActiveSpace returns the current space id64 of the
474475
# display where the cursor is located.
475476
cg = ctypes.CDLL('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
477+
cg.CGSMainConnectionID.restype = ctypes.c_uint32
476478
cg.CGSGetActiveSpace.restype = ctypes.c_int64
477-
current_id64 = cg.CGSGetActiveSpace()
479+
cg.CGSGetActiveSpace.argtypes = [ctypes.c_uint32]
480+
conn = cg.CGSMainConnectionID()
481+
current_id64 = cg.CGSGetActiveSpace(conn)
478482

479483
# Read spaces config
480484
result = subprocess.run(
@@ -560,8 +564,6 @@ def _cycle_desktops(self):
560564
print("[Engine] cycle_desktops only supported on macOS")
561565
return
562566

563-
settings = self.cfg.setdefault("settings", {})
564-
565567
# Get desktop info (cached for 5 seconds to avoid subprocess per press)
566568
now = time.time()
567569
if self._desktop_info_cache is None or (now - self._desktop_info_ts) > 5.0:
@@ -574,20 +576,17 @@ def _cycle_desktops(self):
574576
print(f"[Engine] cycle_desktops: only {desktop_count} desktop, skipping")
575577
return
576578

577-
# Read direction state
578-
direction = settings.get("desktop_direction", "right")
579+
direction = self._desktop_direction
579580

580581
# Calculate next position
581582
if direction == "right":
582583
if current >= desktop_count:
583-
# At right edge, reverse
584584
direction = "left"
585585
next_pos = current - 1
586586
else:
587587
next_pos = current + 1
588588
else: # left
589589
if current <= 1:
590-
# At left edge, reverse
591590
direction = "right"
592591
next_pos = current + 1
593592
else:
@@ -600,9 +599,8 @@ def _cycle_desktops(self):
600599
else:
601600
execute_action("space_left")
602601

603-
# Save direction state
604-
settings["desktop_direction"] = direction
605-
save_config(self.cfg)
602+
self._desktop_direction = direction
603+
self._desktop_info_cache = (desktop_count, next_pos)
606604

607605
def _make_hscroll_handler(self, action_id):
608606
def handler(event):

core/logi_device_catalog.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@
2323
"mode_shift",
2424
)
2525

26-
# MX Master 4 layout: the standard MX Master set plus a third thumb-area
27-
# button. The MX Master 4 relocates the legacy gesture button (CID 0x00C3)
28-
# next to back/forward and adds a haptic Sense Panel (CID 0x01A0); the
29-
# "thumb_button" key gives the relocated button its own UI mapping target.
26+
# MX Master 4 layout: two gesture-area buttons instead of one.
27+
# - Sense Panel (CID 0x01A0): large top surface → config key "gesture"
28+
# - Actions Ring button (CID 0x00C3): small thumb-area button → config key
29+
# "actions_ring". The HID layer calls this "thumb_button" internally
30+
# (after its physical location); the dispatch maps it to actions_ring events.
3031
MX_MASTER_4_BUTTONS = (
3132
"middle",
3233
"gesture",

core/mouse_hook_base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,9 @@ def _on_hid_actions_ring_up(self):
347347
self._dispatch(MouseEvent(MouseEvent.ACTIONS_RING_UP))
348348

349349
def _on_hid_thumb_button_down(self):
350-
self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_DOWN))
350+
# HID layer calls this "thumb_button" (physical location), but the
351+
# UI/config key is "actions_ring" — dispatch the matching event type.
352+
self._dispatch(MouseEvent(MouseEvent.ACTIONS_RING_DOWN))
351353

352354
def _on_hid_thumb_button_up(self):
353-
self._dispatch(MouseEvent(MouseEvent.THUMB_BUTTON_UP))
355+
self._dispatch(MouseEvent(MouseEvent.ACTIONS_RING_UP))

core/mouse_hook_linux.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -773,13 +773,13 @@ def _handle_rel(self, event):
773773
if code == _ecodes.REL_WHEEL or code == rel_wheel_hi_res:
774774
if value != 0 and self._shift_held():
775775
if code == _ecodes.REL_WHEEL:
776-
h_value = -value if self.invert_hscroll else value
776+
h_value = -value if (self.invert_hscroll and not self.wheel_native_invert_active) else value
777777
self._uinput.write(_ecodes.EV_REL, _ecodes.REL_HWHEEL, h_value)
778778
# REL_WHEEL_HI_RES events are suppressed while Shift is held so
779779
# the vertical scroll doesn't double-fire alongside the
780780
# translated horizontal scroll.
781781
return
782-
if self.invert_vscroll:
782+
if self.invert_vscroll and not self.wheel_native_invert_active:
783783
self._uinput.write(_ecodes.EV_REL, code, -value)
784784
else:
785785
self._uinput.write_event(event)
@@ -801,7 +801,7 @@ def _handle_rel(self, event):
801801

802802
if should_block:
803803
return
804-
if self.invert_hscroll:
804+
if self.invert_hscroll and not self.wheel_native_invert_active:
805805
self._uinput.write(_ecodes.EV_REL, code, -value)
806806
else:
807807
self._uinput.write_event(event)

core/mouse_hook_macos.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ def _event_tap_callback(self, proxy, event_type, cg_event, refcon):
373373
mouse_event = None
374374
if should_block:
375375
return None
376-
if self.invert_vscroll or self.invert_hscroll:
376+
if (self.invert_vscroll or self.invert_hscroll) and not self.wheel_native_invert_active:
377377
if self._post_inverted_scroll_event(cg_event):
378378
return None
379379

core/mouse_hook_types.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ class MouseEvent:
2323
XBUTTON1_UP = "xbutton1_up"
2424
XBUTTON2_DOWN = "xbutton2_down"
2525
XBUTTON2_UP = "xbutton2_up"
26-
# MX Master 4 Thumb button (small front-face button, HID++ CID 0x00c3).
27-
# Distinct from the Sense Panel (CID 0x01a0), which is the gesture button.
26+
# Reserved — currently unused. The MX Master 4's small thumb-area
27+
# button (CID 0x00C3) dispatches as ACTIONS_RING_DOWN/UP (see below)
28+
# because the UI/config calls it "actions_ring", not "thumb_button".
2829
THUMB_BUTTON_DOWN = "thumb_button_down"
2930
THUMB_BUTTON_UP = "thumb_button_up"
3031
MIDDLE_DOWN = "middle_down"

core/mouse_hook_windows.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -355,14 +355,12 @@ def _low_level_handler_inner(self, nCode, wParam, lParam):
355355
self._shift_hscroll_posted = True
356356
return 1
357357
self._pending_shift_hscroll -= h_delta
358-
return 1
359358
else:
360359
self._emit_debug(
361360
"Shift+wheel translation skipped: "
362361
"raw input window unavailable"
363362
)
364-
return 1
365-
if self.invert_vscroll:
363+
if self.invert_vscroll and not self.wheel_native_invert_active:
366364
if delta != 0 and self._ri_hwnd:
367365
self._pending_vscroll += -delta
368366
if self._vscroll_posted:
@@ -385,7 +383,7 @@ def _low_level_handler_inner(self, nCode, wParam, lParam):
385383
event = MouseEvent(MouseEvent.HSCROLL_RIGHT, abs(delta))
386384
should_block = MouseEvent.HSCROLL_RIGHT in self._blocked_events
387385

388-
if self.invert_hscroll:
386+
if self.invert_hscroll and not self.wheel_native_invert_active:
389387
if delta != 0 and self._ri_hwnd and not should_block:
390388
self._pending_hscroll += -delta
391389
if self._hscroll_posted:

tests/test_config.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self):
4444

4545
migrated = config._migrate(legacy)
4646

47-
self.assertEqual(migrated["version"], 16)
47+
self.assertEqual(migrated["version"], 17)
4848
self.assertEqual(migrated["profiles"]["default"]["apps"], [])
4949
self.assertFalse(migrated["settings"]["invert_hscroll"])
5050
self.assertFalse(migrated["settings"]["invert_vscroll"])
@@ -92,7 +92,7 @@ def test_migrate_updates_media_player_profile_apps(self):
9292

9393
migrated = config._migrate(cfg)
9494

95-
self.assertEqual(migrated["version"], 16)
95+
self.assertEqual(migrated["version"], 17)
9696
self.assertEqual(
9797
migrated["profiles"]["media"]["apps"],
9898
["Microsoft.Media.Player.exe", "VLC.exe"],
@@ -109,7 +109,7 @@ def test_migrate_updates_media_player_profile_apps(self):
109109
def test_default_hscroll_threshold_supports_fractional_mac_deltas(self):
110110
self.assertEqual(config.DEFAULT_CONFIG["settings"]["hscroll_threshold"], 0.1)
111111

112-
def test_load_config_preserves_integer_hscroll_threshold(self):
112+
def test_load_config_migrates_integer_hscroll_threshold(self):
113113
partial = {
114114
"version": 9,
115115
"active_profile": "default",
@@ -135,7 +135,7 @@ def test_load_config_preserves_integer_hscroll_threshold(self):
135135
):
136136
loaded = config.load_config()
137137

138-
self.assertEqual(loaded["settings"]["hscroll_threshold"], 1)
138+
self.assertEqual(loaded["settings"]["hscroll_threshold"], 0.1)
139139

140140
def test_load_config_merges_missing_defaults_from_disk(self):
141141
partial = {
@@ -165,7 +165,7 @@ def test_load_config_merges_missing_defaults_from_disk(self):
165165
):
166166
loaded = config.load_config()
167167

168-
self.assertEqual(loaded["version"], 16)
168+
self.assertEqual(loaded["version"], 17)
169169
self.assertEqual(loaded["settings"]["dpi"], 800)
170170
self.assertEqual(loaded["settings"]["action_haptic"], [])
171171
self.assertTrue(loaded["settings"]["haptic_enabled"])
@@ -197,7 +197,7 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self):
197197

198198
migrated = config._migrate(legacy)
199199

200-
self.assertEqual(migrated["version"], 16)
200+
self.assertEqual(migrated["version"], 17)
201201
self.assertTrue(migrated["settings"]["start_at_login"])
202202
self.assertEqual(
203203
migrated["profiles"]["default"]["mappings"]["mode_shift"],
@@ -223,7 +223,7 @@ def test_migrate_v8_to_v9_adds_actions_ring_and_haptic(self):
223223

224224
migrated = config._migrate(v8_cfg)
225225

226-
self.assertEqual(migrated["version"], 16)
226+
self.assertEqual(migrated["version"], 17)
227227
self.assertEqual(
228228
migrated["profiles"]["default"]["mappings"]["actions_ring"], "none"
229229
)
@@ -246,7 +246,7 @@ def test_migrate_v9_to_v10_adds_gesture_recognizer_params(self):
246246

247247
migrated = config._migrate(v9_cfg)
248248

249-
self.assertEqual(migrated["version"], 16)
249+
self.assertEqual(migrated["version"], 17)
250250
self.assertEqual(migrated["settings"]["gesture_commit_window_ms"], 400)
251251
self.assertEqual(migrated["settings"]["gesture_settle_ms"], 90)
252252
self.assertEqual(migrated["settings"]["gesture_cross_ratio"], 0.5)
@@ -272,7 +272,7 @@ def test_migrate_v10_to_v11_adds_button_haptic(self):
272272

273273
migrated = config._migrate(v10_cfg)
274274

275-
self.assertEqual(migrated["version"], 16)
275+
self.assertEqual(migrated["version"], 17)
276276
self.assertEqual(migrated["profiles"]["default"]["button_haptic"], {})
277277
self.assertEqual(migrated["profiles"]["work"]["button_haptic"], {})
278278

@@ -292,7 +292,7 @@ def test_migrate_v12_to_v13_adds_action_haptic_list(self):
292292

293293
migrated = config._migrate(v12_cfg)
294294

295-
self.assertEqual(migrated["version"], 16)
295+
self.assertEqual(migrated["version"], 17)
296296
self.assertEqual(migrated["settings"]["action_haptic"], [])
297297
# existing haptic settings preserved
298298
self.assertTrue(migrated["settings"]["haptic_enabled"])
@@ -343,7 +343,7 @@ def test_migrate_v13_to_v14_adds_button_haptic_and_dedup(self):
343343

344344
migrated = config._migrate(v13_cfg)
345345

346-
self.assertEqual(migrated["version"], 16)
346+
self.assertEqual(migrated["version"], 17)
347347
self.assertEqual(migrated["settings"]["button_haptic"], [])
348348
self.assertTrue(migrated["settings"]["haptic_dedup"])
349349
# existing settings preserved

tests/test_smart_shift.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,7 +749,7 @@ def test_multiple_profiles_all_migrated(self):
749749
def test_version_bumped_to_current(self):
750750
from core.config import _migrate
751751
migrated = _migrate(self._v6_config())
752-
self.assertEqual(migrated["version"], 16)
752+
self.assertEqual(migrated["version"], 17)
753753

754754

755755
# ──────────────────────────────────────────────────────────────────────────────
@@ -811,7 +811,7 @@ def test_multiple_profiles_all_migrated(self):
811811
def test_version_bumped_to_current(self):
812812
from core.config import _migrate
813813
migrated = _migrate(self._v7_config())
814-
self.assertEqual(migrated["version"], 16)
814+
self.assertEqual(migrated["version"], 17)
815815

816816

817817
class HidForceReconnectTests(unittest.TestCase):

0 commit comments

Comments
 (0)