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
3 changes: 3 additions & 0 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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)
Expand Down
89 changes: 88 additions & 1 deletion core/hid_gesture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions ui/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions ui/qml/ScrollPage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -934,6 +1012,8 @@ Item {
}
vscrollSwitch.checked = backend.invertVScroll
hscrollSwitch.checked = backend.invertHScroll
if (backend.hiResScrollSupported)
hiResScrollSwitch.checked = backend.hiResScroll
}
}
}
Loading