diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk index 6da510bdc..a4b0bbd5a 100755 --- a/bin/input-remapper-gtk +++ b/bin/input-remapper-gtk @@ -41,11 +41,11 @@ from inputremapper.daemon import DaemonProxy from inputremapper.logger import logger, update_verbosity, log_info -def start_processes() -> DaemonProxy: +def start_processes(ignore_pkexec_errors=False) -> DaemonProxy: """Start reader-service and daemon via pkexec to run in the background.""" # this function is overwritten in tests try: - ReaderService.pkexec_reader_service() + ReaderService.pkexec_reader_service(ingore_errors=ignore_pkexec_errors) except Exception as e: logger.error(e) sys.exit(11) @@ -57,7 +57,12 @@ if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( '-d', '--debug', action='store_true', dest='debug', - help=_('Displays additional debug information'), + help=_('displays additional debug information'), + default=False + ) + parser.add_argument( + '-R', '--no-root', action='store_true', dest='no_root', + help=_('allow rejecting root access (by cancelling the pkexec dialog)'), default=False ) @@ -85,8 +90,8 @@ if __name__ == '__main__': # create the reader before we start the reader-service (start_processes) otherwise # it can come to race conditions with the creation of pipes - reader_client = ReaderClient(message_broker, _Groups()) - daemon = start_processes() + reader_client = ReaderClient(message_broker, _Groups(), ignore_pkexec_errors=options.no_root) + daemon = start_processes(ignore_pkexec_errors=options.no_root) data_manager = DataManager( message_broker, GlobalConfig(), reader_client, daemon, GlobalUInputs(), system_mapping diff --git a/data/input-remapper.glade b/data/input-remapper.glade index 00a6daee4..1bac357a6 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -15,6 +15,11 @@ 2 media-playback-start + + True + False + sweeper + True False @@ -436,6 +441,7 @@ Shortcut: ctrl + del True True + enter preset name... True @@ -501,6 +507,7 @@ Shortcut: ctrl + del False 18 vertical + 6 True @@ -521,14 +528,44 @@ Shortcut: ctrl + del True False - center - 18 + 6 + 6 + 6 + True - + True False - no input configured - True + center + 18 + + + True + False + no input configured + True + + + False + True + 0 + + + + + False + 0.5 + (recording ...) + + + + + + False + True + 1 + + False @@ -536,21 +573,6 @@ Shortcut: ctrl + del 0 - - - False - 0.5 - (recording ...) - - - - - - False - True - 1 - - False @@ -564,11 +586,9 @@ Shortcut: ctrl + del True False center - 18 - 18 - 18 + 6 + 6 6 - True Add @@ -580,8 +600,8 @@ Shortcut: ctrl + del False - True - 1 + False + 0 @@ -596,7 +616,7 @@ Shortcut: ctrl + del False - True + False 1 @@ -610,7 +630,7 @@ Shortcut: ctrl + del False - True + False 2 @@ -629,6 +649,45 @@ Shortcut: ctrl + del False + False + 3 + + + + + True + False + 6 + + + True + True + filter mappings... + + + True + True + 0 + + + + + True + True + True + Clear search filter + start + clear-icon + + + False + True + 1 + + + + + True True 4 diff --git a/inputremapper/gui/components/common.py b/inputremapper/gui/components/common.py index 7110113ec..320b2ea75 100644 --- a/inputremapper/gui/components/common.py +++ b/inputremapper/gui/components/common.py @@ -27,7 +27,10 @@ from gi.repository import Gtk -from typing import Optional +from typing import ( + Optional, + Iterator, +) from inputremapper.configs.mapping import MappingData @@ -173,3 +176,118 @@ def _render(self): label.append(self._mapping_name or "?") self._gui.set_label(" / ".join(label)) + + +class ListFilterControl: + """Implements UI-side filtering of list widgets. + + The following example creates a new ``ListFilterControl`` for a given + ``Gtk.ListBox`` and a given ``Gtk.Entry`` for text input. It also sets all + optional arguments to override some default behavior. + + >>> ListFilterControl( + >>> my_gtk_listbox, + >>> my_gtk_entry, + >>> clear_button=my_gtk_button, # use an optional clear button + >>> case_sensitive=True, # change default behavior + >>> get_row_name=MyRow.get_name # custom row name getter + >>> ) + + """ + + MAX_WIDGET_TREE_TEXT_SEARCH_DEPTH = 10 + + def __init__( + self, + # message_broker: MessageBroker, + controlled_listbox: Gtk.ListBox, + filter_entry: Gtk.GtkEntry, + clear_button: Gtk.Button = None, + case_sensitive=False, + get_row_name=None, + ): + self._controlled_listbox: Gtk.ListBox = controlled_listbox + self._filter_entry: Gtk.Entry = filter_entry + self._clear_button: Gtk.Button = clear_button + + self._filter_value: str = "" + self._case_sensitive: bool = bool(case_sensitive) + self._get_row_name = get_row_name or self.get_row_name + + self._connect_gtk_signals() + + @classmethod + def get_row_name(T, row: Gtk.ListBoxRow) -> str: + """ + Returns the visible text of a Gtk.ListBoxRow from both the row's `name` + attribute or the row's text in the UI. + """ + text = getattr(row, "name", "") + + # find and join all text in the ListBoxRow + text += " ".join(v for v in T.get_widget_tree_text(row) if v != "") + + return text.strip() + + @classmethod + def get_widget_tree_text(T, widget: Gtk.Widget, level=0) -> Iterator[str]: + """ + Recursively traverses the tree of child widgets starting from the given + widget, and yields the text of all text-containing widgets. + """ + if level > T.MAX_WIDGET_TREE_TEXT_SEARCH_DEPTH: + return + + if hasattr(widget, "get_label"): + yield (widget.get_label() or "").strip() + if hasattr(widget, "get_text"): + yield (widget.get_text() or "").strip() + if isinstance(widget, Gtk.Container): + for t in widget.get_children(): + yield from T.get_widget_tree_text(t, level=level + 1) + + def _connect_gtk_signals(self): + if self._clear_button: + self._clear_button.connect("clicked", self.on_gtk_clear_button_clicked) + self._filter_entry.connect("key-release-event", self.on_gtk_filter_entry_input) + + # apply defined filter by sending out the corresponding events + def apply_filter(self): + self._apply_filter_to_listbox_children() + + # matches the current filter_value and filter_options with the given value + def match_filter(self, value: str): + value = (value or "").strip() + + # if filter is not set, all rows need to match + if self._filter_value == "": + return True + + if self._case_sensitive: + return self._filter_value in value + else: + return self._filter_value.lower() in value.lower() + + def _apply_filter_to_listbox_children(self): + value = self._filter_value.lower() + selected: Gtk.ListBoxRow = None + row: Gtk.ListBoxRow = None + for row in self._controlled_listbox.get_children(): + if self.match_filter(self._get_row_name(row)): + # show matching rows, then select the first row + row.show() + if selected is None: + selected = row + self._controlled_listbox.select_row(selected) + else: + # hide non-matching rows + row.hide() + + def on_gtk_filter_entry_input(self, _, event: Gdk.EventKey): + self._filter_value = (self._filter_entry.get_text() or "").strip() + self.apply_filter() + + def on_gtk_clear_button_clicked(self, *_): + self._filter_entry.set_text("") + self._filter_value = "" + self.apply_filter() diff --git a/inputremapper/gui/reader_client.py b/inputremapper/gui/reader_client.py index a0b1009f1..f29c0b440 100644 --- a/inputremapper/gui/reader_client.py +++ b/inputremapper/gui/reader_client.py @@ -72,7 +72,9 @@ class ReaderClient: # how long to wait for the reader-service at most _timeout: int = 5 - def __init__(self, message_broker: MessageBroker, groups: _Groups): + def __init__( + self, message_broker: MessageBroker, groups: _Groups, ignore_pkexec_errors=False + ): self.groups = groups self.message_broker = message_broker @@ -86,13 +88,14 @@ def __init__(self, message_broker: MessageBroker, groups: _Groups): self.attach_to_events() self._read_timeout = GLib.timeout_add(30, self._read) + self.ignore_pkexec_errors = ignore_pkexec_errors def ensure_reader_service_running(self): if ReaderService.is_running(): return logger.info("ReaderService not running anymore, restarting") - ReaderService.pkexec_reader_service() + ReaderService.pkexec_reader_service(ingore_errors=self.ignore_pkexec_errors) # wait until the ReaderService is up diff --git a/inputremapper/gui/reader_service.py b/inputremapper/gui/reader_service.py index 741f2455e..5fd2471e0 100644 --- a/inputremapper/gui/reader_service.py +++ b/inputremapper/gui/reader_service.py @@ -136,7 +136,7 @@ def is_running(): return True @staticmethod - def pkexec_reader_service(): + def pkexec_reader_service(ingore_errors=False): """Start reader-service via pkexec to run in the background.""" debug = " -d" if logger.level <= logging.DEBUG else "" cmd = f"pkexec input-remapper-control --command start-reader-service{debug}" @@ -145,7 +145,11 @@ def pkexec_reader_service(): exit_code = os.system(cmd) if exit_code != 0: - raise Exception(f"Failed to pkexec the reader-service, code {exit_code}") + ex = Exception(f"Failed to pkexec the reader-service, code {exit_code}") + if ingore_errors: + logger.warn(ex) + else: + raise ex async def run(self): """Start doing stuff.""" diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py index d7c222d96..91591e27a 100644 --- a/inputremapper/gui/user_interface.py +++ b/inputremapper/gui/user_interface.py @@ -51,7 +51,10 @@ ) from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.components.main import Stack, StatusBar -from inputremapper.gui.components.common import Breadcrumbs +from inputremapper.gui.components.common import ( + Breadcrumbs, + ListFilterControl, +) from inputremapper.gui.components.device_groups import DeviceGroupSelection from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( @@ -149,6 +152,12 @@ def _create_components(self): MappingListBox(message_broker, controller, self.get("selection_label_listbox")) TargetSelection(message_broker, controller, self.get("target-selector")) + ListFilterControl( + self.get("selection_label_listbox"), + self.get("mapping-filter-input"), + clear_button=self.get("mapping-filter-clear-button"), + ) + Breadcrumbs( message_broker, self.get("selected_device_name"), @@ -366,7 +375,7 @@ def connect_shortcuts(self): "key-press-event", self.on_gtk_shortcut ) - def get(self, name: str): + def get(self, name: str) -> Gtk.Widget: """Get a widget from the window.""" return self.builder.get_object(name) diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index a6d8dc4c2..e18c94cdf 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -69,7 +69,7 @@ def __init__(self, *args, **kwargs): # gather the capabilities. (can_emit is called regularly) self._capabilities_cache = self.capabilities(absinfo=False) - def can_emit(self, event: Tuple[int, int, int]): + def can_emit(self, event: Tuple[int, int, int]) -> bool: """Check if an event can be emitted by the UIinput. Wrong events might be injected if the group mappings are wrong, @@ -90,6 +90,9 @@ def __init__(self, *args, events=None, name="py-evdev-uinput", **kwargs): def capabilities(self): return self.events + def can_emit(self, event: Tuple[int, int, int]) -> bool: + return False + class GlobalUInputs: """Manages all UInputs that are shared between all injection processes.""" diff --git a/tests/integration/test_components.py b/tests/integration/test_components.py index 43cfd8b68..72a7b2369 100644 --- a/tests/integration/test_components.py +++ b/tests/integration/test_components.py @@ -78,7 +78,11 @@ GdkEventRecorder, ) from inputremapper.gui.components.main import Stack, StatusBar -from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs +from inputremapper.gui.components.common import ( + FlowBoxEntry, + Breadcrumbs, + ListFilterControl, +) from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.components.device_groups import ( DeviceGroupEntry, @@ -88,6 +92,23 @@ from inputremapper.configs.input_config import InputCombination, InputConfig +class GtkKeyEvent: + KEY_RELEASE = "key-release-event" + KEY_PRESS = "key-press-event" + + def __init__(self, keyval): + self.keyval = keyval + + def get_keyval(self): + return True, self.keyval + + def emit_to(self, target: Gtk.Widget, event_type=KEY_RELEASE): + ev = Gdk.Event() + ev.key.keyval = self.keyval + target.emit(event_type, ev) + gtk_iteration() + + class ComponentBaseTest(unittest.TestCase): """Test a gui component.""" @@ -349,7 +370,7 @@ def test_loads_preset(self): self.controller_mock.load_preset.assert_called_once_with("preset2") -class TestMappingListbox(ComponentBaseTest): +class TestMappingListboxBase(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ListBox() @@ -401,6 +422,11 @@ def select(label_: MappingSelectionLabel): for label in self.gui.get_children(): select(label) + +class TestMappingListbox(TestMappingListboxBase): + def setUp(self) -> None: + super().setUp() + def test_populates_listbox(self): labels = {row.name for row in self.gui.get_children()} self.assertEqual(labels, {"mapping1", "mapping2", "a + b"}) @@ -491,6 +517,43 @@ def test_sorts_empty_mapping_to_bottom(self): self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) +class TestMappingFilterListbox(TestMappingListboxBase): + def setUp(self) -> None: + super().setUp() + self.entry = Gtk.Entry() + self.button = Gtk.Button() + self.control = ListFilterControl( + self.gui, + self.entry, + clear_button=self.button, + ) + + def get_num_visible(self): + return len([c for c in self.gui.get_children() if c.get_visible()]) + + def test_filter_entry(self): + n = len(list(self.gui.get_children())) + + self.assertGreater(n, 2, "some mappings must be loaded") + self.assertEqual(self.get_num_visible(), n, "all mappings must be visible") + + self.entry.set_text("not in preset") + GtkKeyEvent(Gdk.KEY_Escape).emit_to(self.entry) + self.assertEqual(self.entry.get_text(), "not in preset") + self.assertEqual(self.get_num_visible(), 0, "mappings must not be visible") + + self.button.clicked() + gtk_iteration() + self.assertEqual(self.entry.get_text(), "", "filter must be cleared") + self.assertEqual( + self.get_num_visible(), n, "all mappings must be visible again" + ) + + self.entry.set_text("mapping1") + GtkKeyEvent(Gdk.KEY_Escape).emit_to(self.entry) + self.assertEqual(self.get_num_visible(), 1, "only one mapping must be visible") + + class TestMappingSelectionLabel(ComponentBaseTest): def setUp(self) -> None: super().setUp() @@ -1859,3 +1922,7 @@ def test_breadcrumbs(self): ) self.assertEqual(self.label_4.get_text(), "group / preset / qux") self.assertEqual(self.label_5.get_text(), "qux") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index e3430b20b..11a1d098f 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -34,7 +34,7 @@ from tests.lib.logger import logger from tests.lib.fixtures import fixtures from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe -from tests.integration.test_components import FlowBoxTestUtils +from tests.integration.test_components import FlowBoxTestUtils, GtkKeyEvent import sys import time @@ -173,14 +173,6 @@ def clean_up_integration(test): atexit.unregister(test.daemon.stop_all) -class GtkKeyEvent: - def __init__(self, keyval): - self.keyval = keyval - - def get_keyval(self): - return True, self.keyval - - class TestGroupsFromReaderService(unittest.TestCase): def setUp(self): # don't try to connect, return an object instance of it instead @@ -304,6 +296,8 @@ def setUp(self): self.stop_injector_btn: Gtk.Button = get("stop_injection_preset_page") self.rename_btn: Gtk.Button = get("rename-button") self.rename_input: Gtk.Entry = get("preset_name_input") + self.mapping_filter_btn: Gtk.Button = get("mapping-filter-clear-button") + self.mapping_filter_input: Gtk.Entry = get("mapping-filter-input") self.create_mapping_btn: Gtk.Button = get("create_mapping_button") self.delete_mapping_btn: Gtk.Button = get("delete-mapping") @@ -1447,6 +1441,30 @@ def save(): gtk_iteration() self.assertFalse(os.path.exists(preset_path)) + def test_filtering_mappings(self): + self.controller.load_preset("preset2") + self.throttle(20) + + mappings = list(self.data_manager.get_mappings()) + self.assertGreaterEqual(len(mappings), 2) + + self.assertGreater(len(mappings), 0, "at least one mapping must be loaded") + num_rows = len(self.selection_label_listbox.get_children()) + self.assertEqual(len(mappings), num_rows, "all mappimgs must be in the listbox") + + text0 = mappings[0].format_name() + self.mapping_filter_input.set_text(text0) + GtkKeyEvent(Gdk.KEY_Escape).emit_to(self.mapping_filter_input) + self.assertEqual(self.data_manager.active_mapping.format_name(), text0) + + text1 = mappings[1].format_name() + self.mapping_filter_input.set_text(text1) + GtkKeyEvent(Gdk.KEY_Escape).emit_to(self.mapping_filter_input) + self.assertEqual(self.data_manager.active_mapping.format_name(), text1) + + self.mapping_filter_btn.clicked() + self.assertEqual(self.mapping_filter_input.get_text(), "") + def test_check_for_unknown_symbols(self): status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon")