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

View File

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

View File

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

View File

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