Add live mouse gesture mode

This commit is contained in:
Ali Raza 2026-08-11 15:50:39 +05:00
parent 8a941c5553
commit cda0553d26
6 changed files with 218 additions and 13 deletions

View File

@ -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

View File

@ -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.

View File

@ -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`.

View File

@ -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

View File

@ -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.")
)

View File

@ -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),
{