From 4b2055e4462495f6d9ae45941a566ca0b2c1ab8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:25:31 +0000 Subject: [PATCH 1/3] feat: implement high sensitivity scroll via HID++ features 0x2120/0x2121 Agent-Logs-Url: https://github.com/farfromrefug/Mouser/sessions/ec469ca3-a7a0-4390-9287-92a3096008d6 Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- core/config.py | 8 +++- core/engine.py | 19 +++++++++ core/hid_gesture.py | 89 ++++++++++++++++++++++++++++++++++++++++++- tests/test_config.py | 6 ++- ui/backend.py | 17 +++++++++ ui/qml/ScrollPage.qml | 80 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) diff --git a/core/config.py b/core/config.py index 3fae1a8..478dd04 100644 --- a/core/config.py +++ b/core/config.py @@ -65,7 +65,7 @@ } DEFAULT_CONFIG = { - "version": 6, + "version": 7, "active_profile": "default", "profiles": { "default": { @@ -94,6 +94,7 @@ "invert_vscroll": False, # swap vertical scroll directions "dpi": 1000, # pointer speed / DPI setting "smart_shift_mode": "ratchet", + "hi_res_scroll": False, "gesture_threshold": 50, "gesture_deadzone": 40, "gesture_timeout_ms": 3000, @@ -294,6 +295,11 @@ def _migrate(cfg): mappings.setdefault("mode_shift", "none") cfg["version"] = 6 + if version < 7: + settings = cfg.setdefault("settings", {}) + settings.setdefault("hi_res_scroll", False) + cfg["version"] = 7 + cfg.setdefault("settings", {}) if "start_at_login" not in cfg["settings"]: cfg["settings"]["start_at_login"] = bool( diff --git a/core/engine.py b/core/engine.py index 5bc861c..de8d0ce 100644 --- a/core/engine.py +++ b/core/engine.py @@ -327,6 +327,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): """Send Smart Shift mode change ('ratchet' or 'freespin').""" self.cfg.setdefault("settings", {})["smart_shift_mode"] = mode @@ -382,6 +398,9 @@ def _apply_saved_settings(): self._smart_shift_read_cb(saved_ss) except Exception: pass + saved_hrs = self.cfg.get("settings", {}).get("hi_res_scroll") + if saved_hrs and hg.hi_res_scroll_supported: + hg.set_hi_res_scroll(saved_hrs) threading.Thread(target=_apply_saved_settings, daemon=True).start() def set_dpi_read_callback(self, cb): diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 56c5db5..057820d 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -474,6 +474,8 @@ def read(self, _size, timeout_ms=0): FEAT_REPROG_V4 = 0x1B04 # Reprogrammable Controls V4 FEAT_ADJ_DPI = 0x2201 # Adjustable DPI FEAT_SMART_SHIFT = 0x2110 # Smart Shift (scroll wheel mode) +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_BATTERY_STATUS = 0x1000 # Battery Status (fallback) DEFAULT_GESTURE_CID = DEFAULT_GESTURE_CIDS[0] @@ -597,6 +599,10 @@ def __init__(self, on_down=None, on_up=None, on_move=None, self._smart_shift_idx = None # feature index of SMART_SHIFT 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._pending_battery = None self._battery_result = None self._last_logged_battery = None @@ -1073,6 +1079,73 @@ def _apply_pending_read_smart_shift(self): self._smart_shift_result = None self._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 fnid=0x10→func 1 + 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 fnid=0x20→func 2 + 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 fnid=0x10→func 1 + 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 @@ -1241,6 +1314,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 @@ -1312,7 +1387,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 @@ -1321,6 +1396,14 @@ def _try_connect(self): if ss_fi: self._smart_shift_idx = ss_fi print(f"[HidGesture] Found SMART_SHIFT @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 @@ -1385,6 +1468,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) @@ -1404,6 +1489,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/tests/test_config.py b/tests/test_config.py index d0b7b7e..25cec08 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"], 6) + self.assertEqual(migrated["version"], 7) self.assertEqual(migrated["profiles"]["default"]["apps"], []) self.assertFalse(migrated["settings"]["start_at_login"]) self.assertFalse(migrated["settings"]["invert_hscroll"]) @@ -44,6 +44,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.assertEqual( migrated["profiles"]["default"]["mappings"]["gesture"], "none" ) @@ -129,8 +130,9 @@ def test_migrate_renames_start_with_windows_to_start_at_login(self): migrated = config._migrate(legacy) - self.assertEqual(migrated["version"], 6) + self.assertEqual(migrated["version"], 7) self.assertTrue(migrated["settings"]["start_at_login"]) + self.assertFalse(migrated["settings"]["hi_res_scroll"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["mode_shift"], "none" ) diff --git a/ui/backend.py b/ui/backend.py index 9cf7fd2..a9c9819 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -221,6 +221,14 @@ def smartShiftMode(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=settingsChanged) def startMinimized(self): return bool(self._cfg.get("settings", {}).get("start_minimized", True)) @@ -532,6 +540,15 @@ def setSmartShift(self, mode): self._engine.set_smart_shift(mode) self.smartShiftChanged.emit() + @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 4727267..4d9fe22 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -336,6 +336,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 } + Rectangle { width: parent.width - 72 anchors.horizontalCenter: parent.horizontalCenter @@ -714,6 +792,8 @@ Item { } vscrollSwitch.checked = backend.invertVScroll hscrollSwitch.checked = backend.invertHScroll + if (backend.hiResScrollSupported) + hiResScrollSwitch.checked = backend.hiResScroll } } } From d1538294348c1afe34762a0e1b9aee495d166088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:26:58 +0000 Subject: [PATCH 2/3] fix: address code review feedback (capitalisation, comments) Agent-Logs-Url: https://github.com/farfromrefug/Mouser/sessions/ec469ca3-a7a0-4390-9287-92a3096008d6 Co-authored-by: farfromrefug <655344+farfromrefug@users.noreply.github.com> --- core/hid_gesture.py | 6 +++--- ui/qml/ScrollPage.qml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 057820d..ee1421f 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -1112,7 +1112,7 @@ def _apply_pending_hi_res_scroll(self): 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 fnid=0x10→func 1 + resp = self._request(self._hires_wheel_idx, 0x01, []) # getMode (function 0x10) if resp: _, _, _, _, p = resp current = p[0] if p else 0x00 @@ -1120,7 +1120,7 @@ def _apply_pending_hi_res_scroll(self): 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 fnid=0x20→func 2 + 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 @@ -1133,7 +1133,7 @@ def _apply_pending_hi_res_scroll(self): # 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 fnid=0x10→func 1 + 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 diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index 4d9fe22..577beee 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -391,7 +391,7 @@ Item { } Text { - text: "High sensitivity scroll" + text: "High Sensitivity Scroll" font { family: uiState.fontFamily pixelSize: 13 @@ -404,7 +404,7 @@ Item { id: hiResScrollSwitch checked: backend.hiResScroll Material.accent: scrollPage.theme.accent - Accessible.name: "High sensitivity scroll" + Accessible.name: "High Sensitivity Scroll" onToggled: backend.setHiResScroll(checked) } } From bfe854491d9beb8659d4fb93f9b1ee009ddcd49e Mon Sep 17 00:00:00 2001 From: farfromrefuge Date: Thu, 9 Apr 2026 21:56:41 +0200 Subject: [PATCH 3/3] chore: lint --- core/engine.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/engine.py b/core/engine.py index ca645e1..b12d5f0 100644 --- a/core/engine.py +++ b/core/engine.py @@ -478,8 +478,8 @@ def _replay_saved_settings_once(self): replay_ok = False saved_hrs = self.cfg.get("settings", {}).get("hi_res_scroll") - if saved_hrs and hg.hi_res_scroll_supported: - hg.set_hi_res_scroll(saved_hrs) + if saved_hrs and hg.hi_res_scroll_supported: + hg.set_hi_res_scroll(saved_hrs) saved_ss = self.cfg.get("settings", {}).get("smart_shift_mode") if saved_ss and getattr(hg, "smart_shift_supported", False): @@ -759,8 +759,8 @@ def _apply_device_settings(self, source="startup"): pass saved_hrs = self.cfg.get("settings", {}).get("hi_res_scroll") - if saved_hrs and hg.hi_res_scroll_supported: - hg.set_hi_res_scroll(saved_hrs) + if saved_hrs and hg.hi_res_scroll_supported: + hg.set_hi_res_scroll(saved_hrs) if hg.smart_shift_supported: ok = hg.set_smart_shift(ss_mode, ss_enabled, ss_threshold)