Skip to content

Commit 3507483

Browse files
fix(ui): persist and clamp device-reported DPI through _handleDpiRead
When the engine read the device's current DPI on connect, the backend overwrote ``settings.dpi`` in-memory but never called ``save_config`` -- so a DPI change made on the mouse hardware (via the on-device cycle button, Logi Options+ briefly stealing the device, or another Mouser session) was visible to the running session but reverted on the next launch. The unsaved mutation also left ``engine.cfg`` and disk out of sync, so any later setter (``setDpi`` itself, ``cycleDpiPreset``) wrote the same stale value back. Three concrete changes: * Route the device-reported value through ``clamp_dpi`` against the resolved connected device so the floor/ceiling stay device-aware. The previous handler stored the raw HID value with no validation. * Persist via ``save_config`` and propagate the new config into ``engine.cfg`` so subsequent ``_resolved_connected_device()`` reads observe the same value. * Skip the write entirely when the device-reported value matches the current ``settings.dpi`` -- the handler used to emit ``settingsChanged`` on every poll, churning every QML binding that depends on the ``settings`` keypath. No HID round-trip is added: this handler reacts to a value the device already reports, so calling ``engine.set_dpi`` here would only echo it back. Tests - ``test_persists_new_device_dpi_to_disk`` -- new value flushed via ``save_config``. - ``test_clamps_overrange_dpi_to_device_max`` / ``test_clamps_underrange_dpi_to_device_min`` -- clamp covers both ends of the range. - ``test_no_change_skips_save`` -- redundant reads do not churn disk or signals. - ``test_syncs_engine_cached_config`` -- ``engine.cfg`` mirrors the persisted value. - ``test_emits_dpi_from_device_with_clamped_value`` -- the ``dpiFromDevice`` signal carries the clamped value, not the raw one. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 44880fb commit 3507483

2 files changed

Lines changed: 99 additions & 3 deletions

File tree

tests/test_backend.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import sys
55
import tempfile
66
import unittest
7+
import unittest.mock
78
from types import SimpleNamespace
89
from unittest.mock import patch
910

@@ -1273,5 +1274,79 @@ def test_set_start_minimized_does_not_call_apply_login_startup(self):
12731274
self.assertFalse(backend.startMinimized)
12741275

12751276

1277+
@unittest.skipIf(Backend is None, "PySide6 not installed in test environment")
1278+
class BackendHandleDpiReadTests(unittest.TestCase):
1279+
"""Device-reported DPI must persist to ``config.json`` so a hardware
1280+
DPI change taken on the mouse survives the next Mouser restart, and
1281+
must clamp into the connected device's range to defend against
1282+
bogus reports."""
1283+
1284+
def setUp(self) -> None:
1285+
self._save_mock = unittest.mock.MagicMock()
1286+
self._patches = (
1287+
patch("ui.backend.save_config", self._save_mock),
1288+
patch("ui.backend.supports_login_startup", return_value=False),
1289+
)
1290+
for p in self._patches:
1291+
p.start()
1292+
self.addCleanup(self._stop_patches)
1293+
1294+
def _stop_patches(self) -> None:
1295+
for p in self._patches:
1296+
p.stop()
1297+
1298+
def _build(self, *, cfg=None, engine=None):
1299+
loaded = copy.deepcopy(cfg or DEFAULT_CONFIG)
1300+
with patch("ui.backend.load_config", return_value=loaded):
1301+
backend = Backend(engine=engine)
1302+
self._save_mock.reset_mock()
1303+
return backend
1304+
1305+
def test_persists_new_device_dpi_to_disk(self):
1306+
backend = self._build()
1307+
backend._handleDpiRead(2400)
1308+
self.assertEqual(backend._cfg["settings"]["dpi"], 2400)
1309+
self._save_mock.assert_called_once_with(backend._cfg)
1310+
1311+
def test_clamps_overrange_dpi_to_device_max(self):
1312+
device = SimpleNamespace(dpi_min=200, dpi_max=4000)
1313+
engine = _FakeEngine(device_connected=True, connected_device=device)
1314+
backend = self._build(engine=engine)
1315+
backend._handleDpiRead(99999)
1316+
self.assertEqual(backend._cfg["settings"]["dpi"], 4000)
1317+
self._save_mock.assert_called_once_with(backend._cfg)
1318+
1319+
def test_clamps_underrange_dpi_to_device_min(self):
1320+
device = SimpleNamespace(dpi_min=400, dpi_max=8000)
1321+
engine = _FakeEngine(device_connected=True, connected_device=device)
1322+
backend = self._build(engine=engine)
1323+
backend._handleDpiRead(50)
1324+
self.assertEqual(backend._cfg["settings"]["dpi"], 400)
1325+
self._save_mock.assert_called_once_with(backend._cfg)
1326+
1327+
def test_no_change_skips_save(self):
1328+
cfg = copy.deepcopy(DEFAULT_CONFIG)
1329+
cfg["settings"]["dpi"] = 1500
1330+
backend = self._build(cfg=cfg)
1331+
backend._handleDpiRead(1500)
1332+
self._save_mock.assert_not_called()
1333+
1334+
def test_syncs_engine_cached_config(self):
1335+
engine = _FakeEngine(device_connected=True)
1336+
backend = self._build(engine=engine)
1337+
backend._handleDpiRead(1800)
1338+
self.assertEqual(engine.cfg["settings"]["dpi"], 1800)
1339+
1340+
def test_emits_dpi_from_device_with_clamped_value(self):
1341+
_ensure_qapp()
1342+
device = SimpleNamespace(dpi_min=200, dpi_max=4000)
1343+
engine = _FakeEngine(device_connected=True, connected_device=device)
1344+
backend = self._build(engine=engine)
1345+
seen = []
1346+
backend.dpiFromDevice.connect(seen.append)
1347+
backend._handleDpiRead(9999)
1348+
QCoreApplication.processEvents()
1349+
self.assertEqual(seen, [4000])
1350+
12761351
if __name__ == "__main__":
12771352
unittest.main()

ui/backend.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1640,10 +1640,31 @@ def _handleProfileSwitch(self, profile_name):
16401640

16411641
@Slot(int)
16421642
def _handleDpiRead(self, dpi):
1643-
"""Runs on Qt main thread."""
1644-
self._cfg.setdefault("settings", {})["dpi"] = dpi
1643+
"""Runs on Qt main thread.
1644+
1645+
A device-reported DPI is authoritative for "what the hardware is
1646+
currently set to" -- the user expects Mouser to keep showing the
1647+
same value across restarts rather than reverting to a stale
1648+
preference whenever the engine reads the device. Clamp the
1649+
incoming value, persist it, and keep the engine's cached config
1650+
in sync so subsequent reads do not loop through a stale picture.
1651+
1652+
Skip the engine push (``set_dpi``) here: this handler is reacting
1653+
to a value the device already reports, so echoing it back would
1654+
be a redundant HID round-trip.
1655+
"""
1656+
device = self._resolved_connected_device()
1657+
clamped = clamp_dpi(dpi, device)
1658+
settings = self._cfg.setdefault("settings", {})
1659+
if settings.get("dpi") == clamped:
1660+
self.dpiFromDevice.emit(clamped)
1661+
return
1662+
settings["dpi"] = clamped
1663+
save_config(self._cfg)
1664+
if self._engine:
1665+
self._engine.cfg = self._cfg
16451666
self.settingsChanged.emit()
1646-
self.dpiFromDevice.emit(dpi)
1667+
self.dpiFromDevice.emit(clamped)
16471668

16481669
@Slot(bool)
16491670
def _handleConnectionChange(self, connected):

0 commit comments

Comments
 (0)