Skip to content
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 <proxima@sezanzeb.de>
#
# 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