From 011f3f556bd3793921fc3297ca447439c75ce8f6 Mon Sep 17 00:00:00 2001 From: ApeironTsuka Date: Sun, 4 Jul 2021 07:52:38 -0500 Subject: [PATCH] rules: allow sequence of mouse moves as mouse gestures * Add more robust mouse gesture support - Remove existing mouse-* Test types - Add new 'Mouse Gesture' Condition - Implement Rule Editor UI for it - Add support for diverted buttons - Added diagonal mouse gesture directions Allows you to chain multiple movements/buttons (for instance, moving the mouse up and then left) together into a single mappable gesture. * Update docs * Cleanup Fix inconsistent indenting Fix possible overwriting of built-in Fix 'Mouse Gesture' Condition rule not starting with an initial Action field * Make flake8 happy * yapf * Document no-op and make it more apparent * Make changes to Mouse Gesture UI suggested/submitted by viniciusbm. Co-authored-by: Apeiron Co-authored-by: Peter F. Patel-Schneider --- docs/rules.md | 10 +++- lib/logitech_receiver/diversion.py | 84 +++++++++++++++++++++++----- lib/logitech_receiver/settings.py | 43 ++++++++++++-- lib/solaar/ui/diversion_rules.py | 90 ++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 21 deletions(-) diff --git a/docs/rules.md b/docs/rules.md index 269226fd..6f67f4d8 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -89,11 +89,15 @@ A `thumb_wheel_up` test is the rotation amount of a `THUMB WHEEL` upward rotatio A `thumb_wheel_down` test is the rotation amount of a `THUMB WHEEL` downward rotation. `lowres_wheel_up`, `lowres_wheel_down`, `hires_wheel_up`, `hires_wheel_down` are the same but for `LOWRES WHEEL` and `HIRES WHEEL`. -A 'mouse-down' test is true for a mouse gesture mostly in the downward direction. -`mouse-up', 'mouse-left', and 'mouse-right' are the same but for gestures in the other directions. -A 'mouse-noop' test is true for a mouse gesture where the mouse doesn't move much. `True` and `False` tests return True and False, respectively. +`Mouse Gesture` conditions are true if the actions taken while the mouse's 'Gesture' button is held match the configured list when the 'Gesture' button is released. +The available actions are `Mouse Up`, `Mouse Down`, `Mouse Left`, `Mouse Right`, `Mouse Up-left`, `Mouse Up-Right`, `Mouse Down-left`, `Mouse Down-right`, and buttons that are diverted. +An example would be mapping `Mouse Up` -> `Mouse Up`. To perform this gesture, you would hold down the 'Gesture' button, move the mouse upwards, pause momentarily, move the mouse upwards, and release the 'Gesture' button. +Another example would be mapping `Back Button` -> `Back Button`. With this one, you would hold down the 'Gesture' button, double-tap the 'Back' button, and then release the 'Gesture' button. +Mouse movements 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 the 'Gesture' button. + A `KeyPress` action takes a sequence of X11 key symbols and simulates a chorded keypress on the keyboard. Any key symbols that correspond to modifier keys that are in the current keyboard modifiers are ignored. A `MouseScroll` action takes a sequence of two numbers and simulates a horizontal and vertical mouse scroll of these amounts. diff --git a/lib/logitech_receiver/diversion.py b/lib/logitech_receiver/diversion.py index 28d07999..475119b1 100644 --- a/lib/logitech_receiver/diversion.py +++ b/lib/logitech_receiver/diversion.py @@ -24,6 +24,7 @@ import sys as _sys from logging import DEBUG as _DEBUG from logging import INFO as _INFO from logging import getLogger +from math import sqrt as _sqrt import _thread import psutil @@ -159,18 +160,31 @@ def signed(bytes): return int.from_bytes(bytes, 'big', signed=True) -def xy_direction(d): - x, y = _unpack('!2h', d[:4]) - if x > 0 and x >= abs(y): - return 'right' - elif x < 0 and abs(x) >= abs(y): - return 'left' +def xy_direction(_x, _y): + # normalize x and y + m = _sqrt((_x * _x) + (_y * _y)) + if m == 0: + return 'noop' + x = round(_x / m) + y = round(_y / m) + if x < 0 and y < 0: + return 'Mouse Up-left' + elif x > 0 and y < 0: + return 'Mouse Up-right' + elif x < 0 and y > 0: + return 'Mouse Down-left' + elif x > 0 and y > 0: + return 'Mouse Down-right' + elif x > 0: + return 'Mouse Right' + elif x < 0: + return 'Mouse Left' elif y > 0: - return 'down' + return 'Mouse Down' elif y < 0: - return 'up' + return 'Mouse Up' else: - return None + return 'noop' TESTS = { @@ -188,11 +202,6 @@ TESTS = { 'lowres_wheel_down': lambda f, r, d: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]), 'hires_wheel_up': lambda f, r, d: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]), 'hires_wheel_down': lambda f, r, d: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]), - 'mouse-down': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'down', - 'mouse-up': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'up', - 'mouse-left': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'left', - 'mouse-right': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) == 'right', - 'mouse-noop': lambda f, r, d: f == _F.MOUSE_GESTURE and xy_direction(d) is None, 'False': lambda f, r, d: False, 'True': lambda f, r, d: True, } @@ -445,6 +454,52 @@ class Test(Condition): return {'Test': str(self.test)} +class MouseGesture(Condition): + MOVEMENTS = [ + 'Mouse Up', 'Mouse Down', 'Mouse Left', 'Mouse Right', 'Mouse Up-left', 'Mouse Up-right', 'Mouse Down-left', + 'Mouse Down-right' + ] + + def __init__(self, movements): + if isinstance(movements, str): + movements = [movements] + for x in movements: + if x not in self.MOVEMENTS and x not in _CONTROL: + _log.warn('rule Key argument not name of a Logitech key: %s', x) + self.movements = movements + + def __str__(self): + return 'MouseGesture: ' + ' '.join(self.movements) + + def evaluate(self, feature, notification, device, status, last_result): + if feature == _F.MOUSE_GESTURE: + d = notification.data + count = _unpack('!h', d[:2])[0] + data = _unpack('!' + ((int(len(d) / 2) - 1) * 'h'), d[2:]) + if count != len(self.movements): + return False + x = 0 + z = 0 + while x < len(data): + if data[x] == 0: + direction = xy_direction(data[x + 1], data[x + 2]) + if self.movements[z] != direction: + return False + x += 3 + elif data[x] == 1: + if data[x + 1] not in _CONTROL: + return False + if self.movements[z] != str(_CONTROL[data[x + 1]]): + return False + x += 2 + z += 1 + return True + return False + + def data(self): + return {'MouseGesture': [str(m) for m in self.movements]} + + class Action(RuleComponent): def __init__(self, *args): pass @@ -622,6 +677,7 @@ COMPONENTS = { 'Modifiers': Modifiers, 'Key': Key, 'Test': Test, + 'MouseGesture': MouseGesture, 'KeyPress': KeyPress, 'MouseScroll': MouseScroll, 'MouseClick': MouseClick, diff --git a/lib/logitech_receiver/settings.py b/lib/logitech_receiver/settings.py index 9e15df7f..0b60da08 100644 --- a/lib/logitech_receiver/settings.py +++ b/lib/logitech_receiver/settings.py @@ -24,6 +24,7 @@ import math from copy import copy as _copy from logging import DEBUG as _DEBUG from logging import getLogger +from time import time_ns as _time_ns from . import hidpp20 as _hidpp20 from . import special_keys as _special_keys @@ -1051,6 +1052,9 @@ class DivertedMouseMovement(object): self.dy = 0. self.fsmState = 'idle' self.dpiSetting = next(filter(lambda s: s.name == dpi_name, device.settings), None) + self.data = [0] + self.lastEv = 0. + self.skip = False @staticmethod def notification_handler(device, n): @@ -1065,14 +1069,31 @@ class DivertedMouseMovement(object): dx, dy = _unpack('!hh', n.data[:4]) state.handle_move_event(dx, dy) + def push_mouse_event(self): + x = int(self.dx) + y = int(self.dy) + if x == 0 and y == 0: + return + self.data.append(0) + self.data.append(x) + self.data.append(y) + self.data[0] += 1 + self.dx = 0. + self.dy = 0. + def handle_move_event(self, dx, dy): # This multiplier yields a more-or-less DPI-independent dx of about 5/cm # The multiplier could be configurable to allow adjusting dx + now = _time_ns() / 1e6 dpi = self.dpiSetting.read() if self.dpiSetting else 1000 dx = float(dx) / float(dpi) * 15. self.dx += dx dy = float(dy) / float(dpi) * 15. self.dy += dy + if now - self.lastEv > 50. and not self.skip: + self.push_mouse_event() + self.lastEv = now + self.skip = False if self.fsmState == 'pressed': if abs(self.dx) >= 1. or abs(self.dy) >= 1.: self.fsmState = 'moved' @@ -1083,18 +1104,30 @@ class DivertedMouseMovement(object): self.fsmState = 'pressed' self.dx = 0. self.dy = 0. + self.lastEv = _time_ns() / 1e6 + self.skip = True elif self.fsmState == 'pressed' or self.fsmState == 'moved': if self.key not in cids: # emit mouse gesture notification from .base import _HIDPP_Notification as _HIDPP_Notification from .common import pack as _pack from .diversion import process_notification as _process_notification - payload = _pack('!hh', int(self.dx), int(self.dy)) + self.push_mouse_event() + payload = _pack('!' + (len(self.data) * 'h'), *self.data) notification = _HIDPP_Notification(0, 0, 0, 0, payload) _process_notification(self.device, self.device.status, notification, _hidpp20.FEATURE.MOUSE_GESTURE) + self.data.clear() + self.data.append(0) self.fsmState = 'idle' - self.dx = 0. - self.dy = 0. + else: + last = (cids - {self.key, 0}) + if len(last) != 0: + self.push_mouse_event() + self.data.append(1) + self.data.append(list(last)[0]) + self.data[0] += 1 + self.lastEv = _time_ns() / 1e6 + return True MouseGestureKeys = [ @@ -1120,7 +1153,9 @@ class DivertedMouseMovementRW(object): state = device._divertedMMState if n.address == 0x00: cid1, cid2, cid3, cid4 = _unpack('!HHHH', n.data[:8]) - state.handle_keys_event({cid1, cid2, cid3, cid4}) + x = state.handle_keys_event({cid1, cid2, cid3, cid4}) + if x: + return True elif n.address == 0x10: dx, dy = _unpack('!hh', n.data[:4]) state.handle_move_event(dx, dy) diff --git a/lib/solaar/ui/diversion_rules.py b/lib/solaar/ui/diversion_rules.py index 01ec5d26..b84bce9d 100644 --- a/lib/solaar/ui/diversion_rules.py +++ b/lib/solaar/ui/diversion_rules.py @@ -511,6 +511,7 @@ class DiversionDialog: (_('Modifiers'), _DIV.Modifiers, []), (_('Key'), _DIV.Key, ''), (_('Test'), _DIV.Test, next(iter(_DIV.TESTS))), + (_('Mouse Gesture'), _DIV.MouseGesture, ''), ] ], [ @@ -1073,6 +1074,94 @@ class TestUI(ConditionUI): return str(component.test) +class MouseGestureUI(ConditionUI): + + CLASS = _DIV.MouseGesture + MOUSE_GESTURE_NAMES = [ + 'Mouse Up', 'Mouse Down', 'Mouse Left', 'Mouse Right', 'Mouse Up-left', 'Mouse Up-right', 'Mouse Down-left', + 'Mouse Down-right' + ] + MOVE_NAMES = list(map(str, _CONTROL)) + MOUSE_GESTURE_NAMES + + def create_widgets(self): + self.widgets = {} + self.fields = [] + self.del_btns = [] + self.add_btn = Gtk.Button(_('Add action'), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True, vexpand=True) + self.add_btn.connect('clicked', self._clicked_add) + self.widgets[self.add_btn] = (1, 0, 1, 1) + + def _create_field(self): + field = Gtk.ComboBoxText.new_with_entry() + for g in self.MOUSE_GESTURE_NAMES: + field.append(g, g) + CompletionEntry.add_completion_to_entry(field.get_child(), self.MOVE_NAMES) + field.connect('changed', self._on_update) + self.fields.append(field) + self.widgets[field] = (len(self.fields) - 1, 0, 1, 1) + return field + + def _create_del_btn(self): + btn = Gtk.Button(_('Delete'), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True, vexpand=True) + self.del_btns.append(btn) + self.widgets[btn] = (len(self.del_btns) - 1, 1, 1, 1) + btn.connect('clicked', self._clicked_del, len(self.del_btns) - 1) + return btn + + def _clicked_add(self, _btn): + self.component.__init__(self.collect_value() + ['']) + self.show(self.component) + self.fields[len(self.component.movements) - 1].grab_focus() + + def _clicked_del(self, _btn, pos): + v = self.collect_value() + v.pop(pos) + self.component.__init__(v) + self.show(self.component) + self._on_update_callback() + + def _on_update(self, *args): + super()._on_update(*args) + for i, f in enumerate(self.fields): + if f.get_visible(): + icon = 'dialog-warning' if i < len(self.component.movements + ) and self.component.movements[i] not in self.MOVE_NAMES else '' + f.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + + def show(self, component): + n = len(component.movements) + while len(self.fields) < n: + self._create_field() + self._create_del_btn() + self.widgets[self.add_btn] = (n + 1, 0, 1, 1) + super().show(component) + for i in range(n): + field = self.fields[i] + with self.ignore_changes(): + field.get_child().set_text(component.movements[i]) + field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0) + field.show_all() + self.del_btns[i].show() + for i in range(n, len(self.fields)): + self.fields[i].hide() + self.del_btns[i].hide() + self.add_btn.set_valign(Gtk.Align.END if n >= 1 else Gtk.Align.CENTER) + + def collect_value(self): + return [f.get_active_text().strip() for f in self.fields if f.get_visible()] + + @classmethod + def left_label(cls, component): + return _('Mouse Gesture') + + @classmethod + def right_label(cls, component): + if len(component.movements) == 0: + return 'No-op' + else: + return ' -> '.join(component.movements) + + class ActionUI(RuleComponentUI): CLASS = _DIV.Action @@ -1337,6 +1426,7 @@ COMPONENT_UI = { _DIV.Modifiers: ModifiersUI, _DIV.Key: KeyUI, _DIV.Test: TestUI, + _DIV.MouseGesture: MouseGestureUI, _DIV.KeyPress: KeyPressUI, _DIV.MouseScroll: MouseScrollUI, _DIV.MouseClick: MouseClickUI,