Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mapping filter control with dedicated Gtk.ListBox filter manager #628

Open
wants to merge 23 commits into
base: beta
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion bin/input-remapper-gtk
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ 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
)

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):
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
10 changes: 8 additions & 2 deletions inputremapper/gui/reader_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,15 @@ def pkexec_reader_service():

logger.debug("Running `%s`", cmd)
exit_code = os.system(cmd)
if exit_code == 0:
return

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 os.environ.get("IGNORE_PKEXEC_ERRORS"):
logger.warn(ex)
return

raise ex

async def run(self):
"""Start doing stuff."""
Expand Down
11 changes: 9 additions & 2 deletions inputremapper/gui/user_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
)
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, FilterControl
from inputremapper.gui.components.device_groups import DeviceGroupSelection
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
Expand Down Expand Up @@ -149,6 +149,13 @@ def _create_components(self):
MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
TargetSelection(message_broker, controller, self.get("target-selector"))

FilterControl(
message_broker,
MessageType.mapping_filter,
self.get("mapping-filter-input"),
case_toggle=self.get("mapping-filter-case-button"),
)

Breadcrumbs(
message_broker,
self.get("selected_device_name"),
Expand Down Expand Up @@ -366,7 +373,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)

Expand Down
5 changes: 4 additions & 1 deletion inputremapper/injection/global_uinputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand Down
Loading