diff --git a/core/config.py b/core/config.py index 746e525..9b99149 100644 --- a/core/config.py +++ b/core/config.py @@ -96,6 +96,7 @@ "invert_vscroll": False, # swap vertical scroll directions "dpi": 1000, # pointer speed / DPI setting "smart_shift_mode": "ratchet", + "hi_res_scroll": False, "smart_shift_enabled": False, "smart_shift_threshold": 25, "gesture_threshold": 50, @@ -301,6 +302,8 @@ def _migrate(cfg): cfg["version"] = 6 if version < 7: + settings = cfg.setdefault("settings", {}) + settings.setdefault("hi_res_scroll", False) # v6 defaulted mode_shift to "none"; remap to "toggle_smart_shift" so the # physical SmartShift button behind the scroll wheel works out of the box. # Users who explicitly want no action can set it back to "none" in the UI. diff --git a/core/engine.py b/core/engine.py index bdcf58c..4483b7c 100644 --- a/core/engine.py +++ b/core/engine.py @@ -478,6 +478,7 @@ def _run_saved_settings_replay(self): saved_ss_state = self._saved_smart_shift_state() saved_ss = saved_ss_state["mode"] + saved_hrs = saved_hrs = self.cfg.get("settings", {}).get("hi_res_scroll") ss_enabled = saved_ss_state["enabled"] ss_threshold = saved_ss_state["threshold"] @@ -512,6 +513,13 @@ def _run_saved_settings_replay(self): else: replay_ok = False retry_dpi = True + + if saved_hrs is not None: + if not hasattr(hg, "set_hi_res_scroll"): + replay_ok = False + else: + hg.set_hi_res_scroll(saved_hrs) + if saved_ss and getattr(hg, "smart_shift_supported", False): if not hasattr(hg, "set_smart_shift"): @@ -708,6 +716,22 @@ def set_dpi(self, dpi_value): print("[Engine] No HID++ connection — DPI not applied") return False + def set_hi_res_scroll(self, enabled): + """Send Hi-Res Scroll state change to the mouse via HID++.""" + enabled = bool(enabled) + self.cfg.setdefault("settings", {})["hi_res_scroll"] = enabled + save_config(self.cfg) + hg = self.hook._hid_gesture + if hg: + return hg.set_hi_res_scroll(enabled) + print("[Engine] No HID++ connection — Hi-Res Scroll not applied") + return False + + @property + def hi_res_scroll_supported(self): + hg = self.hook._hid_gesture + return hg.hi_res_scroll_supported if hg else False + def set_smart_shift(self, mode, smart_shift_enabled=False, threshold=25): """Send Smart Shift settings to device. mode: 'ratchet' or 'freespin' (fixed mode when smart_shift_enabled=False) diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 8296ff9..ac93d7b 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -475,6 +475,8 @@ def read(self, _size, timeout_ms=0): FEAT_ADJ_DPI = 0x2201 # Adjustable DPI FEAT_SMART_SHIFT = 0x2110 # Smart Shift basic FEAT_SMART_SHIFT_ENHANCED = 0x2111 # Smart Shift Enhanced (MX Master 3/3S, MX Master 4) +FEAT_HI_RES_SCROLL = 0x2120 # Hi-Res Scrolling (simple on/off) +FEAT_HIRES_WHEEL = 0x2121 # Hi-Res Wheel (resolution + invert + divert bits) FEAT_UNIFIED_BATT = 0x1004 # Unified Battery (preferred) FEAT_DEVICE_NAME = 0x0005 # Device Name & Type FEAT_BATTERY_STATUS = 0x1000 # Battery Status (fallback) @@ -601,6 +603,10 @@ def __init__(self, on_down=None, on_up=None, on_move=None, self._smart_shift_enhanced = False # True → use fn 1/2; False → fn 0/1 self._pending_smart_shift = None self._smart_shift_result = None + self._hi_res_scroll_idx = None # feature index of HI_RES_SCROLLING (0x2120) + self._hires_wheel_idx = None # feature index of HIRES_WHEEL (0x2121) + self._pending_hi_res_scroll = None + self._hi_res_scroll_result = None self._smart_shift_call_lock = threading.Lock() self._smart_shift_slot_lock = threading.Lock() self._smart_shift_event = threading.Event() @@ -1256,6 +1262,73 @@ def _apply_pending_read_smart_shift(self): print("[HidGesture] Smart Shift read FAILED") self._finish_pending_smart_shift(None) + # ── Hi-Res Scroll control ─────────────────────────────────── + + # HIRES_WHEEL (0x2121) mode byte bits + _HIRES_WHEEL_RES_BIT = 0x02 # bit 1: 1 = high-res, 0 = low-res + + @property + def hi_res_scroll_supported(self): + return self._hires_wheel_idx is not None or self._hi_res_scroll_idx is not None + + def set_hi_res_scroll(self, enabled): + """Queue a hi-res scroll change. enabled: True or False. + Can be called from any thread. Returns True on success.""" + self._hi_res_scroll_result = None + self._pending_hi_res_scroll = bool(enabled) + for _ in range(30): + if self._pending_hi_res_scroll is None: + return self._hi_res_scroll_result is True + time.sleep(0.1) + print("[HidGesture] Hi-Res Scroll set timed out") + return False + + def _apply_pending_hi_res_scroll(self): + """Called from the listener thread to apply the queued hi-res scroll state.""" + enabled = self._pending_hi_res_scroll + if enabled is None: + return + if self._dev is None: + print("[HidGesture] Cannot set Hi-Res Scroll — not connected") + self._hi_res_scroll_result = False + self._pending_hi_res_scroll = None + return + # Prefer HIRES_WHEEL (0x2121): read-modify-write the mode byte + if self._hires_wheel_idx is not None: + resp = self._request(self._hires_wheel_idx, 0x01, []) # getMode (function 0x10) + if resp: + _, _, _, _, p = resp + current = p[0] if p else 0x00 + if enabled: + new_mode = current | self._HIRES_WHEEL_RES_BIT + else: + new_mode = current & ~self._HIRES_WHEEL_RES_BIT + wr = self._request(self._hires_wheel_idx, 0x02, [new_mode & 0xFF]) # setMode (function 0x20) + if wr: + print(f"[HidGesture] Hi-Res Scroll (HIRES_WHEEL) set to {'on' if enabled else 'off'}") + self._hi_res_scroll_result = True + self._pending_hi_res_scroll = None + return + print("[HidGesture] Hi-Res Scroll (HIRES_WHEEL) set FAILED") + self._hi_res_scroll_result = False + self._pending_hi_res_scroll = None + return + # Fall back to HI_RES_SCROLLING (0x2120): write a single mode byte + if self._hi_res_scroll_idx is not None: + mode_byte = 0x01 if enabled else 0x00 + resp = self._request(self._hi_res_scroll_idx, 0x01, [mode_byte]) # setMode (function 0x10) + if resp: + print(f"[HidGesture] Hi-Res Scroll (HI_RES_SCROLLING) set to {'on' if enabled else 'off'}") + self._hi_res_scroll_result = True + else: + print("[HidGesture] Hi-Res Scroll (HI_RES_SCROLLING) set FAILED") + self._hi_res_scroll_result = False + self._pending_hi_res_scroll = None + return + print("[HidGesture] Cannot set Hi-Res Scroll — feature not found on device") + self._hi_res_scroll_result = False + self._pending_hi_res_scroll = None + def read_battery(self): """Queue a battery read and wait for the listener thread result.""" self._battery_result = None @@ -1451,6 +1524,8 @@ def _try_connect(self): self._feat_idx = None self._dpi_idx = None self._smart_shift_idx = None + self._hi_res_scroll_idx = None + self._hires_wheel_idx = None self._battery_idx = None self._battery_feature_id = None self._gesture_cid = DEFAULT_GESTURE_CID @@ -1544,7 +1619,7 @@ def _try_connect(self): ) print("[HidGesture] Gesture CID candidates: " + ", ".join(_format_cid(cid) for cid in self._gesture_candidates)) - # Also discover ADJUSTABLE_DPI and SMART_SHIFT + # Also discover ADJUSTABLE_DPI, SMART_SHIFT, and hi-res scroll features dpi_fi = self._find_feature(FEAT_ADJ_DPI) if dpi_fi: self._dpi_idx = dpi_fi @@ -1562,6 +1637,14 @@ def _try_connect(self): self._smart_shift_idx = ss_fi self._smart_shift_enhanced = False print(f"[HidGesture] Found SMART_SHIFT (basic) @0x{ss_fi:02X}") + hw_fi = self._find_feature(FEAT_HIRES_WHEEL) + if hw_fi: + self._hires_wheel_idx = hw_fi + print(f"[HidGesture] Found HIRES_WHEEL @0x{hw_fi:02X}") + hrs_fi = self._find_feature(FEAT_HI_RES_SCROLL) + if hrs_fi: + self._hi_res_scroll_idx = hrs_fi + print(f"[HidGesture] Found HI_RES_SCROLLING @0x{hrs_fi:02X}") batt_fi = self._find_feature(FEAT_UNIFIED_BATT) if batt_fi: self._battery_idx = batt_fi @@ -1647,6 +1730,8 @@ def _main_loop(self): self._apply_pending_dpi() if self._pending_smart_shift is not None: self._apply_pending_smart_shift() + if self._pending_hi_res_scroll is not None: + self._apply_pending_hi_res_scroll() if self._pending_battery is not None: self._apply_pending_read_battery() raw = self._rx(1000) @@ -1673,6 +1758,8 @@ def _main_loop(self): self._feat_idx = None self._dpi_idx = None self._smart_shift_idx = None + self._hi_res_scroll_idx = None + self._hires_wheel_idx = None self._battery_idx = None self._battery_feature_id = None self._pending_battery = None diff --git a/requirements.txt b/requirements.txt index 2660c83..1e3d8e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pyinstaller>=6.0 -hidapi>=0.14 +hidapi>=0.15.0 PySide6>=6.6 Pillow>=10.0 pyobjc-framework-Quartz>=10.0; sys_platform == "darwin" diff --git a/tests/test_config.py b/tests/test_config.py index 15cef13..2f17e3a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -43,6 +43,7 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): self.assertEqual(migrated["settings"]["appearance_mode"], "system") self.assertFalse(migrated["settings"]["debug_mode"]) self.assertEqual(migrated["settings"]["device_layout_overrides"], {}) + self.assertFalse(migrated["settings"]["hi_res_scroll"]) self.assertFalse(migrated["settings"]["start_at_login"]) self.assertNotIn("start_with_windows", migrated["settings"]) self.assertEqual( @@ -138,6 +139,7 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self): self.assertEqual(migrated["version"], 8) self.assertTrue(migrated["settings"]["start_at_login"]) + self.assertFalse(migrated["settings"]["hi_res_scroll"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["mode_shift"], "switch_scroll_mode", diff --git a/ui/backend.py b/ui/backend.py index f86ee2a..d0eb750 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -279,6 +279,14 @@ def smartShiftThreshold(self): def smartShiftSupported(self): return self._engine.smart_shift_supported if self._engine else False + @Property(bool, notify=mouseConnectedChanged) + def hiResScrollSupported(self): + return self._engine.hi_res_scroll_supported if self._engine else False + + @Property(bool, notify=settingsChanged) + def hiResScroll(self): + return bool(self._cfg.get("settings", {}).get("hi_res_scroll", False)) + @Property(bool, notify=deviceLayoutChanged) def deviceHasSmartShift(self): """Whether the effective device has a mode_shift button (SmartShift).""" @@ -610,6 +618,15 @@ def setSmartShiftEnabled(self, enabled): def setSmartShiftThreshold(self, threshold): self._applySmartShift(threshold=threshold) + @Slot(bool) + def setHiResScroll(self, value): + enabled = bool(value) + self._cfg.setdefault("settings", {})["hi_res_scroll"] = enabled + save_config(self._cfg) + if self._engine: + self._engine.set_hi_res_scroll(enabled) + self.settingsChanged.emit() + @Slot(bool) def setInvertVScroll(self, value): self._cfg.setdefault("settings", {})["invert_vscroll"] = value diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index b3fc0f9..cd2a422 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -555,6 +555,84 @@ Item { Item { width: 1; height: 16 } + Item { width: 1; height: 16; visible: backend.hiResScrollSupported } + + Rectangle { + visible: backend.hiResScrollSupported + width: parent.width - 72 + anchors.horizontalCenter: parent.horizontalCenter + height: hiResScrollContent.implicitHeight + 40 + radius: Theme.radius + color: scrollPage.theme.bgCard + border.width: 1 + border.color: scrollPage.theme.border + + Column { + id: hiResScrollContent + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 20 + } + spacing: 12 + + Text { + text: "High Sensitivity Scroll" + font { + family: uiState.fontFamily + pixelSize: 16 + bold: true + } + color: scrollPage.theme.textPrimary + } + + Text { + text: "Enable high-resolution scrolling for finer, smoother wheel movement." + font { + family: uiState.fontFamily + pixelSize: 12 + } + color: scrollPage.theme.textSecondary + } + + Rectangle { + width: parent.width + height: 52 + radius: 10 + color: scrollPage.theme.bgSubtle + + RowLayout { + anchors { + fill: parent + leftMargin: 16 + rightMargin: 16 + } + + Text { + text: "High Sensitivity Scroll" + font { + family: uiState.fontFamily + pixelSize: 13 + } + color: scrollPage.theme.textPrimary + Layout.fillWidth: true + } + + Switch { + id: hiResScrollSwitch + checked: backend.hiResScroll + Material.accent: scrollPage.theme.accent + Accessible.name: "High Sensitivity Scroll" + onToggled: backend.setHiResScroll(checked) + } + } + } + } + } + + Item { width: 1; height: 16 } + // ── Language ────────────────────────────────────────── Rectangle { width: parent.width - 72 @@ -934,6 +1012,8 @@ Item { } vscrollSwitch.checked = backend.invertVScroll hscrollSwitch.checked = backend.invertHScroll + if (backend.hiResScrollSupported) + hiResScrollSwitch.checked = backend.hiResScroll } } }