Mapping media keys (#318)

pull/329/head
Tobi 2 years ago committed by GitHub
parent 8f8800498c
commit 2badf2c5d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -801,7 +801,7 @@ Gives your keys back their original function</property>
<property name="can-focus">False</property>
<child>
<object class="GtkScrolledWindow">
<property name="width-request">160</property>
<property name="width-request">200</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<child>

@ -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"<EventCombination {', '.join([str(e.event_tuple) for e in self])}>"
@classmethod
def __get_validators__(cls):

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

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

@ -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."""

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

@ -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"<InputEvent {key_name} {action}>"
return f"<InputEvent {self.event_tuple}>"
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,

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

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

@ -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=...)."""

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

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

Loading…
Cancel
Save