input-remapper/keymapper/dev/macros.py

261 lines
7.8 KiB
Python
Raw Normal View History

2020-11-27 20:27:15 +00:00
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# key-mapper - GUI for device specific keyboard mappings
# Copyright (C) 2020 sezanzeb <proxima@hip70890b.de>
#
# This file is part of key-mapper.
#
# key-mapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# key-mapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
"""Executes more complex patterns of keystrokes.
To keep it short on the UI, the available functions are one-letter long.
The global functions actually perform the stuff and always return macro
instances that can then be further chained.
the outermost macro (in the examples below the one created by 'r',
'r' and 'w') will be started, which triggers a chain reaction to execute
all of the configured stuff.
Examples
--------
r(3, k('a').w(10)): 'a' <10ms> 'a' <10ms> 'a'
2020-11-27 20:31:58 +00:00
r(2, k('a').k('-')).k('b'): 'a' '-' 'a' '-' 'b'
2020-11-27 20:27:15 +00:00
w(1000).m('SHIFT_L', r(2, k('a'))).w(10).k('b'): <1s> 'A' 'A' <10ms> 'b'
"""
import time
2020-11-28 16:49:32 +00:00
import re
2020-11-27 20:27:15 +00:00
import random
2020-11-28 16:49:32 +00:00
from keymapper.logger import logger
2020-11-27 20:27:15 +00:00
class _Macro:
2020-11-27 20:27:15 +00:00
"""Supports chaining and preparing actions."""
2020-11-28 17:27:28 +00:00
def __init__(self, handler):
"""Create a macro instance that can be populated with tasks.
Parameters
----------
handler : func
A function that accepts keycodes as the first parameter and the
key-press state as the second. 1 for down and 0 for up. The
macro will write to this function once executed with `.run()`.
"""
2020-11-27 20:27:15 +00:00
self.tasks = []
2020-11-28 17:27:28 +00:00
self.handler = handler
2020-11-27 20:27:15 +00:00
def run(self):
2020-11-28 14:43:24 +00:00
"""Run the macro."""
2020-11-27 20:27:15 +00:00
for task in self.tasks:
2020-11-28 14:43:24 +00:00
# TODO async, don't block the rest of the application
2020-11-27 20:27:15 +00:00
task()
2020-11-28 14:43:24 +00:00
def stop(self):
"""Stop the macro."""
# TODO
2020-11-28 16:49:32 +00:00
def modify(self, modifier, macro):
2020-11-27 20:27:15 +00:00
"""Do stuff while a modifier is activated.
Parameters
----------
modifier : str
macro : _Macro
2020-11-27 20:27:15 +00:00
"""
2020-11-28 17:27:28 +00:00
self.tasks.append(lambda: self.handler(modifier, 1))
2020-11-27 20:27:15 +00:00
self.tasks.append(macro.run)
2020-11-28 17:27:28 +00:00
self.tasks.append(lambda: self.handler(modifier, 0))
2020-11-27 20:27:15 +00:00
return self
2020-11-28 16:49:32 +00:00
def repeat(self, repeats, macro):
2020-11-27 20:27:15 +00:00
"""Repeat actions.
Parameters
----------
repeats : int
macro : _Macro
2020-11-27 20:27:15 +00:00
"""
for _ in range(repeats):
self.tasks.append(macro.run)
return self
2020-11-28 16:49:32 +00:00
def keycode(self, character):
2020-11-28 14:43:24 +00:00
"""Write the character."""
2020-11-28 17:27:28 +00:00
self.tasks.append(lambda: self.handler(character, 1))
self.tasks.append(lambda: self.handler(character, 0))
2020-11-27 20:27:15 +00:00
return self
2020-11-28 16:49:32 +00:00
def wait(self, min, max=None):
2020-11-27 20:27:15 +00:00
"""Wait a random time in milliseconds"""
# TODO random
self.tasks.append(lambda: time.sleep(min / 1000))
return self
def _extract_params(inner):
2020-11-28 16:49:32 +00:00
"""Extract parameters from the inner contents of a call.
Parameters
----------
inner : string
for example 'r, r(2, k(a))' should result in ['r', 'r(2, k(a)']
"""
inner = inner.strip()
brackets = 0
params = []
start = 0
for position, char in enumerate(inner):
if char == '(':
brackets += 1
if char == ')':
brackets -= 1
if (char == ',') and brackets == 0:
# , potentially starts another parameter, but only if
# the current brackets are all closed.
params.append(inner[start:position].strip())
# skip the comma
start = position + 1
if brackets == 0 and start != len(inner):
# one last parameter
params.append(inner[start:].strip())
return params
def _parse_recurse(macro, handler, macro_instance=None, depth=0):
2020-11-28 16:49:32 +00:00
"""Handle a subset of the macro, e.g. one parameter or function call.
Parameters
----------
macro : string
Just like parse
2020-11-28 17:27:28 +00:00
handler : function
passed to _Macro constructors
macro_instance : _Macro or None
2020-11-28 16:49:32 +00:00
A macro instance to add tasks to
depth : int
For logging and debugging purposes
"""
# to anyone who knows better about compilers and thinks this is horrible:
# please make a pull request. Because it probably is.
# not using eval for security reasons ofc. And this syntax doesn't need
# string quotes for its params.
2020-11-28 17:27:28 +00:00
# If this gets more complicated than that I'd rather make a macro
# editor GUI and store them as json.
assert isinstance(macro, str)
assert callable(handler)
assert isinstance(depth, int)
2020-11-28 16:49:32 +00:00
if macro_instance is None:
macro_instance = _Macro(handler)
2020-11-28 17:27:28 +00:00
else:
assert isinstance(macro_instance, _Macro)
2020-11-28 16:49:32 +00:00
macro = macro.strip()
logger.spam('%sinput %s', ' ' * depth, macro)
space = ' ' * depth
# is it another macro?
call_match = re.match(r'^(\w+)\(.+?', macro)
call = call_match[1] if call_match else None
if call is not None:
# available functions in the macro
functions = {
'm': macro_instance.modify,
'r': macro_instance.repeat,
'k': macro_instance.keycode,
'w': macro_instance.wait
}
if functions.get(call) is None:
logger.error(f'Unknown function %s', call)
# get all the stuff inbetween
brackets = 0
position = 0
for char in macro:
position += 1
if char == '(':
brackets += 1
continue
if char == ')':
brackets -= 1
if brackets < 0:
logger.error(f'There is one ")" too much at %s', position)
return
if brackets == 0:
# the closing bracket of the call
break
if brackets != 0:
logger.error(f'There are %s closing brackets missing', brackets)
inner = macro[2:position - 1]
# split "3, k(a).w(10)" into parameters
string_params = _extract_params(inner)
2020-11-28 16:49:32 +00:00
logger.spam('%scalls %s with %s', space, call, string_params)
# evaluate the params
params = [
_parse_recurse(param.strip(), handler, None, depth + 1)
2020-11-28 16:49:32 +00:00
for param in string_params
]
logger.spam('%scalling %s with %s', space, call, params)
functions[call](*params)
# is after this another call? Chain it to the macro_instance
if len(macro) > position and macro[position] == '.':
chain = macro[position + 1:]
logger.spam('%sfollowed by %s', space, chain)
_parse_recurse(chain, handler, macro_instance, depth)
2020-11-28 16:49:32 +00:00
return macro_instance
else:
# probably a parameter for an outer function
try:
macro = int(macro)
except ValueError:
pass
return macro
def parse(macro, handler):
"""parse and generate a _Macro that can be run as often as you want.
Parameters
----------
macro : string
"r(3, k(a).w(10))"
"r(2, k(a).k(-)).k(b)"
"w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)"
handler : func
A function that accepts keycodes as the first parameter and the
key-press state as the second. 1 for down and 0 for up. The
macro will write to this function once executed with `.run()`.
"""
try:
return _parse_recurse(macro, handler)
except Exception as e:
logger.error('Failed to parse macro "%s": %s', macro, e)
return None