From dba2bd685c34edc88e736b8e98fdd6aa3a811a69 Mon Sep 17 00:00:00 2001 From: sezanzeb <28510156+sezanzeb@users.noreply.github.com> Date: Thu, 2 Jan 2025 21:14:44 +0100 Subject: [PATCH] Add parallel macro --- inputremapper/injection/macros/parse.py | 12 +- .../injection/macros/tasks/parallel.py | 45 +++++++ readme/macros.md | 18 +++ tests/unit/test_macros/test_parallel.py | 114 ++++++++++++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 inputremapper/injection/macros/tasks/parallel.py create mode 100644 tests/unit/test_macros/test_parallel.py diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index 6f13d2b3..1b8cf310 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -44,6 +44,7 @@ from inputremapper.injection.macros.tasks.mod_tap import ModTapTask from inputremapper.injection.macros.tasks.modify import ModifyTask from inputremapper.injection.macros.tasks.mouse import MouseTask +from inputremapper.injection.macros.tasks.parallel import ParallelTask from inputremapper.injection.macros.tasks.repeat import RepeatTask from inputremapper.injection.macros.tasks.set import SetTask from inputremapper.injection.macros.tasks.wait import WaitTask @@ -76,6 +77,7 @@ class Parser: "if_single": IfSingleTask, "add": AddTask, "mod_tap": ModTapTask, + "parallel": ParallelTask, # Those are only kept for backwards compatibility with old macros. The space for # writing macro was very constrained in the past, so shorthands were introduced: "m": ModifyTask, @@ -210,8 +212,14 @@ def _parse_recurse( code Just like parse. A single parameter or the complete macro as string. Comments and redundant whitespace characters are expected to be removed already. - TODO add some examples. - Are all of "foo(1);bar(2)" "foo(1)" and "1" valid inputs? + Example: + - "parallel(key(a),key(b).key($foo))" + - "key(a)" + - "a" + - "key(b).key($foo)" + - "b" + - "key($foo)" + - "$foo" context : Context macro_instance A macro instance to add tasks to. This is the output of the parser, and is diff --git a/inputremapper/injection/macros/tasks/parallel.py b/inputremapper/injection/macros/tasks/parallel.py new file mode 100644 index 00000000..cd20fb2a --- /dev/null +++ b/inputremapper/injection/macros/tasks/parallel.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper 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. +# +# input-remapper 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 input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from typing import List + +from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags +from inputremapper.injection.macros.macro import Macro, InjectEventCallback +from inputremapper.injection.macros.task import Task + + +class ParallelTask(Task): + """Run all provided macros in parallel.""" + + argument_configs = [ + ArgumentConfig( + name="*macros", + position=ArgumentFlags.spread, + types=[Macro], + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + macros: List[Macro] = self.get_argument("*macros").get_values() + coroutines = [macro.run(callback) for macro in macros] + await asyncio.gather(*coroutines) diff --git a/readme/macros.md b/readme/macros.md index 83c063a4..f62f969d 100644 --- a/readme/macros.md +++ b/readme/macros.md @@ -321,6 +321,24 @@ Bear in mind that anti-cheat software might detect macros in games. > if_single(key(KEY_A), key(KEY_B), timeout=1000) > ``` +### parallel + +> Run all provided macros in parallel. +> +> ```ts +> parallel(*macros: Macro) +> ``` +> +> Examples: +> +> ```ts +> parallel( +> mouse(up, 10), +> hold_keys(a), +> wheel(down, 10) +> ) +> ``` + ## Syntax Multiple functions are chained using `.`. diff --git a/tests/unit/test_macros/test_parallel.py b/tests/unit/test_macros/test_parallel.py new file mode 100644 index 00000000..b4e9ec66 --- /dev/null +++ b/tests/unit/test_macros/test_parallel.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2024 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper 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. +# +# input-remapper 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 input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + KEY_A, + KEY_B, + KEY_C, + KEY_D, +) + +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestParallel(MacroTestBase): + async def test_1_child_macro(self): + macro = Parser.parse( + "parallel(key(a))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 1) + await macro.run(self.handler) + self.assertEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_4_child_macros(self): + macro = Parser.parse( + "parallel(key(a), key(b), key(c), key(d))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 4) + await macro.run(self.handler) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertIn((EV_KEY, KEY_D, 0), self.result) + + async def test_one_wait_takes_longer(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(wait(100), wait(10).key(b)).key(c)", + self.context, + mapping, + True, + ) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.06) + # The wait(10).key(b) macro is already done, but KEY_C is not yet injected + self.assertEqual(len(self.result), 2) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + + # Both need to complete for it to continue to key(c) + await asyncio.sleep(0.06) + self.assertEqual(len(self.result), 4) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + + async def test_parallel_hold(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(hold_keys(a), hold_keys(b)).key(c)", + self.context, + mapping, + True, + ) + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertEqual(len(self.result), 2) + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertEqual(len(self.result), 6) + + +if __name__ == "__main__": + unittest.main()