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 <apeiron@none>
Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
This commit is contained in:
ApeironTsuka 2021-07-04 07:52:38 -05:00 committed by GitHub
parent 8854ca6f23
commit 011f3f556b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 206 additions and 21 deletions

View File

@ -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. 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 `lowres_wheel_up`, `lowres_wheel_down`, `hires_wheel_up`, `hires_wheel_down` are the
same but for `LOWRES WHEEL` and `HIRES WHEEL`. 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. `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. 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. 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. A `MouseScroll` action takes a sequence of two numbers and simulates a horizontal and vertical mouse scroll of these amounts.

View File

@ -24,6 +24,7 @@ import sys as _sys
from logging import DEBUG as _DEBUG from logging import DEBUG as _DEBUG
from logging import INFO as _INFO from logging import INFO as _INFO
from logging import getLogger from logging import getLogger
from math import sqrt as _sqrt
import _thread import _thread
import psutil import psutil
@ -159,18 +160,31 @@ def signed(bytes):
return int.from_bytes(bytes, 'big', signed=True) return int.from_bytes(bytes, 'big', signed=True)
def xy_direction(d): def xy_direction(_x, _y):
x, y = _unpack('!2h', d[:4]) # normalize x and y
if x > 0 and x >= abs(y): m = _sqrt((_x * _x) + (_y * _y))
return 'right' if m == 0:
elif x < 0 and abs(x) >= abs(y): return 'noop'
return 'left' 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: elif y > 0:
return 'down' return 'Mouse Down'
elif y < 0: elif y < 0:
return 'up' return 'Mouse Up'
else: else:
return None return 'noop'
TESTS = { 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]), '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_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]), '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, 'False': lambda f, r, d: False,
'True': lambda f, r, d: True, 'True': lambda f, r, d: True,
} }
@ -445,6 +454,52 @@ class Test(Condition):
return {'Test': str(self.test)} 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): class Action(RuleComponent):
def __init__(self, *args): def __init__(self, *args):
pass pass
@ -622,6 +677,7 @@ COMPONENTS = {
'Modifiers': Modifiers, 'Modifiers': Modifiers,
'Key': Key, 'Key': Key,
'Test': Test, 'Test': Test,
'MouseGesture': MouseGesture,
'KeyPress': KeyPress, 'KeyPress': KeyPress,
'MouseScroll': MouseScroll, 'MouseScroll': MouseScroll,
'MouseClick': MouseClick, 'MouseClick': MouseClick,

View File

@ -24,6 +24,7 @@ import math
from copy import copy as _copy from copy import copy as _copy
from logging import DEBUG as _DEBUG from logging import DEBUG as _DEBUG
from logging import getLogger from logging import getLogger
from time import time_ns as _time_ns
from . import hidpp20 as _hidpp20 from . import hidpp20 as _hidpp20
from . import special_keys as _special_keys from . import special_keys as _special_keys
@ -1051,6 +1052,9 @@ class DivertedMouseMovement(object):
self.dy = 0. self.dy = 0.
self.fsmState = 'idle' self.fsmState = 'idle'
self.dpiSetting = next(filter(lambda s: s.name == dpi_name, device.settings), None) self.dpiSetting = next(filter(lambda s: s.name == dpi_name, device.settings), None)
self.data = [0]
self.lastEv = 0.
self.skip = False
@staticmethod @staticmethod
def notification_handler(device, n): def notification_handler(device, n):
@ -1065,14 +1069,31 @@ class DivertedMouseMovement(object):
dx, dy = _unpack('!hh', n.data[:4]) dx, dy = _unpack('!hh', n.data[:4])
state.handle_move_event(dx, dy) 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): def handle_move_event(self, dx, dy):
# This multiplier yields a more-or-less DPI-independent dx of about 5/cm # This multiplier yields a more-or-less DPI-independent dx of about 5/cm
# The multiplier could be configurable to allow adjusting dx # The multiplier could be configurable to allow adjusting dx
now = _time_ns() / 1e6
dpi = self.dpiSetting.read() if self.dpiSetting else 1000 dpi = self.dpiSetting.read() if self.dpiSetting else 1000
dx = float(dx) / float(dpi) * 15. dx = float(dx) / float(dpi) * 15.
self.dx += dx self.dx += dx
dy = float(dy) / float(dpi) * 15. dy = float(dy) / float(dpi) * 15.
self.dy += dy 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 self.fsmState == 'pressed':
if abs(self.dx) >= 1. or abs(self.dy) >= 1.: if abs(self.dx) >= 1. or abs(self.dy) >= 1.:
self.fsmState = 'moved' self.fsmState = 'moved'
@ -1083,18 +1104,30 @@ class DivertedMouseMovement(object):
self.fsmState = 'pressed' self.fsmState = 'pressed'
self.dx = 0. self.dx = 0.
self.dy = 0. self.dy = 0.
self.lastEv = _time_ns() / 1e6
self.skip = True
elif self.fsmState == 'pressed' or self.fsmState == 'moved': elif self.fsmState == 'pressed' or self.fsmState == 'moved':
if self.key not in cids: if self.key not in cids:
# emit mouse gesture notification # emit mouse gesture notification
from .base import _HIDPP_Notification as _HIDPP_Notification from .base import _HIDPP_Notification as _HIDPP_Notification
from .common import pack as _pack from .common import pack as _pack
from .diversion import process_notification as _process_notification 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) notification = _HIDPP_Notification(0, 0, 0, 0, payload)
_process_notification(self.device, self.device.status, notification, _hidpp20.FEATURE.MOUSE_GESTURE) _process_notification(self.device, self.device.status, notification, _hidpp20.FEATURE.MOUSE_GESTURE)
self.data.clear()
self.data.append(0)
self.fsmState = 'idle' self.fsmState = 'idle'
self.dx = 0. else:
self.dy = 0. 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 = [ MouseGestureKeys = [
@ -1120,7 +1153,9 @@ class DivertedMouseMovementRW(object):
state = device._divertedMMState state = device._divertedMMState
if n.address == 0x00: if n.address == 0x00:
cid1, cid2, cid3, cid4 = _unpack('!HHHH', n.data[:8]) 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: elif n.address == 0x10:
dx, dy = _unpack('!hh', n.data[:4]) dx, dy = _unpack('!hh', n.data[:4])
state.handle_move_event(dx, dy) state.handle_move_event(dx, dy)

View File

@ -511,6 +511,7 @@ class DiversionDialog:
(_('Modifiers'), _DIV.Modifiers, []), (_('Modifiers'), _DIV.Modifiers, []),
(_('Key'), _DIV.Key, ''), (_('Key'), _DIV.Key, ''),
(_('Test'), _DIV.Test, next(iter(_DIV.TESTS))), (_('Test'), _DIV.Test, next(iter(_DIV.TESTS))),
(_('Mouse Gesture'), _DIV.MouseGesture, ''),
] ]
], ],
[ [
@ -1073,6 +1074,94 @@ class TestUI(ConditionUI):
return str(component.test) 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 ActionUI(RuleComponentUI):
CLASS = _DIV.Action CLASS = _DIV.Action
@ -1337,6 +1426,7 @@ COMPONENT_UI = {
_DIV.Modifiers: ModifiersUI, _DIV.Modifiers: ModifiersUI,
_DIV.Key: KeyUI, _DIV.Key: KeyUI,
_DIV.Test: TestUI, _DIV.Test: TestUI,
_DIV.MouseGesture: MouseGestureUI,
_DIV.KeyPress: KeyPressUI, _DIV.KeyPress: KeyPressUI,
_DIV.MouseScroll: MouseScrollUI, _DIV.MouseScroll: MouseScrollUI,
_DIV.MouseClick: MouseClickUI, _DIV.MouseClick: MouseClickUI,