diff --git a/data/input-remapper.glade b/data/input-remapper.glade index 9ce29959..49de9a94 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -801,7 +801,7 @@ Gives your keys back their original function False - 160 + 200 True True diff --git a/inputremapper/event_combination.py b/inputremapper/event_combination.py index 6cc70d8d..23336051 100644 --- a/inputremapper/event_combination.py +++ b/inputremapper/event_combination.py @@ -53,12 +53,14 @@ class EventCombination(Tuple[InputEvent]): events = [] for init_arg in init_args: event = None + for constructor in InputEvent.__get_validators__(): try: event = constructor(init_arg) break except InputEventCreationError: pass + if event: events.append(event) else: @@ -68,7 +70,7 @@ class EventCombination(Tuple[InputEvent]): def __str__(self): # only used in tests and logging - return f"EventCombination({', '.join([str(e.event_tuple) for e in self])})" + return f"" @classmethod def __get_validators__(cls): diff --git a/inputremapper/gui/editor/editor.py b/inputremapper/gui/editor/editor.py index 96a4ca57..6e126c0b 100644 --- a/inputremapper/gui/editor/editor.py +++ b/inputremapper/gui/editor/editor.py @@ -23,14 +23,11 @@ import re -import locale -import gettext -import os -from inputremapper.configs.data import get_data_path -from inputremapper.gui.gettext import _ +import time from gi.repository import Gtk, GLib, Gdk +from inputremapper.gui.gettext import _ from inputremapper.gui.editor.autocompletion import Autocompletion from inputremapper.configs.system_mapping import system_mapping from inputremapper.gui.active_preset import active_preset @@ -110,6 +107,9 @@ def ensure_everything_saved(func): SET_KEY_FIRST = _("Set the key first") +RECORD_ALL = float("inf") +RECORD_NONE = 0 + class Editor: """Maintains the widgets of the editor.""" @@ -138,14 +138,7 @@ class Editor: # keys were not pressed yet self._input_has_arrived = False - toggle = self.get_recording_toggle() - toggle.connect("focus-out-event", self._reset_keycode_consumption) - toggle.connect("focus-out-event", lambda *_: toggle.set_active(False)) - toggle.connect("toggled", self._on_recording_toggle_toggle) - # Don't leave the input when using arrow keys or tab. wait for the - # window to consume the keycode from the reader. I.e. a tab input should - # be recorded, instead of causing the recording to stop. - toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) + self.record_events_until = RECORD_NONE text_input = self.get_text_input() text_input.connect("focus-out-event", self.on_text_input_unfocus) @@ -161,6 +154,16 @@ class Editor: GLib.source_remove(timeout) self.timeouts = [] + def _on_toggle_clicked(self, toggle, event=None): + if toggle.get_active(): + self._show_press_key() + else: + self._show_change_key() + + @ensure_everything_saved + def _on_toggle_unfocus(self, toggle, event=None): + toggle.set_active(False) + @ensure_everything_saved def on_text_input_unfocus(self, *_): """When unfocusing the text it saves. @@ -184,7 +187,8 @@ class Editor: One could debounce saving on text-change to avoid those logs, but that just sounds like a huge source of race conditions and is also hard to test. """ - pass + print("on_text_input_unfocus") + pass # the decorator will be triggered @ensure_everything_saved def _on_target_input_changed(self, *_): @@ -229,31 +233,25 @@ class Editor: def _setup_recording_toggle(self): """Prepare the toggle button for recording key inputs.""" - toggle = self.get("key_recording_toggle") - toggle.connect( - "focus-out-event", - self._show_change_key, - ) - toggle.connect( - "focus-in-event", - self._show_press_key, - ) - toggle.connect( - "clicked", - lambda _: ( - self._show_press_key() - if toggle.get_active() - else self._show_change_key() - ), - ) + toggle = self.get_recording_toggle() + toggle.connect("focus-out-event", self._show_change_key) + toggle.connect("focus-in-event", self._show_press_key) + toggle.connect("clicked", self._on_toggle_clicked) + toggle.connect("focus-out-event", self._reset_keycode_consumption) + toggle.connect("focus-out-event", self._on_toggle_unfocus) + toggle.connect("toggled", self._on_recording_toggle_toggle) + # Don't leave the input when using arrow keys or tab. wait for the + # window to consume the keycode from the reader. I.e. a tab input should + # be recorded, instead of causing the recording to stop. + toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) - def _show_press_key(self, *_): + def _show_press_key(self, *args): """Show user friendly instructions.""" - self.get("key_recording_toggle").set_label("Press Key") + self.get_recording_toggle().set_label("Press Key") - def _show_change_key(self, *_): + def _show_change_key(self, *args): """Show user friendly instructions.""" - self.get("key_recording_toggle").set_label("Change Key") + self.get_recording_toggle().set_label("Change Key") def _setup_source_view(self): """Prepare the code editor.""" @@ -322,8 +320,12 @@ class Editor: presets accidentally before configuring the key and then it's gone. It can only be saved to the preset if a key is configured. This avoids that pitfall. """ + logger.debug("Disabling the text input") text_input = self.get_text_input() + + # beware that this also disables event listeners like focus-out-event: text_input.set_sensitive(False) + text_input.set_opacity(0.5) if clear or self.get_symbol_input_text() == "": @@ -332,6 +334,7 @@ class Editor: def enable_symbol_input(self): """Don't display help information anymore and allow changing the symbol.""" + logger.debug("Enabling the text input") text_input = self.get_text_input() text_input.set_sensitive(True) text_input.set_opacity(1) @@ -428,6 +431,11 @@ class Editor: """Show what the user is currently pressing in the user interface.""" self.active_selection_label.set_combination(combination) + if combination and len(combination) > 0: + self.enable_symbol_input() + else: + self.disable_symbol_input() + def get_combination(self): """Get the EventCombination object from the left column. @@ -491,11 +499,16 @@ class Editor: return True - def _on_recording_toggle_toggle(self, *args): + def _on_recording_toggle_toggle(self, toggle): """Refresh useful usage information.""" - if not self.get_recording_toggle().get_active(): + if not toggle.get_active(): + # if more events arrive from the time when the toggle was still on, + # use them. + self.record_events_until = time.time() return + self.record_events_until = RECORD_ALL + self._reset_keycode_consumption() reader.clear() if not self.user_interface.can_modify_preset(): @@ -505,7 +518,7 @@ class Editor: self.user_interface.show_status( CTX_ERROR, _('Use "Stop Injection" to stop before editing') ) - self.get_recording_toggle().set_active(False) + toggle.set_active(False) def _on_delete_button_clicked(self, *_): """Destroy the row and remove it from the config.""" @@ -560,22 +573,28 @@ class Editor: self.user_interface.save_preset() def is_waiting_for_input(self): - """Check if the user is interacting with the ToggleButton for combination recording.""" + """Check if the user is trying to record buttons.""" return self.get_recording_toggle().get_active() - def consume_newest_keycode(self, combination): - """To capture events from keyboards, mice and gamepads. - - Parameters - ---------- - combination : EventCombination or None - """ + def should_record_combination(self, combination): + """Check if the combination was written when the toggle was active.""" + # At this point the toggle might already be off, because some keys that are + # used while the toggle was still on might cause the focus of the toggle to + # be lost, like multimedia keys. This causes the toggle to be disabled. + # Yet, this event should be mapped. + timestamp = max([event.timestamp() for event in combination]) + return timestamp < self.record_events_until + + def consume_newest_keycode(self, combination: EventCombination): + """To capture events from keyboards, mice and gamepads.""" self._switch_focus_if_complete() if combination is None: return - if not self.is_waiting_for_input(): + if not self.should_record_combination(combination): + # the event arrived after the toggle has been deactivated + logger.debug("Recording toggle is not on") return if not isinstance(combination, EventCombination): @@ -592,7 +611,7 @@ class Editor: ) logger.info("%s %s", combination, msg) self.user_interface.show_status(CTX_KEYCODE, msg) - return True + return if combination.is_problematic(): self.user_interface.show_status( @@ -620,8 +639,13 @@ class Editor: symbol = self.get_symbol_input_text() target = self.get_target_selection() - # the symbol is empty and therefore the mapping is not complete - if not symbol or not target: + if not symbol: + # has not been entered yet + logger.debug("Symbol missing") + return + + if not target: + logger.debug("Target missing") return # else, the keycode has changed, the symbol is set, all good @@ -648,6 +672,7 @@ class Editor: all_keys_released = reader.get_unreleased_keys() is None if all_keys_released and self._input_has_arrived and self.get_combination(): + logger.debug("Recording complete") # A key was pressed and then released. # Switch to the symbol. idle_add this so that the # keycode event won't write into the symbol input as well. diff --git a/inputremapper/gui/helper.py b/inputremapper/gui/helper.py index f0d228ed..d5be0994 100644 --- a/inputremapper/gui/helper.py +++ b/inputremapper/gui/helper.py @@ -48,8 +48,13 @@ from inputremapper import utils from inputremapper.user import USER -TERMINATE = "terminate" -REFRESH_GROUPS = "refresh_groups" +# received by the helper +CMD_TERMINATE = "terminate" +CMD_REFRESH_GROUPS = "refresh_groups" + +# sent by the helper to the reader +MSG_GROUPS = "groups" +MSG_EVENT = "event" def is_helper_running(): @@ -88,7 +93,7 @@ class RootHelper: def _send_groups(self): """Send the groups to the gui.""" - self._results.send({"type": "groups", "message": groups.dumps()}) + self._results.send({"type": MSG_GROUPS, "message": groups.dumps()}) def _handle_commands(self): """Handle all unread commands.""" @@ -99,11 +104,11 @@ class RootHelper: cmd = self._commands.recv() logger.debug('Received command "%s"', cmd) - if cmd == TERMINATE: + if cmd == CMD_TERMINATE: logger.debug("Helper terminates") sys.exit(0) - if cmd == REFRESH_GROUPS: + if cmd == CMD_REFRESH_GROUPS: groups.refresh() self._send_groups() continue @@ -209,7 +214,7 @@ class RootHelper: self._results.send( { - "type": "event", + "type": MSG_EVENT, "message": (event.sec, event.usec, event.type, event.code, event.value), } ) diff --git a/inputremapper/gui/reader.py b/inputremapper/gui/reader.py index f87c56c0..4bd29bc0 100644 --- a/inputremapper/gui/reader.py +++ b/inputremapper/gui/reader.py @@ -32,7 +32,12 @@ from inputremapper.logger import logger from inputremapper.event_combination import EventCombination from inputremapper.groups import groups, GAMEPAD from inputremapper.ipc.pipe import Pipe -from inputremapper.gui.helper import TERMINATE, REFRESH_GROUPS +from inputremapper.gui.helper import ( + MSG_EVENT, + MSG_GROUPS, + CMD_TERMINATE, + CMD_REFRESH_GROUPS, +) from inputremapper import utils from inputremapper.gui.active_preset import active_preset from inputremapper.user import USER @@ -88,14 +93,14 @@ class Reader: message_type = message["type"] message_body = message["message"] - if message_type == "groups": + if message_type == MSG_GROUPS: if message_body != groups.dumps(): groups.loads(message_body) logger.debug("Received %d devices", len(groups)) self._groups_updated = True return None - if message_type == "event": + if message_type == MSG_EVENT: return InputEvent(*message_body) logger.error('Received unknown message "%s"', message) @@ -139,12 +144,12 @@ class Reader: continue if event.value == 0: - logger.debug_key(event.event_tuple, "release") + logger.debug_key(event, "release") self._release(event.type_and_code) continue - if self._unreleased.get(event.type_and_code) == event.event_tuple: - logger.debug_key(event.event_tuple, "duplicate key down") + if self._unreleased.get(event.type_and_code) == event: + logger.debug_key(event, "duplicate key down") self._debounce_start(event.event_tuple) continue @@ -154,8 +159,8 @@ class Reader: # from release to input in order to remember it. Since all release # events have value 0, the value is not used in the combination. key_down_received = True - logger.debug_key(event.event_tuple, "down") - self._unreleased[event.type_and_code] = event.event_tuple + logger.debug_key(event, "down") + self._unreleased[event.type_and_code] = event self._debounce_start(event.event_tuple) previous_event = event @@ -190,11 +195,11 @@ class Reader: def terminate(self): """Stop reading keycodes for good.""" logger.debug("Sending close msg to helper") - self._commands.send(TERMINATE) + self._commands.send(CMD_TERMINATE) def refresh_groups(self): """Ask the helper for new device groups.""" - self._commands.send(REFRESH_GROUPS) + self._commands.send(CMD_REFRESH_GROUPS) def clear(self): """Next time when reading don't return the previous keycode.""" diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py index 053d0683..69d1d6dd 100644 --- a/inputremapper/gui/user_interface.py +++ b/inputremapper/gui/user_interface.py @@ -407,12 +407,18 @@ class UserInterface: # letting go of one of the keys of a combination won't just make # it return the leftover key, it will continue to return None because # they have already been read. - key = reader.read() + combination = reader.read() if reader.are_new_groups_available(): self.populate_devices() - self.editor.consume_newest_keycode(key) + # giving editor its own interval and making it call reader.read itself causes + # incredibly frustrating and miraculous problems. Do not do it. Observations: + # - test_autocomplete_key fails if the gui has been launched and closed by a + # previous test already + # Maybe it has something to do with the order of editor.consume_newest_keycode + # and user_interface.populate_devices. + self.editor.consume_newest_keycode(combination) return True diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py index 3acdbd6c..796cbfc1 100644 --- a/inputremapper/input_event.py +++ b/inputremapper/input_event.py @@ -116,6 +116,18 @@ class InputEvent: """event type, code, value""" return self.type, self.code, self.value + def __str__(self): + if self.type == evdev.ecodes.EV_KEY: + key_name = evdev.ecodes.bytype[self.type].get(self.code, self.code) + action = "down" if self.value == 1 else "up" + return f"" + + return f"" + + def timestamp(self): + """Return the unix timestamp of when the event was seen.""" + return self.sec + self.usec / 1000000 + def modify( self, sec: int = None, diff --git a/inputremapper/logger.py b/inputremapper/logger.py index 4118b4e5..ab3cbead 100644 --- a/inputremapper/logger.py +++ b/inputremapper/logger.py @@ -61,10 +61,10 @@ def debug_key(self, key, msg, *args): msg = msg % args str_key = str(key) str_key = str_key.replace(",)", ")") - spacing = " " + "-" * max(0, 30 - len(str_key)) + spacing = " " + "·" * max(0, 30 - len(msg)) if len(spacing) == 1: spacing = "" - msg = f"{str_key}{spacing} {msg}" + msg = f"{msg}{spacing} {str_key}" if msg == previous_key_debug_log: # avoid some super spam from EV_ABS events diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index 2a118643..8e64df38 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -288,7 +288,7 @@ class GuiTestBase(unittest.TestCase): raise e # try again - print("Test failed, trying again") + print("Test failed, trying again...") self.tearDown() self.setUp() @@ -335,6 +335,17 @@ class GuiTestBase(unittest.TestCase): def tearDownClass(cls): UserInterface.start_processes = cls.original_start_processes + def activate_recording_toggle(self): + logger.info("Activating the recording toggle") + self.set_focus(self.toggle) + self.toggle.set_active(True) + + def disable_recording_toggle(self): + logger.info("Deactivating the recording toggle") + self.set_focus(None) + # should happen automatically: + self.assertFalse(self.toggle.get_active()) + def set_focus(self, widget): logger.info("Focusing %s", widget) @@ -781,23 +792,31 @@ class TestGui(GuiTestBase): "Button A + Button B + Button C", ) + def test_is_waiting_for_input(self): + self.activate_recording_toggle() + self.assertTrue(self.editor.is_waiting_for_input()) + + self.disable_recording_toggle() + self.assertFalse(self.editor.is_waiting_for_input()) + def test_editor_simple(self): self.assertEqual(self.toggle.get_label(), "Change Key") self.assertEqual(len(self.selection_label_listbox.get_children()), 1) selection_label = self.selection_label_listbox.get_children()[0] - self.set_focus(self.toggle) - self.toggle.set_active(True) + self.activate_recording_toggle() + self.assertTrue(self.editor.is_waiting_for_input()) self.assertEqual(self.toggle.get_label(), "Press Key") - self.editor.consume_newest_keycode(None) + self.user_interface.consume_newest_keycode() # nothing happens self.assertIsNone(selection_label.get_combination()) self.assertEqual(len(active_preset), 0) self.assertEqual(self.toggle.get_label(), "Press Key") - self.editor.consume_newest_keycode(EventCombination([EV_KEY, 30, 1])) + send_event_to_reader(InputEvent.from_tuple((EV_KEY, 30, 1))) + self.user_interface.consume_newest_keycode() # no symbol configured yet, so the active_preset remains empty self.assertEqual(len(active_preset), 0) self.assertEqual(len(selection_label.get_combination()), 1) @@ -806,9 +825,10 @@ class TestGui(GuiTestBase): # but KEY_ is removed from the text for display purposes self.assertEqual(selection_label.get_label(), "a") - # providing the same key again (Maybe this could happen for gamepads or - # something, idk) doesn't do any harm - self.editor.consume_newest_keycode(EventCombination([EV_KEY, 30, 1])) + # providing the same key again doesn't do any harm + # (Maybe this could happen for gamepads or something, idk) + send_event_to_reader(InputEvent.from_tuple((EV_KEY, 30, 1))) + self.user_interface.consume_newest_keycode() self.assertEqual(len(active_preset), 0) # not released yet self.assertEqual(len(selection_label.get_combination()), 1) self.assertEqual(selection_label.get_combination()[0], (EV_KEY, 30, 1)) @@ -821,11 +841,17 @@ class TestGui(GuiTestBase): 2, ) + self.disable_recording_toggle() self.set_focus(self.editor.get_text_input()) + self.assertFalse(self.editor.is_waiting_for_input()) + self.editor.set_symbol_input_text("Shift_L") + self.set_focus(None) + self.assertFalse(self.editor.is_waiting_for_input()) - self.assertEqual(len(active_preset), 1) + num_mappings = len(active_preset) + self.assertEqual(num_mappings, 1) time.sleep(0.1) gtk_iteration() @@ -1970,7 +1996,7 @@ class TestGui(GuiTestBase): self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) def test_enable_disable_symbol_input(self): - self.editor.disable_symbol_input() + # should be disabled by default since no key is recorded yet self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertFalse(self.editor.get_text_input().get_sensitive()) @@ -1978,6 +2004,26 @@ class TestGui(GuiTestBase): self.assertEqual(self.get_unfiltered_symbol_input_text(), "") self.assertTrue(self.editor.get_text_input().get_sensitive()) + # disable it + self.editor.disable_symbol_input() + self.assertFalse(self.editor.get_text_input().get_sensitive()) + + # try to enable it by providing a key via set_combination + self.editor.set_combination(EventCombination((1, 201, 1))) + self.assertEqual(self.get_unfiltered_symbol_input_text(), "") + self.assertTrue(self.editor.get_text_input().get_sensitive()) + + # disable it again + self.editor.set_combination(None) + self.assertFalse(self.editor.get_text_input().get_sensitive()) + + # try to enable it via the reader + self.activate_recording_toggle() + send_event_to_reader(InputEvent.from_tuple((EV_KEY, 101, 1))) + self.user_interface.consume_newest_keycode() + self.assertEqual(self.get_unfiltered_symbol_input_text(), "") + self.assertTrue(self.editor.get_text_input().get_sensitive()) + # it wouldn't clear user input, if for whatever reason (a bug?) there is user # input in there when enable_symbol_input is called. self.editor.set_symbol_input_text("foo") diff --git a/tests/test.py b/tests/test.py index 8b5bf624..5f9d7388 100644 --- a/tests/test.py +++ b/tests/test.py @@ -565,7 +565,7 @@ def send_event_to_reader(event): def quick_cleanup(log=True): """Reset the applications state.""" if log: - print("quick cleanup") + print("Quick cleanup...") for device in list(pending_events.keys()): try: @@ -648,13 +648,16 @@ def quick_cleanup(log=True): uinput.write_count = 0 uinput.write_history = [] + if log: + print("Quick cleanup done") + def cleanup(): """Reset the applications state. Using this is slower, usually quick_cleanup() is sufficient. """ - print("cleanup") + print("Cleanup...") os.system("pkill -f input-remapper-service") os.system("pkill -f input-remapper-control") @@ -665,6 +668,8 @@ def cleanup(): with patch.object(sys, "argv", ["input-remapper-service"]): global_uinputs.prepare() + print("Cleanup done") + def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" diff --git a/tests/unit/test_event_combination.py b/tests/unit/test_event_combination.py index d1458260..661c929d 100644 --- a/tests/unit/test_event_combination.py +++ b/tests/unit/test_event_combination.py @@ -31,20 +31,17 @@ class TestKey(unittest.TestCase): def test_key(self): # its very similar to regular tuples, but with some extra stuff key_1 = EventCombination((1, 3, 1), (1, 5, 1)) - self.assertEqual(str(key_1), "EventCombination((1, 3, 1), (1, 5, 1))") self.assertEqual(len(key_1), 2) self.assertEqual(key_1[0], (1, 3, 1)) self.assertEqual(key_1[1], (1, 5, 1)) self.assertEqual(hash(key_1), hash(((1, 3, 1), (1, 5, 1)))) key_2 = EventCombination((1, 3, 1)) - self.assertEqual(str(key_2), "EventCombination((1, 3, 1))") self.assertEqual(len(key_2), 1) self.assertNotEqual(key_2, key_1) self.assertNotEqual(hash(key_2), hash(key_1)) key_3 = EventCombination((1, 3, 1)) - self.assertEqual(str(key_3), "EventCombination((1, 3, 1))") self.assertEqual(len(key_3), 1) self.assertEqual(key_3, key_2) self.assertNotEqual(key_3, (1, 3, 1)) @@ -52,15 +49,11 @@ class TestKey(unittest.TestCase): self.assertEqual(hash(key_3), hash(((1, 3, 1),))) key_4 = EventCombination(*key_3) - self.assertEqual(str(key_4), "EventCombination((1, 3, 1))") self.assertEqual(len(key_4), 1) self.assertEqual(key_4, key_3) self.assertEqual(hash(key_4), hash(key_3)) key_5 = EventCombination(*key_4, *key_4, (1, 7, 1)) - self.assertEqual( - str(key_5), "EventCombination((1, 3, 1), (1, 3, 1), (1, 7, 1))" - ) self.assertEqual(len(key_5), 3) self.assertNotEqual(key_5, key_4) self.assertNotEqual(hash(key_5), hash(key_4)) diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index c198aea5..348af2a8 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -50,8 +50,14 @@ class TestLogger(unittest.TestCase): logger.debug_key(((1, 200, -1), (1, 5, 1)), "foo %s", (1, 2)) with open(path, "r") as f: content = f.read().lower() - self.assertIn("((1, 2, 1)) ------------------- foo 1234 bar", content) - self.assertIn("((1, 200, -1), (1, 5, 1)) ----- foo (1, 2)", content) + self.assertIn( + "foo 1234 bar ·················· ((1, 2, 1))", + content, + ) + self.assertIn( + "foo (1, 2) ···················· ((1, 200, -1), (1, 5, 1))", + content, + ) def test_log_info(self): update_verbosity(debug=False)