Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
07065cb
add listbox filter component and use to for filtering mappings
Feb 17, 2023
3d47406
add missing can_emit method, used in tests
Feb 18, 2023
bce087d
use help func to create combination
Feb 18, 2023
3f3904d
add tests for listbox filter
Feb 18, 2023
b782024
cleanup and black format
Feb 19, 2023
6be2700
remove debug log
Feb 19, 2023
d8e07ae
Merge branch 'beta' into feature/mapping-filter
ubunatic Feb 19, 2023
5460f0c
add Gtk.ListBox filter
Feb 21, 2023
5abf431
split mapping filter in list control and entry control
Feb 21, 2023
b2b0c46
override all optional args, fix format
Feb 21, 2023
eee1752
remove key event test
Feb 21, 2023
d06dd8d
use GtkSearchEntry, rm clear button
Feb 21, 2023
ecc7930
moving the search to a second row
sezanzeb Feb 21, 2023
2a8075f
fixed horizontal resizing
sezanzeb Feb 22, 2023
00fec91
Update inputremapper/gui/components/gtkext/listbox_filter.py
ubunatic Feb 22, 2023
5757af2
Update tests/integration/test_gui.py
ubunatic Feb 22, 2023
f0b0bf5
use GTK_PATH to allow overriding the data dir for glade and style loa…
Feb 22, 2023
69463e6
Merge branch 'feature/mapping-filter-generic' of github.com:ubunatic/…
Feb 22, 2023
817c3d9
black fmt
Feb 22, 2023
d8461fe
Update inputremapper/gui/components/gtkext/listbox_filter.py
ubunatic Feb 22, 2023
dc6b59d
remove root flag, use pkexec only on failure
Feb 23, 2023
3026a8e
use env var to ignore pkexec errors
Feb 23, 2023
fe9dd37
remove -R flag, use env var for pkexec error ignoring
ubunatic Feb 24, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions bin/input-remapper-gtk
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
217 changes: 146 additions & 71 deletions data/input-remapper.glade

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion inputremapper/configs/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def _try_standard_locations():
return data


def _try_gtk_path():
# See GTK_PATH docs at https://docs.gtk.org/gtk3/running.html
if os.environ.get("GTK_PATH"):
data = os.path.join(os.environ.get("GTK_PATH"), "data")
if os.path.exists(data):
return data


def _try_python_package_location():
"""Look for the data dir at the packages installation location."""
source = None
Expand Down Expand Up @@ -86,7 +94,9 @@ def get_data_path(filename=""):
# prefix path for data
# https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long

data = _try_python_package_location() or _try_standard_locations()
data = (
_try_gtk_path() or _try_python_package_location() or _try_standard_locations()
)

if data is None:
logger.error("Could not find the application data")
Expand Down
70 changes: 68 additions & 2 deletions inputremapper/gui/components/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@

from gi.repository import Gtk

from typing import Optional
from typing import (
Optional,
Iterator,
)

from inputremapper.configs.mapping import MappingData

Expand All @@ -36,7 +39,11 @@
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import GroupData, PresetData
from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
MappingFilter,
)
from inputremapper.gui.utils import HandlerDisabled


Expand Down Expand Up @@ -173,3 +180,62 @@ def _render(self):
label.append(self._mapping_name or "?")

self._gui.set_label(" / ".join(label))


class FilterControl:
"""Watches a text input to produce filter events.

The following example creates a new ``FilterControl`` for a given ``Gtk.Entry``
for text input. It also sets all optional arguments to override some default behavior.

>>> ListFilterControl(
>>> message_broker,
>>> message_type,
>>> my_gtk_entry,
>>> case_toggle=my_gtk_toggle, # use optional case sensitivity switch
>>> )

"""

def __init__(
self,
message_broker: MessageBroker,
message_type: MessageType,
filter_entry: Gtk.GtkEntry,
case_toggle: Gtk.ToggleButton = None,
):
self._message_broker: MessageBroker = message_broker
self._message_type: MessageType = message_type
self._filter_entry: Gtk.Entry = filter_entry
self._case_toggle: Gtk.ToggleButton = case_toggle

self._filter_value: str = ""
self._case_sensitive = case_toggle is None or case_toggle.get_active()

self._connect_gtk_signals()

self._update()

def _update(self, force=False) -> bool:
old_value = self._filter_value
self._filter_value = (self._filter_entry.get_text() or "").strip()
if force or self._filter_value != old_value:
self._message_broker.publish(
MappingFilter(
filter_value=self._filter_value,
case_sensitive=self._case_sensitive,
)
)

def _connect_gtk_signals(self):
self._filter_entry.connect("changed", self._on_gtk_input_changed)
if self._case_toggle:
self._case_toggle.connect("toggled", self._on_gtk_case_button_toggled)

def _on_gtk_case_button_toggled(self, btn: Gtk.ToggleButton):
self._case_sensitive = btn.get_active()
if self._filter_value != "":
self._update(force=True)

def _on_gtk_input_changed(self, *_):
self._update()
11 changes: 11 additions & 0 deletions inputremapper/gui/components/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@
UInputsData,
PresetData,
CombinationUpdate,
MappingFilter,
)
from inputremapper.gui.utils import HandlerDisabled, Colors
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.input_event import InputEvent
from inputremapper.configs.system_mapping import system_mapping, XKB_KEYCODE_OFFSET
from inputremapper.utils import get_evdev_constant_name
from inputremapper.gui.components.gtkext.listbox_filter import ListBoxFilter

Capabilities = Dict[int, List]

Expand Down Expand Up @@ -148,9 +150,13 @@ def __init__(
self._controller = controller
self._gui = listbox
self._gui.set_sort_func(self._sort_func)
self._mapping_filter = ListBoxFilter(listbox)

self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
self._message_broker.subscribe(
MessageType.mapping_filter, self._on_mapping_filter_changed
)
self._gui.connect("row-selected", self._on_gtk_mapping_selected)

@staticmethod
Expand Down Expand Up @@ -190,6 +196,11 @@ def _on_mapping_changed(self, mapping: MappingData):
if row.combination == combination:
self._gui.select_row(row)

def _on_mapping_filter_changed(self, filter: MappingFilter):
self._mapping_filter.set_filter(
filter.filter_value, case_sensitive=filter.case_sensitive
)

def _on_gtk_mapping_selected(self, _, row: Optional[MappingSelectionLabel]):
if not row:
return
Expand Down
5 changes: 5 additions & 0 deletions inputremapper/gui/components/gtkext/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Module with general Gtk enhancements

All code in this module is indendent from any inputremapper code.
"""
133 changes: 133 additions & 0 deletions inputremapper/gui/components/gtkext/listbox_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2023 sezanzeb <[email protected]>
#
# 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 <https://www.gnu.org/licenses/>.

import gi

from gi.repository import Gtk

from typing import Iterator


class ListBoxFilter:
"""Implements UI-side filtering of list widgets.

The following example creates a new ``ListBoxFilter`` for a given ``Gtk.ListBox``.
It also sets all optional arguments to override some default behavior.

>>> filter = ListBoxFilter(
>>> my_listbox, # Gtk.ListBox to be managed
>>> get_row_name=MyRow.get_name # custom row name getter
>>> filter_value="text" # inital value
>>> case_sensitive=True, # override default:False
>>> )

To apply a filter use `set_filter` as follows.

>>> filter.set_filter("some text")
>>> filter.set_filter("More Text", case_sensitive=True)

"""

MAX_WIDGET_TREE_TEXT_SEARCH_DEPTH = 10

def __init__(
self,
listbox: Gtk.ListBox,
get_row_name=None,
filter_value="",
case_sensitive=False,
):
self._controlled_listbox: Gtk.ListBox = listbox
self._get_row_name = get_row_name or self.get_row_name
self._filter_value: str = ""
self._case_sensitive = False
self.set_filter(filter_value, case_sensitive=case_sensitive)

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

@property
def filter_value(self):
return self._filter_value

@property
def case_sensitive(self):
return self._case_sensitive

def match_filter(self, value: str):
"""Match the current filter_value and filter_options with the given value."""
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 set_filter(self, filter_value: str, case_sensitive=False):
"""Set and apply filter."""
self._filter_value = str(filter_value)
self._case_sensitive = bool(case_sensitive)
self._gtk_apply_filter_to_listbox_children()

def _gtk_apply_filter_to_listbox_children(self):
"""Apply filter to widget tree."""
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()
15 changes: 15 additions & 0 deletions inputremapper/gui/messages/message_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,18 @@ class DoStackSwitch:

message_type = MessageType.do_stack_switch
page_index: int


@dataclass(frozen=True)
class FilterData:
"""Stores filter data for any kind of text-based filter"""

filter_value: str
case_sensitive: bool = False


@dataclass(frozen=True)
class MappingFilter(FilterData):
"""Message sent by the mapping list filter."""

message_type = MessageType.mapping_filter
1 change: 1 addition & 0 deletions inputremapper/gui/messages/message_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class MessageType(Enum):
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
mapping_filter = "mapping_filter"
combination_recorded = "combination_recorded"

# only the reader_client should send those messages:
Expand Down
7 changes: 5 additions & 2 deletions inputremapper/gui/reader_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand Down
Loading