diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5a8760..a6ceb175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 1.1.20 +* Add live mouse gestures that trigger rules while their button is held * Add Irish translation * Don't use old settings when reusing sboxes * Add POUND and ISO_BACKSLASH cells to MAIN_ISO key layout diff --git a/docs/capabilities.md b/docs/capabilities.md index f61d42a9..cf297b96 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -178,6 +178,12 @@ is sent to the Solaar rule system so that rules can detect these notifications. For more information on Mouse Gestures rule conditions see [the rules page](https://pwr-solaar.github.io/Solaar/rules). +Setting a button to `Live Mouse Gestures` instead emits a separate `MOUSE_GESTURE` +notification as soon as the mouse crosses a movement threshold. Continuing to move +the mouse emits more notifications, with a short delay between them. Diverted key +presses are also emitted immediately. Releasing the initiating button stops tracking +and discards any movement that has not crossed the threshold. + ### Keyboard Key Names and Locations Solaar uses the standard Logitech names for keyboard keys. Some Logitech keyboards have different icons on some of their keys and have different functionality than suggested by these names. diff --git a/docs/rules.md b/docs/rules.md index da19895d..5a18ee82 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -115,6 +115,12 @@ The condition `Smart Shift` -> `Mouse Down` -> `Back Button` would match pressin Directions and buttons can be mixed and chained together however you like. It's possible to create a `No-op` gesture by clicking 'Delete' on the initial Action when you first create the rule. This gesture will trigger when you simply click a Mouse Gestures button. +Buttons set to `Live Mouse Gestures` emit each direction or diverted key press as a +separate notification while the initiating button is still held. Rules for live +gestures should therefore contain a single direction or key event. Continued mouse +movement can trigger the same rule repeatedly; releasing the initiating button stops +the live gesture without emitting an additional event. + ### Key modifiers `Modifiers` conditions take either a string or a sequence of strings, which can only be `Shift`, `Control`, `Alt`, and `Super`. diff --git a/docs/usage.md b/docs/usage.md index 11c3d3d3..fdf9aacb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -165,6 +165,7 @@ Backlight | Turn on backlight Swap Fx function | Change Fn keys to normally do their special action DPI Sliding Adjustment | Change Sensitivity (DPI) by holding a button and moving the mouse Mouse Gestures | Create HID++ events by holding a button and moving the mouse +Live Mouse Gestures | Create HID++ events while holding a button and moving the mouse Key/Button Actions | Change what a key or button does Key/Button Diversion | Divert keys and buttons to create HID++ events Divert crown events | Divert crown actions to create HID++ events diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py index a3891b35..ba8ae0de 100644 --- a/lib/logitech_receiver/settings_templates.py +++ b/lib/logitech_receiver/settings_templates.py @@ -17,10 +17,12 @@ from __future__ import annotations import enum import logging +import math import socket import struct import traceback +from time import monotonic from time import sleep from time import time from typing import Callable @@ -59,6 +61,9 @@ logger = logging.getLogger(__name__) _hidpp20 = hidpp20.Hidpp20() _F = hidpp20_constants.SupportedFeature +LIVE_GESTURE_MOVEMENT_THRESHOLD = 2.5 +LIVE_GESTURE_REPEAT_DELAY = 0.35 + def halving_marks(max_value, step_count): """Halving series from max_value down by powers of two, plus 0. @@ -214,8 +219,7 @@ class RegisterSideScroll(settings.Setting): name = "side-scroll" label = _("Side Scrolling") description = _( - "When disabled, pushing the wheel sideways sends custom button events\n" - "instead of the standard side-scrolling events." + "When disabled, pushing the wheel sideways sends custom button events\ninstead of the standard side-scrolling events." ) register = Registers.MOUSE_BUTTON_FLAGS validator_options = {"true_value": 0x02, "mask": 0x02} @@ -699,8 +703,7 @@ class SmartShift(settings.Setting): name = "smart-shift" label = _("Scroll Wheel Ratchet Speed") description = _( - "Use the mouse wheel speed to switch between ratcheted and freespinning.\n" - "The mouse wheel is always ratcheted at 50." + "Use the mouse wheel speed to switch between ratcheted and freespinning.\nThe mouse wheel is always ratcheted at 50." ) feature = _F.SMART_SHIFT rw_options = {"read_fnid": 0x00, "write_fnid": 0x10} @@ -964,14 +967,106 @@ class MouseGesturesXY(settings.RawXYProcessing): logger.debug("mouse gesture move event %d %d %s", x, y, self.data) +class LiveMouseGesturesXY(settings.RawXYProcessing): + """Emit mouse gesture notifications while the initiating button is held.""" + + def activate_action(self): + self.dpiSetting = next(filter(lambda s: s.name == "dpi" or s.name == "dpi_extended", self.device.settings), None) + self.fsmState = State.IDLE + self.starting = False + self.gesture_key = None + self.last_emit = float("-inf") + self.reset_motion() + + def deactivate_action(self): + self.fsmState = State.IDLE + self.starting = False + self.gesture_key = None + self.reset_motion() + + def reset_motion(self): + self.dx = 0.0 + self.dy = 0.0 + + def press_action(self, key): + if self.fsmState == State.IDLE: + self.fsmState = State.PRESSED + self.starting = True + self.gesture_key = int(key.key) + self.last_emit = float("-inf") + self.reset_motion() + + def release_action(self): + # Live events have already been emitted. Discard sub-threshold motion + # rather than generating a second event when the button is released. + self.fsmState = State.IDLE + self.starting = False + self.gesture_key = None + self.reset_motion() + + def emit_event(self, event): + if self.gesture_key is None: + return + data = [self.gesture_key, *event] + if logger.isEnabledFor(logging.INFO): + logger.info("live mouse gesture notification %s", data) + payload = struct.pack("!" + (len(data) * "h"), *data) + notification = base.HIDPPNotification(0, 0, 0, 0, payload) + diversion.process_notification(self.device, notification, _F.MOUSE_GESTURE) + + def move_action(self, dx, dy): + if self.fsmState != State.PRESSED: + return + + if (self.device.features.get_feature_version(_F.REPROG_CONTROLS_V4) or 0) >= 5 and self.starting: + self.starting = False # hack to ignore strange first movement report from MX Master 3S + return + self.starting = False + + dpi = self.dpiSetting.read() if self.dpiSetting else 1000 + dpi = dpi if isinstance(dpi, (int, float)) and dpi > 0 else 1000 + scale = 15.0 / float(dpi) # yields a more-or-less DPI-independent movement of about 5 units/cm + self.dx += float(dx) * scale + self.dy += float(dy) * scale + + distance = math.hypot(self.dx, self.dy) + if distance < LIVE_GESTURE_MOVEMENT_THRESHOLD: + return + + now = monotonic() + if now - self.last_emit < LIVE_GESTURE_REPEAT_DELAY: + # Preserve one threshold of pending movement without allowing a + # large movement to overflow the signed shorts in the notification. + factor = LIVE_GESTURE_MOVEMENT_THRESHOLD / distance + self.dx *= factor + self.dy *= factor + return + + move_x = int(self.dx) + move_y = int(self.dy) + self.last_emit = now + self.reset_motion() + self.emit_event([0, move_x, move_y]) + + def key_action(self, key): + self.emit_event([1, key]) + + class DivertKeys(settings.Settings): name = "divert-keys" label = _("Key/Button Diversion") - description = _("Make the key or button send HID++ notifications (Diverted) or initiate Mouse Gestures or Sliding DPI") + description = _( + "Make the key or button send HID++ notifications (Diverted) or initiate Mouse Gestures, " + "Live Mouse Gestures, or Sliding DPI" + ) feature = _F.REPROG_CONTROLS_V4 keys_universe = special_keys.CONTROL - choices_universe = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2, _("Sliding DPI"): 3}) - choices_gesture = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2}) + choices_universe = common.NamedInts( + **{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2, _("Sliding DPI"): 3, _("Live Mouse Gestures"): 4} + ) + choices_gesture = common.NamedInts( + **{_("Regular"): 0, _("Diverted"): 1, _("Mouse Gestures"): 2, _("Live Mouse Gestures"): 4} + ) choices_divert = common.NamedInts(**{_("Regular"): 0, _("Diverted"): 1}) class rw_class: @@ -997,17 +1092,21 @@ class DivertKeys(settings.Settings): def prepare_write(self, key, new_value): if self.gestures and new_value != 2: # mouse gestures self.gestures.stop(key) + if self.live_gestures and new_value != 4: # live mouse gestures + self.live_gestures.stop(key) if self.sliding and new_value != 3: # sliding DPI self.sliding.stop(key) if self.gestures and new_value == 2: # mouse gestures self.gestures.start(key) + if self.live_gestures and new_value == 4: # live mouse gestures + self.live_gestures.start(key) if self.sliding and new_value == 3: # sliding DPI self.sliding.start(key) return super().prepare_write(key, new_value) @classmethod def build(cls, setting_class, device): - sliding = gestures = None + sliding = gestures = live_gestures = None choices = {} if device.keys: for key in device.keys: @@ -1016,6 +1115,8 @@ class DivertKeys(settings.Settings): choices[key.key] = setting_class.choices_gesture if gestures is None: gestures = MouseGesturesXY(device, name="MouseGestures") + if live_gestures is None: + live_gestures = LiveMouseGesturesXY(device, name="LiveMouseGestures") if _F.ADJUSTABLE_DPI in device.features: choices[key.key] = setting_class.choices_universe if sliding is None: @@ -1029,6 +1130,7 @@ class DivertKeys(settings.Settings): validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01) validator.sliding = sliding validator.gestures = gestures + validator.live_gestures = live_gestures return validator @@ -3109,8 +3211,7 @@ def _logivoice_make_parameters_class(feature: hidpp20_constants.SupportedFeature "name": f"logivoice-{slug}-parameters", "label": f"LogiVoice {module_name}: Parameters (read-only)", "description": ( - f"Decoded {module_name} GetParameters fields. " - "Opaque raw values shown where the wire encoding isn't confirmed yet." + f"Decoded {module_name} GetParameters fields. Opaque raw values shown where the wire encoding isn't confirmed yet." ), "feature": feature, } @@ -3732,7 +3833,7 @@ class RgbShutdownAnimation(_RgbBootEffectSetting): name = "rgb_shutdown_animation" label = _("Shutdown Animation") description = ( - _("Firmware-played animation when the keyboard powers off.\n" "Setting persists on the device (non-volatile).") + _("Firmware-played animation when the keyboard powers off.\nSetting persists on the device (non-volatile).") + "\n" + _("Device default: Primary #FF0081, Secondary #80AAFF.") ) diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py index 01bb52f4..2abb2c0b 100644 --- a/tests/logitech_receiver/test_setting_templates.py +++ b/tests/logitech_receiver/test_setting_templates.py @@ -18,7 +18,10 @@ The device uses some methods from the real device to set up data structures that are needed for some tests. """ +import struct + from dataclasses import dataclass +from types import SimpleNamespace from typing import Any import pytest @@ -38,6 +41,74 @@ _PERKEY_COLOR_RANGE = settings_validator.Range(min=0, max=0xFFFFFF, byte_count=3 # TODO action part of DpiSlidingXY, MouseGesturesXY +def live_mouse_gesture_processor(mocker, feature_version=4): + dpi_setting = mocker.Mock(name="dpi_setting") + dpi_setting.name = "dpi" + dpi_setting.read.return_value = 1000 + + features = mocker.MagicMock() + features.__getitem__.return_value = 1 + features.get_feature_version.return_value = feature_version + + device = mocker.MagicMock() + device.features = features + device.settings = [dpi_setting] + + processor = settings_templates.LiveMouseGesturesXY(device, name="LiveMouseGestures") + processor.activate_action() + return processor + + +def test_live_mouse_gestures_emit_during_movement_and_stop_on_release(mocker): + processor = live_mouse_gesture_processor(mocker) + process_notification = mocker.patch.object(settings_templates.diversion, "process_notification") + mocker.patch.object(settings_templates, "monotonic", side_effect=[0.0, 0.1, 0.36]) + + processor.press_action(SimpleNamespace(key=0xC3)) + processor.move_action(200, 0) + + process_notification.assert_called_once() + first_notification = process_notification.call_args.args[1] + assert struct.unpack("!4h", first_notification.data) == (0xC3, 0, 3, 0) + + processor.move_action(200, 0) + process_notification.assert_called_once() # repeat event is held back by the cooldown + + processor.move_action(1, 0) + assert process_notification.call_count == 2 + + processor.release_action() + processor.move_action(-200, 0) + assert process_notification.call_count == 2 + + +def test_live_mouse_gestures_ignore_first_mx_master_3s_movement(mocker): + processor = live_mouse_gesture_processor(mocker, feature_version=5) + process_notification = mocker.patch.object(settings_templates.diversion, "process_notification") + mocker.patch.object(settings_templates, "monotonic", return_value=0.0) + + processor.press_action(SimpleNamespace(key=0xC3)) + processor.move_action(-200, 0) + process_notification.assert_not_called() + + processor.move_action(-200, 0) + process_notification.assert_called_once() + notification = process_notification.call_args.args[1] + assert struct.unpack("!4h", notification.data) == (0xC3, 0, -3, 0) + + +def test_live_mouse_gestures_emit_diverted_keys_immediately(mocker): + processor = live_mouse_gesture_processor(mocker) + process_notification = mocker.patch.object(settings_templates.diversion, "process_notification") + + processor.press_action(SimpleNamespace(key=0xC3)) + processor.key_action(0x53) + + process_notification.assert_called_once() + notification = process_notification.call_args.args[1] + assert struct.unpack("!3h", notification.data) == (0xC3, 1, 0x53) + + class Setup: def __init__(self, test, *params): self.test = test @@ -576,19 +647,38 @@ key_tests = [ ), Setup( FeatureTest(settings_templates.DivertKeys, {0xC4: 0}, {0xC4: 1}, 2, offset=0x05), - {common.NamedInt(0xC4, "Smart Shift"): common.NamedInts(Regular=0, Diverted=1, Mouse_Gestures=2)}, + { + common.NamedInt(0xC4, "Smart Shift"): common.NamedInts( + Regular=0, Diverted=1, Mouse_Gestures=2, Live_Mouse_Gestures=4 + ) + }, *responses_reprog_controls, fake_hidpp.Response("00C4020000", 0x0530, "00C4020000"), # Smart Shift write fake_hidpp.Response("00C4030000", 0x0530, "00C4030000"), # Smart Shift divert write ), Setup( FeatureTest(settings_templates.DivertKeys, {0xC4: 0}, {0xC4: 2}, 2, offset=0x05), - {common.NamedInt(0xC4, "Smart Shift"): common.NamedInts(Regular=0, Diverted=1, Mouse_Gestures=2, Sliding_DPI=3)}, + { + common.NamedInt(0xC4, "Smart Shift"): common.NamedInts( + Regular=0, Diverted=1, Mouse_Gestures=2, Sliding_DPI=3, Live_Mouse_Gestures=4 + ) + }, *responses_reprog_controls, fake_hidpp.Response("0A0001", 0x0000, "2201"), # ADJUSTABLE_DPI fake_hidpp.Response("00C4300000", 0x0530, "00C4300000"), # Smart Shift write fake_hidpp.Response("00C4030000", 0x0530, "00C4030000"), # Smart Shift divert write ), + Setup( + FeatureTest(settings_templates.DivertKeys, {0xC4: 0}, {0xC4: 4}, 2, offset=0x05), + { + common.NamedInt(0xC4, "Smart Shift"): common.NamedInts( + Regular=0, Diverted=1, Mouse_Gestures=2, Live_Mouse_Gestures=4 + ) + }, + *responses_reprog_controls, + fake_hidpp.Response("00C4300000", 0x0530, "00C4300000"), # Smart Shift write + fake_hidpp.Response("00C4030000", 0x0530, "00C4030000"), # Smart Shift divert write + ), Setup( FeatureTest(settings_templates.PersistentRemappableAction, {80: 16797696, 81: 16797696}, {0x51: 16797952}, 3), {