macro parser

pull/14/head
sezanzeb 4 years ago
parent 7134083e5b
commit 8d23593c89

@ -29,9 +29,9 @@ key-mapper-service
## Macros
It is possible to write timed macros into the center column:
- `r(3, k("a").w(10))` aaa
- `r(2, k("a").k("-")).k("b")` a-a-b
- `w(1000).m("SHIFT_L", r(2, k("a"))).w(10, 20).k("b")` AAb
- `r(3, k(a).w(10))` aaa
- `r(2, k(a).k(-)).k(b)` a-a-b
- `w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)` AAb
Documentation:
- `r` repeats

@ -24,7 +24,6 @@
import sys
import atexit
import getpass
from argparse import ArgumentParser
import gi
@ -38,6 +37,13 @@ from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument(

@ -38,6 +38,13 @@ from keymapper.daemon import Daemon
from keymapper.dev.permissions import can_read_devices
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument(

@ -22,6 +22,7 @@
"""Keeps injecting keycodes in the background based on the mapping."""
import os
import re
import asyncio
import time
@ -225,8 +226,6 @@ class KeycodeInjector:
where to read keycodes from
keymapper_device : evdev.UInput
where to write keycodes to
mapping : Mapping
to figure out which keycodes to write
"""
logger.debug(
'Started injecting into %s, fd %s',
@ -250,6 +249,17 @@ class KeycodeInjector:
if character is None:
# unknown keycode, forward it
target_keycode = input_keycode
elif '(' in character:
# must be a macro. Only allow it if the injector is not
# running as root.
if os.geteuid() == 0:
logger.error(
'Cannot allow running macros as root to avoid '
'injecting arbitrary code'
)
continue
Macro()
else:
target_keycode = system_mapping.get_keycode(character)
if target_keycode is None:
@ -274,6 +284,14 @@ class KeycodeInjector:
)
keymapper_device.syn()
# this should only ever happen in tests to avoid blocking them
# forever, as soon as all events are consumed. In normal operation
# there is no end to the events.
logger.error(
'The injector for "%s" stopped early',
keymapper_device.device.path
)
@ensure_numlock
def stop_injecting(self):
"""Stop injecting keycodes."""

@ -39,27 +39,18 @@ w(1000).m('SHIFT_L', r(2, k('a'))).w(10).k('b'): <1s> 'A' 'A' <10ms> 'b'
import time
import re
import random
try:
from rich.traceback import install
install(show_locals=True)
except ImportError:
pass
# TODO parse securely
def m(*args):
return Macro().m(*args)
def r(*args):
return Macro().r(*args)
def k(*args):
return Macro().k(*args)
def w(*args):
return Macro().w(*args)
from keymapper.logger import logger
logger.setLevel(5)
class Macro:
"""Supports chaining and preparing actions."""
@ -76,7 +67,7 @@ class Macro:
"""Stop the macro."""
# TODO
def m(self, modifier, macro):
def modify(self, modifier, macro):
"""Do stuff while a modifier is activated.
Parameters
@ -89,7 +80,7 @@ class Macro:
# TODO release modifier
return self
def r(self, repeats, macro):
def repeat(self, repeats, macro):
"""Repeat actions.
Parameters
@ -101,30 +92,160 @@ class Macro:
self.tasks.append(macro.run)
return self
def k(self, character):
def keycode(self, character):
"""Write the character."""
# TODO write character
self.tasks.append(lambda: print(character))
return self
def w(self, min, max=None):
def wait(self, min, max=None):
"""Wait a random time in milliseconds"""
# TODO random
self.tasks.append(lambda: time.sleep(min / 1000))
return self
# TODO make these into tests
print()
r(3, k('a').w(200)).run()
print()
r(2, k('a').k('-')).k('b').run()
print()
w(400).m('SHIFT_L', r(2, k('a'))).w(10).k('b').run()
print()
# prints nothing yet
k('a').r(3, k('b'))
def parse(macro):
"""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)"
"""
try:
return parse_recurse(macro)
except Exception as e:
logger.error(e)
# parsing unsuccessful
return None
def extract_params(inner):
"""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, macro_instance=None, depth=0):
"""Handle a subset of the macro, e.g. one parameter or function call.
Parameters
----------
macro : string
Just like parse
macro_instance : Macro or None
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.
if macro_instance is None:
macro_instance = Macro()
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)
logger.spam('%scalls %s with %s', space, call, string_params)
# evaluate the params
params = [
parse_recurse(param.strip(), None, depth + 1)
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, macro_instance, depth)
return macro_instance
else:
# probably a parameter for an outer function
try:
macro = int(macro)
except ValueError:
pass
return macro
parse("k(1).k(2).k(3)").run()
parse("r(1, k(2))").run()
parse("r(3, k(a).w(10))").run()
parse("r(2, k(a).k(-)).k(b)").run()
parse("w(1000).m(SHIFT_L, r(2, k(a))).w(10, 20).k(b)").run()

@ -0,0 +1,44 @@
#!/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/>.
import unittest
from keymapper.dev.macros import Macro, k, m, r, w
class TestMacros(unittest.TestCase):
def test_1(self):
r(3, k('a').w(200)).run()
def test_2(self):
r(2, k('a').k('-')).k('b').run()
def test_3(self):
w(400).m('SHIFT_L', r(2, k('a'))).w(10).k('b').run()
def test_4(self):
# prints nothing without .run
k('a').r(3, k('b'))
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save