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