pull/496/head
Tobi 2 years ago committed by GitHub
parent ce73f786bd
commit 71f8bf4e13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -17,8 +17,6 @@
#### Known Issues (Beta Branch)
* Mapping relative axis to relative axis (mouse to mouse) is not possible
* Mapping relative axis to absolute axis (mouse to gamepad) is not possible
* Mapping absolute axis to absolute axis (gamepad to gamepad) is not possible
## Installation

@ -74,7 +74,7 @@ if __name__ == '__main__':
logger.debug('Using locale directory: {}'.format(LOCALE_DIR))
# import input-remapper stuff after setting the log verbosity
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.data_manager import DataManager
from inputremapper.gui.user_interface import UserInterface

File diff suppressed because it is too large Load Diff

@ -1,63 +1,16 @@
row {
padding: 0;
}
.status_bar frame {
/* the status bar is ugly in elementary os otherwise */
border: 0px;
}
/* adds a bottom border for themes that don't add one in primary-toolbar
classes. Interestingly, those that do add a border ignore this separator,
which is perfect. */
.top_separator {
border: 0px; /* fixes light pixels on each end in arc-dark */
}
.table-header, .row-box {
padding: 2px;
}
.changed {
background: @selected_bg_color;
}
list entry {
border-radius: 4px;
border: 0px;
box-shadow: none;
}
list.basic-editor button:not(:focus) {
border-color: transparent;
background: transparent;
box-shadow: none;
}
list button {
border-color: transparent;
}
.invalid_input {
background-color: #ea9697;
}
.transparent {
background: transparent;
}
.code-editor-text-view > * {
border-radius: 2px;
}
.copyright {
font-size: 7pt;
}
.editor-key-list label {
padding: 11px;
}
.autocompletion label {
padding: 11px;
}
@ -67,19 +20,19 @@ list button {
box-shadow: none;
}
.no-border {
border: 0px;
box-shadow: none;
}
.code-editor-text-view.multiline {
/* extra space between text editor and line numbers */
padding-left: 18px;
}
.no-v-padding{
.no-v-padding {
padding-top: 0;
padding-bottom: 0;
}
/* @theme_bg_color, @theme_fg_color */
.transformation-draw-area {
border: 1px solid @borders;
border-radius: 6px;
background: @theme_base_color;
}
/*
@theme_bg_color
@theme_selected_bg_color
@theme_base_color
*/

@ -40,18 +40,22 @@ from pydantic import (
from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import MacroParsingError
from inputremapper.gui.message_broker import MessageType
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.gui.gettext import _
from inputremapper.injection.macros.parse import is_this_a_macro, parse
from inputremapper.input_event import EventActions
# TODO: remove pydantic VERSION check as soon as we no longer support
# Ubuntu 20.04 and with it the ainchant pydantic 1.2
# Ubuntu 20.04 and with it the ancient pydantic 1.2
needs_workaround = pkg_resources.parse_version(
str(VERSION)
) < pkg_resources.parse_version("1.7.1")
EMPTY_MAPPING_NAME = _("Empty Mapping")
class KnownUinput(str, enum.Enum):
keyboard = "keyboard"
mouse = "mouse"
@ -69,7 +73,7 @@ class Cfg(BaseConfig):
validate_assignment = True
use_enum_values = True
underscore_attrs_are_private = True
json_encoders = {EventCombination: lambda v: v.json_str()}
json_encoders = {EventCombination: lambda v: v.json_key()}
class ImmutableCfg(Cfg):
@ -180,6 +184,23 @@ class UIMapping(BaseModel):
object.__setattr__(copy, "_combination_changed", self._combination_changed)
return copy
def format_name(self) -> str:
"""Get the custom-name or a readable representation of the combination."""
if self.name:
return self.name
if (
self.event_combination == EventCombination.empty_combination()
or self.event_combination is None
):
return EMPTY_MAPPING_NAME
return self.event_combination.beautify()
def has_input_defined(self) -> bool:
"""Whether this mapping defines an event-input."""
return self.event_combination != EventCombination.empty_combination()
def is_axis_mapping(self) -> bool:
"""whether this mapping specifies an output axis"""
return self.output_type == EV_ABS or self.output_type == EV_REL
@ -214,7 +235,7 @@ class UIMapping(BaseModel):
return None
def get_bus_message(self) -> MappingData:
"""return a immutable copy for use in the"""
"""return an immutable copy for use in the message broker"""
return MappingData(**self.dict())
@root_validator
@ -266,7 +287,7 @@ class Mapping(UIMapping):
if system_mapping.get(symbol) is not None:
return symbol
raise ValueError(
f"the output_symbol '{symbol}' is not a macro and not a valid keycode-name"
f'the output_symbol "{symbol}" is not a macro and not a valid keycode-name'
)
@validator("event_combination")

@ -268,7 +268,7 @@ def _convert_to_individual_mappings():
if "mapping" in old_preset.keys():
for combination, symbol_target in old_preset["mapping"].items():
logger.info(
f"migrating from '{combination}: {symbol_target}' to mapping dict"
f'migrating from "{combination}: {symbol_target}" to mapping dict'
)
try:
combination = EventCombination.from_string(combination)

@ -209,7 +209,7 @@ class Preset(Generic[MappingModel]):
del d["event_combination"]
except KeyError:
pass
json_ready[combination.json_str()] = d
json_ready[combination.json_key()] = d
saved_mappings[combination] = mapping.copy()
saved_mappings[combination].remove_combination_changed_callback()

@ -145,8 +145,9 @@ class EventCombination(Tuple[InputEvent]):
return permutations
def json_str(self) -> str:
return "+".join([event.json_str() for event in self])
def json_key(self) -> str:
"""Get a representation of the input that works as key in a json object."""
return "+".join([event.json_key() for event in self])
def beautify(self) -> str:
"""Get a human readable string representation."""

@ -30,8 +30,9 @@ from gi.repository import Gdk, Gtk, GLib, GObject
from inputremapper.configs.mapping import MappingData
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.components import CodeEditor
from inputremapper.gui.message_broker import MessageBroker, MessageType, UInputsData
from inputremapper.gui.components.editor import CodeEditor
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_data import UInputsData
from inputremapper.gui.utils import debounce
from inputremapper.injection.macros.parse import (
FUNCTIONS,
@ -293,19 +294,43 @@ class Autocompletion(Gtk.Popover):
# to write code is possible), so here is a custom solution.
row_height = row.get_allocation().height
list_box_height = self.list_box.get_allocated_height()
if row:
y_offset = row.translate_coordinates(self.list_box, 0, 0)[1]
# get coordinate relative to the list_box,
# measured from the top of the selected row to the top of the list_box
row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1]
# Depending on the theme, the y_offset will be > 0, even though it
# is the uppermost element, due to margins/paddings.
if row_y_position < row_height:
row_y_position = 0
# if the selected row sits lower than the second to last row,
# then scroll all the way down. otherwise it will only scroll down
# to the bottom edge of the selected-row, which might not actually be the
# bottom of the list-box due to paddings.
if row_y_position > list_box_height - row_height * 1.5:
# using a value that is too high doesn't hurt here.
row_y_position = list_box_height
# the visible height of the scrolled_window. not the content.
height = self.scrolled_window.get_max_content_height()
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
vadjustment = self.scrolled_window.get_vadjustment()
if y_offset > current_y_scroll + (height - row_height):
vadjustment.set_value(y_offset - (height - row_height))
# for the selected row to still be visible, its y_offset has to be
# at height - row_height. If the y_offset is higher than that, then
# the autocompletion needs to scroll down to make it visible again.
if row_y_position > current_y_scroll + (height - row_height):
value = row_y_position - (height - row_height)
vadjustment.set_value(value)
if y_offset < current_y_scroll:
# scroll up because the element is not visible anymore
vadjustment.set_value(y_offset)
if row_y_position < current_y_scroll:
# the selected element is not visiable, so we need to scroll up.
vadjustment.set_value(row_y_position)
def _get_text_iter_at_cursor(self):
"""Get Gtk.TextIter at the current text cursor location."""
@ -341,7 +366,7 @@ class Autocompletion(Gtk.Popover):
cursor.y += 12
if self.code_editor.gui.get_show_line_numbers():
cursor.x += 25
cursor.x += 48
self.set_pointing_to(cursor)

@ -0,0 +1,176 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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/>.
"""Components used in multiple places."""
from __future__ import annotations
from gi.repository import Gtk
from typing import Optional
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
from inputremapper.gui.controller import Controller
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import GroupData, PresetData
from inputremapper.gui.utils import HandlerDisabled
class FlowBoxEntry(Gtk.ToggleButton):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "FlowBoxEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
name: str,
icon_name: Optional[str] = None,
):
super().__init__()
self.icon_name = icon_name
self.message_broker = message_broker
self._controller = controller
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if icon_name:
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
box.add(icon)
label = Gtk.Label()
label.set_label(name)
self.name = name
# wrap very long names properly
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
# this affeects how many device entries fit next to each other
label.set_width_chars(28)
label.set_max_width_chars(28)
box.add(label)
box.set_margin_top(18)
box.set_margin_bottom(18)
box.set_homogeneous(True)
box.set_spacing(12)
# self.set_relief(Gtk.ReliefStyle.NONE)
self.add(box)
self.show_all()
self.connect("toggled", self._on_gtk_toggle)
def _on_gtk_toggle(self):
raise NotImplementedError
def show_active(self, active):
"""Show the active state without triggering anything."""
with HandlerDisabled(self, self._on_gtk_toggle):
self.set_active(active)
class FlowBoxWrapper:
"""A wrapper for a flowbox that contains FlowBoxEntry widgets."""
def __init__(self, flowbox: Gtk.FlowBox):
self._gui = flowbox
def show_active_entry(self, name: Optional[str]):
"""Activate the togglebutton that matches the name."""
for child in self._gui.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
flow_box_entry.show_active(flow_box_entry.name == name)
class Breadcrumbs:
"""Writes a breadcrumbs string into a given label."""
def __init__(
self,
message_broker: MessageBroker,
label: Gtk.Label,
show_device_group: bool = False,
show_preset: bool = False,
show_mapping: bool = False,
):
self._message_broker = message_broker
self._gui = label
self._connect_message_listener()
self.show_device_group = show_device_group
self.show_preset = show_preset
self.show_mapping = show_mapping
self._group_key: str = ""
self._preset_name: str = ""
self._mapping_name: str = ""
label.set_max_width_chars(50)
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
self._render()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
def _on_preset_changed(self, data: PresetData):
self._preset_name = data.name or ""
self._render()
def _on_group_changed(self, data: GroupData):
self._group_key = data.group_key
self._render()
def _on_mapping_changed(self, mapping_data: MappingData):
self._mapping_name = mapping_data.format_name()
self._render()
def _render(self):
label = []
if self.show_device_group:
label.append(self._group_key or "?")
if self.show_preset:
label.append(self._preset_name or "?")
if self.show_mapping:
label.append(self._mapping_name or "?")
self._gui.set_label(" / ".join(label))

@ -0,0 +1,112 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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/>.
from __future__ import annotations
from typing import Optional
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupsData,
GroupData,
DoStackSwitch,
)
from inputremapper.logger import logger
class DeviceGroupEntry(FlowBoxEntry):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "DeviceGroupEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
icon_name: Optional[str],
group_key: str,
):
super().__init__(
message_broker=message_broker,
controller=controller,
icon_name=icon_name,
name=group_key,
)
self.group_key = group_key
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting device "%s"', self.group_key)
self._controller.load_group(self.group_key)
self.message_broker.send(DoStackSwitch(Stack.presets_page))
class DeviceGroupSelection(FlowBoxWrapper):
"""A wrapper for the container with our groups.
A group is a collection of devices.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
def _on_groups_changed(self, data: GroupsData):
self._gui.foreach(lambda group: self._gui.remove(group))
for group_key, types in data.groups.items():
if len(types) > 0:
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
icon_name = ICON_NAMES[device_type]
else:
icon_name = None
logger.debug(f"adding {group_key} to device selection")
device_group_entry = DeviceGroupEntry(
self._message_broker,
self._controller,
icon_name,
group_key,
)
self._gui.insert(device_group_entry, -1)
def _on_group_changed(self, data: GroupData):
self.show_active_entry(data.group_key)

@ -18,6 +18,10 @@
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""All components that control a single preset."""
from __future__ import annotations
from collections import defaultdict
@ -32,18 +36,16 @@ from inputremapper.event_combination import EventCombination
from inputremapper.groups import DeviceType
from inputremapper.gui.controller import Controller
from inputremapper.gui.gettext import _
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
GroupsData,
GroupData,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
PresetData,
StatusData,
CombinationUpdate,
UserConfirmRequest,
)
from inputremapper.gui.utils import HandlerDisabled, CTX_ERROR, CTX_MAPPING, CTX_WARNING
from inputremapper.gui.utils import HandlerDisabled, Colors
from inputremapper.injection.mapping_handlers.axis_transform import Transformation
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
@ -51,7 +53,6 @@ from inputremapper.logger import logger
Capabilities = Dict[int, List]
SET_KEY_FIRST = _("Record the input first")
EMPTY_MAPPING_NAME = _("Empty Mapping")
ICON_NAMES = {
DeviceType.GAMEPAD: "input-gaming",
@ -73,60 +74,11 @@ ICON_PRIORITIES = [
]
class DeviceSelection:
"""the dropdown menu to select the active_group"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
combobox: Gtk.ComboBox,
):
self._message_broker = message_broker
self._controller = controller
self._device_store = Gtk.ListStore(str, str, str)
self._gui = combobox
# https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view
combobox.set_model(self._device_store)
renderer_icon = Gtk.CellRendererPixbuf()
renderer_text = Gtk.CellRendererText()
renderer_text.set_padding(5, 0)
combobox.pack_start(renderer_icon, False)
combobox.pack_start(renderer_text, False)
combobox.add_attribute(renderer_icon, "icon-name", 1)
combobox.add_attribute(renderer_text, "text", 2)
combobox.set_id_column(0)
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
combobox.connect("changed", self._on_gtk_select_device)
def _on_groups_changed(self, data: GroupsData):
with HandlerDisabled(self._gui, self._on_gtk_select_device):
self._device_store.clear()
for group_key, types in data.groups.items():
if len(types) > 0:
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
icon_name = ICON_NAMES[device_type]
else:
icon_name = None
logger.debug(f"adding {group_key} to device dropdown ")
self._device_store.append([group_key, icon_name, group_key])
def _on_group_changed(self, data: GroupData):
with HandlerDisabled(self._gui, self._on_gtk_select_device):
self._gui.set_active_id(data.group_key)
def _on_gtk_select_device(self, *_, **__):
group_key = self._gui.get_active_id()
logger.debug('Selecting device "%s"', group_key)
self._controller.load_group(group_key)
class TargetSelection:
"""the dropdown menu to select the targe_uinput of the active_mapping"""
"""The dropdown menu to select the targe_uinput of the active_mapping,
For example "keyboard" or "gamepad".
"""
def __init__(
self,
@ -154,63 +106,14 @@ class TargetSelection:
self._gui.set_id_column(0)
def _on_mapping_loaded(self, mapping: MappingData):
if not self._controller.is_empty_mapping():
self._enable()
else:
self._disable()
with HandlerDisabled(self._gui, self._on_gtk_target_selected):
self._gui.set_active_id(mapping.target_uinput)
def _enable(self):
self._gui.set_sensitive(True)
self._gui.set_opacity(1)
def _disable(self):
self._gui.set_sensitive(False)
self._gui.set_opacity(0.5)
def _on_gtk_target_selected(self, *_):
target = self._gui.get_active_id()
self._controller.update_mapping(target_uinput=target)
class PresetSelection:
"""the dropdown menu to select the active_preset"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
combobox: Gtk.ComboBoxText,
):
self._message_broker = message_broker
self._controller = controller
self._gui = combobox
self._connect_message_listener()
combobox.connect("changed", self._on_gtk_select_preset)
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
def _on_group_changed(self, data: GroupData):
with HandlerDisabled(self._gui, self._on_gtk_select_preset):
self._gui.remove_all()
for preset in data.presets:
self._gui.append(preset, preset)
def _on_preset_changed(self, data: PresetData):
with HandlerDisabled(self._gui, self._on_gtk_select_preset):
self._gui.set_active_id(data.name)
def _on_gtk_select_preset(self, *_, **__):
name = self._gui.get_active_id()
logger.debug('Selecting preset "%s"', name)
self._controller.load_preset(name)
class MappingListBox:
"""the listbox showing all available mapping in the active_preset"""
@ -230,7 +133,7 @@ class MappingListBox:
self._gui.connect("row-selected", self._on_gtk_mapping_selected)
@staticmethod
def _sort_func(row1: SelectionLabel, row2: SelectionLabel) -> int:
def _sort_func(row1: MappingSelectionLabel, row2: MappingSelectionLabel) -> int:
"""sort alphanumerical by name"""
if row1.combination == EventCombination.empty_combination():
return 1
@ -244,9 +147,12 @@ class MappingListBox:
if not data.mappings:
return
for name, combination in data.mappings:
selection_label = SelectionLabel(
self._message_broker, self._controller, name, combination
for mapping in data.mappings:
selection_label = MappingSelectionLabel(
self._message_broker,
self._controller,
mapping.format_name(),
mapping.event_combination,
)
self._gui.insert(selection_label, -1)
self._gui.invalidate_sort()
@ -255,22 +161,22 @@ class MappingListBox:
with HandlerDisabled(self._gui, self._on_gtk_mapping_selected):
combination = mapping.event_combination
def set_active(row: SelectionLabel):
def set_active(row: MappingSelectionLabel):
if row.combination == combination:
self._gui.select_row(row)
self._gui.foreach(set_active)
def _on_gtk_mapping_selected(self, _, row: Optional[SelectionLabel]):
def _on_gtk_mapping_selected(self, _, row: Optional[MappingSelectionLabel]):
if not row:
return
self._controller.load_mapping(row.combination)
class SelectionLabel(Gtk.ListBoxRow):
class MappingSelectionLabel(Gtk.ListBoxRow):
"""the ListBoxRow representing a mapping inside the MappingListBox"""
__gtype_name__ = "SelectionLabel"
__gtype_name__ = "MappingSelectionLabel"
def __init__(
self,
@ -282,7 +188,11 @@ class SelectionLabel(Gtk.ListBoxRow):
super().__init__()
self._message_broker = message_broker
self._controller = controller
self._name = name
if not name:
name = combination.beautify()
self.name = name
self.combination = combination
# Make the child label widget break lines, important for
@ -294,6 +204,9 @@ class SelectionLabel(Gtk.ListBoxRow):
# set the name or combination.beautify as label
self.label.set_label(self.name)
self.label.set_margin_top(11)
self.label.set_margin_bottom(11)
# button to edit the name of the mapping
self.edit_btn = Gtk.Button()
self.edit_btn.set_relief(Gtk.ReliefStyle.NONE)
@ -307,7 +220,7 @@ class SelectionLabel(Gtk.ListBoxRow):
self.name_input = Gtk.Entry()
self.name_input.set_text(self.name)
self.name_input.set_width_chars(12)
self.name_input.set_halign(Gtk.Align.FILL)
self.name_input.set_margin_top(4)
self.name_input.set_margin_bottom(4)
self.name_input.connect("activate", self._on_gtk_rename_finished)
@ -318,7 +231,7 @@ class SelectionLabel(Gtk.ListBoxRow):
self._box.add(self.edit_btn)
self._box.set_child_packing(self.edit_btn, False, False, 4, Gtk.PackType.END)
self._box.add(self.name_input)
self._box.set_child_packing(self.name_input, False, True, 4, Gtk.PackType.START)
self._box.set_child_packing(self.name_input, True, True, 4, Gtk.PackType.START)
self.add(self._box)
self.show_all()
@ -331,16 +244,7 @@ class SelectionLabel(Gtk.ListBoxRow):
self.name_input.hide()
def __repr__(self):
return f"SelectionLabel for {self.combination} as {self.name}"
@property
def name(self) -> str:
if (
self.combination == EventCombination.empty_combination()
or self.combination is None
):
return EMPTY_MAPPING_NAME
return self._name or self.combination.beautify()
return f"MappingSelectionLabel for {self.combination} as {self.name}"
def _set_not_selected(self):
self.edit_btn.hide()
@ -363,7 +267,7 @@ class SelectionLabel(Gtk.ListBoxRow):
if mapping.event_combination != self.combination:
self._set_not_selected()
return
self._name = mapping.name
self.name = mapping.format_name()
self._set_selected()
self.get_parent().invalidate_sort()
@ -375,7 +279,7 @@ class SelectionLabel(Gtk.ListBoxRow):
name = self.name_input.get_text()
if name.lower().strip() == self.combination.beautify().lower():
name = ""
self._name = name
self.name = name
self._set_selected()
self._controller.update_mapping(name=name)
@ -447,26 +351,16 @@ class CodeEditor:
"""Show line numbers if multiline, otherwise remove them"""
if "\n" in self.code:
self.gui.set_show_line_numbers(True)
# adds a bit of space between numbers and text:
self.gui.set_show_line_marks(True)
self.gui.set_monospace(True)
self.gui.get_style_context().add_class("multiline")
else:
self.gui.set_show_line_numbers(False)
self.gui.set_show_line_marks(False)
self.gui.set_monospace(False)
self.gui.get_style_context().remove_class("multiline")
def _enable(self):
logger.debug("Enabling the code editor")
self.gui.set_sensitive(True)
self.gui.set_opacity(1)
def _disable(self):
logger.debug("Disabling the code editor")
# beware that this also appeared to disable event listeners like
# focus-out-event:
self.gui.set_sensitive(False)
self.gui.set_opacity(0.5)
def _on_gtk_focus_out(self, *_):
self._controller.save()
@ -477,18 +371,70 @@ class CodeEditor:
code = SET_KEY_FIRST
if not self._controller.is_empty_mapping():
code = mapping.output_symbol or ""
self._enable()
else:
self._disable()
if self.code.strip().lower() != code.strip().lower():
self.code = code
self._toggle_line_numbers()
def _on_recording_finished(self, _):
self._controller.set_focus(self.gui)
class RequireActiveMapping:
"""Disable the widget if no mapping is selected."""
def __init__(
self,
message_broker: MessageBroker,
widget: Gtk.ToggleButton,
require_recorded_input: False,
):
self._widget = widget
self._default_tooltip = self._widget.get_tooltip_text()
self._require_recorded_input = require_recorded_input
self._active_preset: Optional[PresetData] = None
self._active_mapping: Optional[MappingData] = None
message_broker.subscribe(MessageType.preset, self._on_preset)
message_broker.subscribe(MessageType.mapping, self._on_mapping)
def _on_preset(self, preset_data: PresetData):
self._active_preset = preset_data
self._check()
def _on_mapping(self, mapping_data: MappingData):
self._active_mapping = mapping_data
self._check()
def _check(self, *__):
if not self._active_preset or len(self._active_preset.mappings) == 0:
self._disable()
self._widget.set_tooltip_text(_("Add a mapping first"))
return
if (
self._require_recorded_input
and self._active_mapping
and not self._active_mapping.has_input_defined()
):
self._disable()
self._widget.set_tooltip_text(_("Record input first"))
return
self._enable()
self._widget.set_tooltip_text(self._default_tooltip)
def _enable(self):
self._widget.set_sensitive(True)
self._widget.set_opacity(1)
def _disable(self):
self._widget.set_sensitive(False)
self._widget.set_opacity(0.5)
class RecordingToggle:
"""the toggle used to record the input form the active_group in order to update the
event_combination of the active_mapping"""
@ -509,105 +455,52 @@ class RecordingToggle:
# be recorded, instead of causing the recording to stop.
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
self._message_broker.subscribe(
MessageType.recording_finished, self._on_recording_finished
MessageType.recording_finished,
self._on_recording_finished,
)
self._update_label(_("Record Input"))
def _update_label(self, msg: str):
self._gui.set_label(msg)
RequireActiveMapping(
message_broker,
toggle,
require_recorded_input=False,
)
def _on_gtk_toggle(self, *__):
if self._gui.get_active():
self._update_label(_("Recording ..."))
self._controller.start_key_recording()
else:
self._update_label(_("Record Input"))
self._controller.stop_key_recording()
def _on_recording_finished(self, __):
logger.debug("finished recording")
with HandlerDisabled(self._gui, self._on_gtk_toggle):
self._gui.set_active(False)
self._update_label(_("Record Input"))
class StatusBar:
"""the status bar on the bottom of the main window"""
class RecordingStatus:
"""Displays if keys are being recorded for a mapping."""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
status_bar: Gtk.Statusbar,
error_icon: Gtk.Image,
warning_icon: Gtk.Image,
label: Gtk.Label,
):
self._message_broker = message_broker
self._controller = controller
self._gui = status_bar
self._error_icon = error_icon
self._warning_icon = warning_icon
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
self._message_broker.subscribe(MessageType.init, self._on_init)
# keep track if there is an error or warning in the stack of statusbar
# unfortunately this is not exposed over the api
self._error = False
self._warning = False
def _on_init(self, _):
self._error_icon.hide()
self._warning_icon.hide()
def _on_status_update(self, data: StatusData):
"""Show a status message and set its tooltip.
If message is None, it will remove the newest message of the
given context_id.
"""
context_id = data.ctx_id
message = data.msg
tooltip = data.tooltip
status_bar = self._gui
if message is None:
status_bar.remove_all(context_id)
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.hide()
self._error = False
if self._warning:
self._warning_icon.show()
if context_id == CTX_WARNING:
self._warning_icon.hide()
self._warning = False
if self._error:
self._error_icon.show()
status_bar.set_tooltip_text("")
else:
if tooltip is None:
tooltip = message
self._gui = label
self._error_icon.hide()
self._warning_icon.hide()
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.show()
self._error = True
message_broker.subscribe(
MessageType.recording_started,
self._on_recording_started,
)
if context_id == CTX_WARNING:
self._warning_icon.show()
self._warning = True
message_broker.subscribe(
MessageType.recording_finished,
self._on_recording_finished,
)
max_length = 45
if len(message) > max_length:
message = message[: max_length - 3] + "..."
def _on_recording_started(self, _):
self._gui.set_visible(True)
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)
def _on_recording_finished(self, _):
self._gui.set_visible(False)
class AutoloadSwitch:
@ -763,16 +656,10 @@ class CombinationListbox:
self._select_row(event)
def _on_gtk_row_selected(self, *_):
row: Optional[EventEntry] = None
def find_row(r: EventEntry):
nonlocal row
if r.is_selected():
row = r
self._gui.foreach(find_row)
if row:
self._controller.load_event(row.input_event)
for row in self._gui.get_children():
if row.is_selected():
self._controller.load_event(row.input_event)
break
class AnalogInputSwitch:
@ -989,33 +876,6 @@ class OutputAxisSelector:
)
class ConfirmCancelDialog:
"""the dialog shown to the user to query a confirm or cancel action form the user"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
gui: Gtk.Dialog,
label: Gtk.Label,
):
self._message_broker = message_broker
self._controller = controller
self._gui = gui
self._label = label
self._message_broker.subscribe(
MessageType.user_confirm_request, self._on_user_confirm_request
)
def _on_user_confirm_request(self, msg: UserConfirmRequest):
self._label.set_label(msg.msg)
self._gui.show()
response = self._gui.run()
self._gui.hide()
msg.respond(response == Gtk.ResponseType.ACCEPT)
class KeyAxisStackSwitcher:
"""the controls used to switch between the gui to modify a key-mapping or
an analog-axis mapping"""
@ -1062,7 +922,10 @@ class KeyAxisStackSwitcher:
self._set_active("key_macro")
def _on_gtk_toggle(self, btn: Gtk.ToggleButton):
if not btn.get_active():
# get_active returns the new toggle state already
was_active = not btn.get_active()
if was_active:
# cannot deactivate manually
with HandlerDisabled(btn, self._on_gtk_toggle):
btn.set_active(True)
@ -1101,23 +964,14 @@ class TransformationDrawArea:
def _on_gtk_draw(self, _, context: cairo.Context):
points = [
(x / 200 + 0.5, -0.5 * self._transformation(x) + 0.5)
for x in range(-100, 100)
# leave some space left and right for the lineCap to be visible
for x in range(-97, 97)
]
w = self._gui.get_allocated_width()
h = self._gui.get_allocated_height()
b = min((w, h))
scaled_points = [(x * b, y * b) for x, y in points]
# box
context.move_to(0, 0)
context.line_to(0, b)
context.line_to(b, b)
context.line_to(b, 0)
context.line_to(0, 0)
context.set_line_width(1)
context.set_source_rgb(0.7, 0.7, 0.7)
context.stroke()
# x arrow
context.move_to(0 * b, 0.5 * b)
context.line_to(1 * b, 0.5 * b)
@ -1133,7 +987,13 @@ class TransformationDrawArea:
context.line_to(0.52 * b, 0.04 * b)
context.set_line_width(2)
context.set_source_rgb(0.5, 0.5, 0.5)
arrow_color = Gdk.RGBA(0.5, 0.5, 0.5, 0.2)
context.set_source_rgba(
arrow_color.red,
arrow_color.green,
arrow_color.blue,
arrow_color.alpha,
)
context.stroke()
# graph
@ -1142,8 +1002,16 @@ class TransformationDrawArea:
# Ploting point
context.line_to(*p)
context.set_line_width(2)
context.set_source_rgb(0.2, 0.2, 1)
line_color = Colors.get_accent_color()
context.set_line_width(3)
context.set_line_cap(cairo.LineCap.ROUND)
# the default gtk adwaita highlight color:
context.set_source_rgba(
line_color.red,
line_color.green,
line_color.blue,
line_color.alpha,
)
context.stroke()

@ -0,0 +1,139 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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/>.
"""Components that wrap everything."""
from __future__ import annotations
from gi.repository import Gtk
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch
from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING
class Stack:
"""Wraps the Stack, which contains the main menu pages."""
devices_page = 0
presets_page = 1
editor_page = 2
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
stack: Gtk.Stack,
):
self._message_broker = message_broker
self._controller = controller
self._gui = stack
self._message_broker.subscribe(
MessageType.do_stack_switch, self._do_stack_switch
)
def _do_stack_switch(self, msg: DoStackSwitch):
self._gui.set_visible_child(self._gui.get_children()[msg.page_index])
class StatusBar:
"""the status bar on the bottom of the main window"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
status_bar: Gtk.Statusbar,
error_icon: Gtk.Image,
warning_icon: Gtk.Image,
):
self._message_broker = message_broker
self._controller = controller
self._gui = status_bar
self._error_icon = error_icon
self._warning_icon = warning_icon
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
self._message_broker.subscribe(MessageType.init, self._on_init)
# keep track if there is an error or warning in the stack of statusbar
# unfortunately this is not exposed over the api
self._error = False
self._warning = False
def _on_init(self, _):
self._error_icon.hide()
self._warning_icon.hide()
def _on_status_update(self, data: StatusData):
"""Show a status message and set its tooltip.
If message is None, it will remove the newest message of the
given context_id.
"""
context_id = data.ctx_id
message = data.msg
tooltip = data.tooltip
status_bar = self._gui
if message is None:
status_bar.remove_all(context_id)
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.hide()
self._error = False
if self._warning:
self._warning_icon.show()
if context_id == CTX_WARNING:
self._warning_icon.hide()
self._warning = False
if self._error:
self._error_icon.show()
status_bar.set_tooltip_text("")
else:
if tooltip is None:
tooltip = message
self._error_icon.hide()
self._warning_icon.hide()
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.show()
self._error = True
if context_id == CTX_WARNING:
self._warning_icon.show()
self._warning = True
max_length = 135
if len(message) > max_length:
message = message[: max_length - 3] + "..."
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)

@ -0,0 +1,107 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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/>.
"""All components that are visible on the page that shows all the presets."""
from __future__ import annotations
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
DoStackSwitch,
)
from inputremapper.logger import logger
class PresetEntry(FlowBoxEntry):
"""A preset that can be selected in the GUI."""
__gtype_name__ = "PresetEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
preset_name: str,
):
super().__init__(
message_broker=message_broker, controller=controller, name=preset_name
)
self.preset_name = preset_name
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting preset "%s"', self.preset_name)
self._controller.load_preset(self.preset_name)
self.message_broker.send(DoStackSwitch(Stack.editor_page))
class PresetSelection(FlowBoxWrapper):
"""A wrapper for the container with our presets.
Selectes the active_preset.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._connect_message_listener()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
def _on_group_changed(self, data: GroupData):
self._gui.foreach(lambda preset: self._gui.remove(preset))
for preset_name in data.presets:
preset_entry = PresetEntry(
self._message_broker,
self._controller,
preset_name,
)
self._gui.insert(preset_entry, -1)
def _on_preset_changed(self, data: PresetData):
self.show_active_entry(data.name)
def set_active_preset(self, preset_name: str):
"""Change the currently selected preset."""
# TODO might only be needed in tests
for child in self._gui.get_children():
preset_entry: PresetEntry = child.get_children()[0]
preset_entry.set_active(preset_entry.preset_name == preset_name)

@ -41,6 +41,17 @@ from inputremapper.exceptions import DataManagementError
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
from inputremapper.gui.gettext import _
from inputremapper.gui.helper import is_helper_running
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
PresetData,
StatusData,
CombinationRecorded,
UserConfirmRequest,
DoStackSwitch,
)
from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
from inputremapper.injection.injector import (
RUNNING,
@ -53,14 +64,6 @@ from inputremapper.injection.injector import (
)
from inputremapper.input_event import InputEvent
from inputremapper.logger import logger
from inputremapper.gui.message_broker import (
MessageBroker,
MessageType,
PresetData,
StatusData,
CombinationRecorded,
UserConfirmRequest,
)
if TYPE_CHECKING:
# avoids gtk import error in tests
@ -116,8 +119,12 @@ class Controller:
"""load a mapping as soon as everyone got notified about the new preset"""
if data.mappings:
mappings = list(data.mappings)
mappings.sort(key=lambda t: t[0] or t[1].beautify())
combination = mappings[0][1]
mappings.sort(
key=lambda mapping: (
mapping.format_name() or mapping.event_combination.beautify()
)
)
combination = mappings[0].event_combination
self.load_mapping(combination)
self.load_event(combination[0])
else:
@ -139,7 +146,7 @@ class Controller:
if not mapping.get_error():
continue
position = mapping.name or mapping.event_combination.beautify()
position = mapping.format_name()
msg = _("Mapping error at %s, hover for info") % position
self.show_status(CTX_MAPPING, msg, self._get_ui_error_string(mapping))
@ -167,7 +174,7 @@ class Controller:
)
if "missing output axis:" in error_string:
message = _(
"The input specifies a analog axis, but no output axis is selected."
"The input specifies an analog axis, but no output axis is selected."
)
if mapping.output_symbol is not None:
event = [
@ -175,9 +182,9 @@ class Controller:
][0]
message += _(
"\nIf you mean to create a key or macro mapping "
"go to the 'Advanced Input Configuration'"
" and set a 'Trigger Threshold' for "
f"{event.description()}"
"go to the advanced input configuration"
' and set a "Trigger Threshold" for '
f'"{event.description()}"'
)
return message
@ -188,8 +195,8 @@ class Controller:
)
if mapping.output_type in (EV_ABS, EV_REL):
message += _(
"\nIf you mean to create a analog axis mapping go to the "
"'Advanced Input Configuration' and set a input to 'Use as Analog'."
"\nIf you mean to create an analog axis mapping go to the "
'advanced input configuration and set a input to "Use as Analog".'
)
return message
@ -226,6 +233,7 @@ class Controller:
self.data_manager.copy_preset(
self.data_manager.get_available_preset_name(f"{name} copy")
)
self.message_broker.send(DoStackSwitch(1))
def update_combination(self, combination: EventCombination):
"""update the event_combination of the active mapping"""
@ -233,7 +241,10 @@ class Controller:
self.data_manager.update_mapping(event_combination=combination)
self.save()
except KeyError:
# the combination was a duplicate
self.show_status(
CTX_MAPPING,
f'"{combination.beautify()}" already mapped to something else',
)
return
if combination.is_problematic():
@ -385,11 +396,12 @@ class Controller:
if answer:
self.data_manager.delete_preset()
self.data_manager.load_preset(self.get_a_preset())
self.message_broker.send(DoStackSwitch(1))
if not self.data_manager.active_preset:
return
msg = (
_("Are you sure you want to delete the \npreset: '%s' ?")
_('Are you sure you want to delete the preset "%s"?')
% self.data_manager.active_preset.name
)
self.message_broker.send(UserConfirmRequest(msg, f))
@ -418,6 +430,7 @@ class Controller:
except KeyError:
# there is already an empty mapping
return
self.data_manager.load_mapping(combination=EventCombination.empty_combination())
self.data_manager.update_mapping(**MAPPING_DEFAULTS)
@ -432,7 +445,7 @@ class Controller:
if not self.data_manager.active_mapping:
return
self.message_broker.send(
UserConfirmRequest(_("Are you sure you want to delete \nthis mapping?"), f)
UserConfirmRequest(_("Are you sure you want to delete this mapping?"), f)
)
def set_autoload(self, autoload: bool):
@ -448,14 +461,16 @@ class Controller:
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
def start_key_recording(self):
"""recorde the input of the active_group and update the
active_mapping.event_combination with the recorded events"""
"""Record the input of the active_group
Updates the active_mapping.event_combination with the recorded events.
"""
self.message_broker.signal(MessageType.recording_started) # TODO test
state = self.data_manager.get_state()
if state == RUNNING or state == STARTING:
self.message_broker.signal(MessageType.recording_finished)
self.show_status(
CTX_ERROR, _('Use "Stop Injection" to stop before editing')
)
self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing'))
return
logger.debug("Recording Keys")
@ -598,15 +613,19 @@ class Controller:
def _change_mapping_type(self, kwargs):
"""query the user to update the mapping in order to change the mapping type"""
mapping = self.data_manager.active_mapping
if mapping is None:
return kwargs
if kwargs["mapping_type"] == mapping.mapping_type:
return kwargs
if kwargs["mapping_type"] == "analog":
msg = f"You are about to change the mapping to analog!"
msg = f"You are about to change the mapping to analog."
if mapping.output_symbol:
msg += (
f"\nThis will remove the '{mapping.output_symbol}' "
f"from the text input."
f'\nThis will remove "{mapping.output_symbol}" '
f"from the text input!"
)
if not [e for e in mapping.event_combination if e.value == 0]:
@ -617,13 +636,13 @@ class Controller:
events[i] = e.modify(value=0)
kwargs["event_combination"] = EventCombination(events)
msg += (
f"\nThe input '{e.description()}' "
f'\nThe input "{e.description()}" '
f"will be used as analog input."
)
break
else:
# not possible to autoconfigure inform the user
msg += "\nNote: you need to recorde an analog input."
msg += "\nYou need to record an analog input."
elif not mapping.output_symbol:
return kwargs
@ -658,8 +677,8 @@ class Controller:
self.message_broker.send(
UserConfirmRequest(
f"You are about to change the mapping to a Key or Macro mapping!\n"
f"Go to the 'Advanced Input Configuration' and set a "
f"'Trigger Threshold' for '{analog_input.description()}'.",
f"Go to the advanced input configuration and set a "
f'"Trigger Threshold" for "{analog_input.description()}".',
f,
)
)

@ -26,7 +26,7 @@ from typing import Optional, List, Tuple, Set
from gi.repository import GLib
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.mapping import UIMapping
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.paths import get_preset_path, mkdir, split_all
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import SystemMapping
@ -34,12 +34,14 @@ from inputremapper.daemon import DaemonProxy
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Group
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
GroupData,
PresetData,
CombinationUpdate,
UInputsData,
)
from inputremapper.gui.reader import Reader
from inputremapper.injection.global_uinputs import GlobalUInputs
@ -195,13 +197,12 @@ class DataManager:
presets.reverse()
return tuple(presets)
def get_mappings(self) -> Optional[List[Tuple[Optional[Name], EventCombination]]]:
"""all mapping names and their combination from the active_preset"""
def get_mappings(self) -> Optional[List[MappingData]]:
"""all mappings from the active_preset"""
if not self._active_preset:
return None
return [
(mapping.name, mapping.event_combination) for mapping in self._active_preset
]
return [mapping.get_bus_message() for mapping in self._active_preset]
def get_autoload(self) -> bool:
"""the autoload status of the active_preset"""

@ -22,8 +22,6 @@ import os.path
import re
import traceback
from collections import defaultdict, deque
from dataclasses import dataclass
from enum import Enum
from typing import (
Callable,
Dict,
@ -31,42 +29,15 @@ from typing import (
Protocol,
Tuple,
Deque,
Optional,
List,
Any,
TYPE_CHECKING,
)
from inputremapper.groups import DeviceType
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logger import logger
if TYPE_CHECKING:
from inputremapper.event_combination import EventCombination
class MessageType(Enum):
reset_gui = "reset_gui"
terminate = "terminate"
init = "init"
uinputs = "uinputs"
groups = "groups"
group = "group"
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
combination_recorded = "combination_recorded"
recording_finished = "recording_finished"
combination_update = "combination_update"
status_msg = "status_msg"
injector_state = "injector_state"
gui_focus_request = "gui_focus_request"
user_confirm_request = "user_confirm_request"
# for unit tests:
test1 = "test1"
test2 = "test2"
pass
class Message(Protocol):
@ -77,10 +48,6 @@ class Message(Protocol):
# useful type aliases
MessageListener = Callable[[Any], None]
Capabilities = Dict[int, List]
Name = str
Key = str
DeviceTypes = List[DeviceType]
class MessageBroker:
@ -121,7 +88,7 @@ class MessageBroker:
def subscribe(self, massage_type: MessageType, listener: MessageListener):
"""attach a listener to an event"""
logger.debug("adding new Listener: %s", listener)
logger.debug("adding new Listener for %s: %s", massage_type, listener)
self._listeners[massage_type].add(listener)
return self
@ -139,92 +106,6 @@ class MessageBroker:
pass
@dataclass(frozen=True)
class UInputsData:
message_type = MessageType.uinputs
uinputs: Dict[Name, Capabilities]
def __str__(self):
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
# find all sequences of comma+space separated numbers, and shorten them
# to the first and last number
all_matches = [m for m in re.finditer("(\d+, )+", string)]
all_matches.reverse()
for match in all_matches:
start = match.start()
end = match.end()
start += string[start:].find(",") + 2
if start == end:
continue
string = f"{string[:start]}... {string[end:]}"
return string
@dataclass(frozen=True)
class GroupsData:
"""Message containing all available groups and their device types"""
message_type = MessageType.groups
groups: Dict[Key, DeviceTypes]
@dataclass(frozen=True)
class GroupData:
"""Message with the active group and available presets for the group"""
message_type = MessageType.group
group_key: str
presets: Tuple[str, ...]
@dataclass(frozen=True)
class PresetData:
"""Message with the active preset name and mapping names/combinations"""
message_type = MessageType.preset
name: Optional[Name]
mappings: Optional[Tuple[Tuple[Name, "EventCombination"], ...]]
autoload: bool = False
@dataclass(frozen=True)
class StatusData:
"""Message with the strings and id for the status bar"""
message_type = MessageType.status_msg
ctx_id: int
msg: Optional[str] = None
tooltip: Optional[str] = None
@dataclass(frozen=True)
class CombinationRecorded:
"""Message with the latest recoded combination"""
message_type = MessageType.combination_recorded
combination: "EventCombination"
@dataclass(frozen=True)
class CombinationUpdate:
"""Message with the old and new combination (hash for a mapping) when it changed"""
message_type = MessageType.combination_update
old_combination: "EventCombination"
new_combination: "EventCombination"
@dataclass(frozen=True)
class UserConfirmRequest:
"""Message for requesting a user response (confirm/cancel) from the gui"""
message_type = MessageType.user_confirm_request
msg: str
respond: Callable[[bool], None] = lambda _: None
class Signal(Message):
"""Send a Message without any associated data over the MassageBus"""

@ -0,0 +1,107 @@
import re
from dataclasses import dataclass
from typing import Dict, Tuple, Optional, Callable
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
from inputremapper.gui.messages.message_types import (
MessageType,
Name,
Capabilities,
Key,
DeviceTypes,
)
@dataclass(frozen=True)
class UInputsData:
message_type = MessageType.uinputs
uinputs: Dict[Name, Capabilities]
def __str__(self):
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
# find all sequences of comma+space separated numbers, and shorten them
# to the first and last number
all_matches = [m for m in re.finditer("(\d+, )+", string)]
all_matches.reverse()
for match in all_matches:
start = match.start()
end = match.end()
start += string[start:].find(",") + 2
if start == end:
continue
string = f"{string[:start]}... {string[end:]}"
return string
@dataclass(frozen=True)
class GroupsData:
"""Message containing all available groups and their device types"""
message_type = MessageType.groups
groups: Dict[Key, DeviceTypes]
@dataclass(frozen=True)
class GroupData:
"""Message with the active group and available presets for the group"""
message_type = MessageType.group
group_key: str
presets: Tuple[str, ...]
@dataclass(frozen=True)
class PresetData:
"""Message with the active preset name and mapping names/combinations"""
message_type = MessageType.preset
name: Optional[Name]
mappings: Optional[Tuple[MappingData, ...]]
autoload: bool = False
@dataclass(frozen=True)
class StatusData:
"""Message with the strings and id for the status bar"""
message_type = MessageType.status_msg
ctx_id: int
msg: Optional[str] = None
tooltip: Optional[str] = None
@dataclass(frozen=True)
class CombinationRecorded:
"""Message with the latest recoded combination"""
message_type = MessageType.combination_recorded
combination: "EventCombination"
@dataclass(frozen=True)
class CombinationUpdate:
"""Message with the old and new combination (hash for a mapping) when it changed"""
message_type = MessageType.combination_update
old_combination: "EventCombination"
new_combination: "EventCombination"
@dataclass(frozen=True)
class UserConfirmRequest:
"""Message for requesting a user response (confirm/cancel) from the gui"""
message_type = MessageType.user_confirm_request
msg: str
respond: Callable[[bool], None] = lambda _: None
@dataclass(frozen=True)
class DoStackSwitch:
"""Command the stack to switch to a different page."""
message_type = MessageType.do_stack_switch
page_index: int

@ -0,0 +1,58 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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/>.
from enum import Enum
from typing import Dict, List
from inputremapper.groups import DeviceType
# useful type aliases
Capabilities = Dict[int, List]
Name = str
Key = str
DeviceTypes = List[DeviceType]
class MessageType(Enum):
reset_gui = "reset_gui"
terminate = "terminate"
init = "init"
uinputs = "uinputs"
groups = "groups"
group = "group"
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
combination_recorded = "combination_recorded"
recording_started = "recording_started"
recording_finished = "recording_finished"
combination_update = "combination_update"
status_msg = "status_msg"
injector_state = "injector_state"
gui_focus_request = "gui_focus_request"
user_confirm_request = "user_confirm_request"
do_stack_switch = "do_stack_switch"
# for unit tests:
test1 = "test1"
test2 = "test2"

@ -36,12 +36,9 @@ from inputremapper.gui.helper import (
CMD_TERMINATE,
CMD_REFRESH_GROUPS,
)
from inputremapper.gui.message_broker import (
MessageBroker,
GroupsData,
MessageType,
CombinationRecorded,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.gui.messages.message_broker import MessageBroker
from inputremapper.gui.messages.message_data import GroupsData, CombinationRecorded
from inputremapper.input_event import InputEvent
from inputremapper.ipc.pipe import Pipe
from inputremapper.logger import logger

@ -28,29 +28,35 @@ from inputremapper.configs.data import get_data_path
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
from inputremapper.gui.autocompletion import Autocompletion
from inputremapper.gui.components import (
DeviceSelection,
PresetSelection,
from inputremapper.gui.components.editor import (
MappingListBox,
TargetSelection,
CodeEditor,
RecordingToggle,
StatusBar,
RecordingStatus,
AutoloadSwitch,
ReleaseCombinationSwitch,
CombinationListbox,
AnalogInputSwitch,
TriggerThresholdInput,
OutputAxisSelector,
ConfirmCancelDialog,
ReleaseTimeoutInput,
TransformationDrawArea,
Sliders,
RelativeInputCutoffInput,
KeyAxisStackSwitcher,
RequireActiveMapping,
)
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.device_groups import DeviceGroupSelection
from inputremapper.gui.controller import Controller
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import UserConfirmRequest
from inputremapper.gui.utils import (
gtk_iteration,
)
@ -58,7 +64,6 @@ from inputremapper.injection.injector import InjectorState
from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION
from inputremapper.gui.gettext import _
# TODO add to .deb and AUR dependencies
# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/
GObject.type_register(GtkSource.View)
# GtkSource.View() also works:
@ -87,6 +92,7 @@ class UserInterface:
Gdk.KEY_q: self.controller.close,
Gdk.KEY_r: self.controller.refresh_groups,
Gdk.KEY_Delete: self.controller.stop_injecting,
Gdk.KEY_n: self.controller.add_preset,
}
# stores the ids for all the listeners attached to the gui
@ -97,7 +103,6 @@ class UserInterface:
self.builder = Gtk.Builder()
self._build_ui()
self.window: Gtk.Window = self.get("window")
self.confirm_cancel_dialog: Gtk.MessageDialog = self.get("confirm-cancel")
self.about: Gtk.Window = self.get("about-dialog")
self.combination_editor: Gtk.Dialog = self.get("combination-editor")
@ -137,10 +142,24 @@ class UserInterface:
"""setup all objects which manage individual components of the ui"""
message_broker = self.message_broker
controller = self.controller
DeviceSelection(message_broker, controller, self.get("device_selection"))
DeviceGroupSelection(message_broker, controller, self.get("device_selection"))
PresetSelection(message_broker, controller, self.get("preset_selection"))
MappingListBox(message_broker, controller, self.get("selection_label_listbox"))
TargetSelection(message_broker, controller, self.get("target-selector"))
Breadcrumbs(
message_broker,
self.get("selected_device_name"),
show_device_group=True,
)
Breadcrumbs(
message_broker,
self.get("selected_preset_name"),
show_device_group=True,
show_preset=True,
)
Stack(message_broker, controller, self.get("main_stack"))
RecordingToggle(message_broker, controller, self.get("key_recording_toggle"))
StatusBar(
message_broker,
@ -149,6 +168,7 @@ class UserInterface:
self.get("error_status_icon"),
self.get("warning_status_icon"),
)
RecordingStatus(message_broker, self.get("recording_status"))
AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch"))
ReleaseCombinationSwitch(
message_broker, controller, self.get("release-combination-switch")
@ -162,12 +182,6 @@ class UserInterface:
message_broker, controller, self.get("input-cutoff-spin-btn")
)
OutputAxisSelector(message_broker, controller, self.get("output-axis-selector"))
ConfirmCancelDialog(
message_broker,
controller,
self.get("confirm-cancel"),
self.get("confirm-cancel-label"),
)
KeyAxisStackSwitcher(
message_broker,
controller,
@ -189,6 +203,22 @@ class UserInterface:
self.get("expo-scale"),
)
RequireActiveMapping(
message_broker,
self.get("edit-combination-btn"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("output"),
require_recorded_input=True,
)
RequireActiveMapping(
message_broker,
self.get("delete-mapping"),
require_recorded_input=False,
)
# code editor and autocompletion
code_editor = CodeEditor(message_broker, controller, self.get("code_editor"))
autocompletion = Autocompletion(message_broker, code_editor)
@ -221,7 +251,10 @@ class UserInterface:
self.get("apply_preset").connect(
"clicked", lambda *_: self.controller.start_injecting()
)
self.get("apply_system_layout").connect(
self.get("stop_injection_preset_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("stop_injection_editor_page").connect(
"clicked", lambda *_: self.controller.stop_injecting()
)
self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked)
@ -255,22 +288,65 @@ class UserInterface:
self.message_broker.subscribe(
MessageType.injector_state, self.on_injector_state_msg
)
self.message_broker.subscribe(
MessageType.user_confirm_request, self._on_user_confirm_request
)
def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog:
"""Create a message dialog with cancel and confirm buttons."""
message_dialog = Gtk.MessageDialog(
self.window,
Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
Gtk.MessageType.QUESTION,
Gtk.ButtonsType.NONE,
primary,
)
if secondary:
message_dialog.format_secondary_text(secondary)
message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT)
confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION)
return message_dialog
def _on_user_confirm_request(self, msg: UserConfirmRequest):
# if the message contains a line-break, use the first chunk for the primary
# message, and the rest for the secondary message.
chunks = msg.msg.split("\n")
primary = chunks[0]
secondary = " ".join(chunks[1:])
message_dialog = self._create_dialog(primary, secondary)
response = message_dialog.run()
msg.respond(response == Gtk.ResponseType.ACCEPT)
message_dialog.hide()
def on_injector_state_msg(self, msg: InjectorState):
"""update the ui to reflect the status of the injector"""
stop_injection_btn: Gtk.Button = self.get("apply_system_layout")
stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page")
stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page")
recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle")
if msg.active():
stop_injection_btn.set_opacity(1)
stop_injection_btn.set_sensitive(True)
recording_toggle.set_opacity(0.4)
stop_injection_preset_page.set_opacity(1)
stop_injection_editor_page.set_opacity(1)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(0.5)
else:
stop_injection_btn.set_opacity(0.4)
stop_injection_btn.set_sensitive(True)
stop_injection_preset_page.set_opacity(0.5)
stop_injection_editor_page.set_opacity(0.5)
stop_injection_preset_page.set_sensitive(True)
stop_injection_editor_page.set_sensitive(True)
recording_toggle.set_opacity(1)
def disconnect_shortcuts(self):
"""stop listening for shortcuts
"""Stop listening for shortcuts.
e.g. when recording key combinations
"""
@ -280,7 +356,7 @@ class UserInterface:
logger.debug("key listeners seem to be not connected")
def connect_shortcuts(self):
"""stop listening for shortcuts"""
"""Start listening for shortcuts."""
if not self.gtk_listeners.get(self.on_gtk_shortcut):
self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect(
"key-press-event", self.on_gtk_shortcut
@ -301,7 +377,7 @@ class UserInterface:
if mapping.event_combination.beautify() == label.get_label():
return
if mapping.event_combination == EventCombination.empty_combination():
label.set_opacity(0.4)
label.set_opacity(0.5)
label.set_label(_("no input configured"))
return

@ -17,9 +17,14 @@
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import time
from typing import List
from gi.repository import Gtk, GLib, Gdk
from gi.repository import Gtk, GLib
from inputremapper.logger import logger
# status ctx ids
@ -65,7 +70,7 @@ def debounce(timeout):
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a with statement.
Use in a `with` statement.
"""
def __init__(self, widget, handler):
@ -73,10 +78,18 @@ class HandlerDisabled:
self.handler = handler
def __enter__(self):
self.widget.handler_block_by_func(self.handler)
try:
self.widget.handler_block_by_func(self.handler)
except TypeError as error:
# if nothing is connected to the given signal, it is not critical
# at all
logger.warning('HandlerDisabled entry failed: "%s"', error)
def __exit__(self, *_):
self.widget.handler_unblock_by_func(self.handler)
try:
self.widget.handler_unblock_by_func(self.handler)
except TypeError as error:
logger.warning('HandlerDisabled exit failed: "%s"', error)
def gtk_iteration(iterations=0):
@ -87,3 +100,63 @@ def gtk_iteration(iterations=0):
time.sleep(0.002)
while Gtk.events_pending():
Gtk.main_iteration()
class Colors:
"""Looks up colors from the GTK theme.
Defaults to libadwaita-light theme colors if the lookup fails.
"""
fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1)
fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1)
fallback_base = Gdk.RGBA(1, 1, 1, 1)
fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1)
fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1)
@staticmethod
def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA:
"""Get theme colors. Provide multiple names for fallback purposes."""
for name in names:
found, color = Gtk.StyleContext().lookup_color(name)
if found:
return color
return fallback
@staticmethod
def get_accent_color() -> Gdk.RGBA:
"""Look up the accent color from the current theme."""
return Colors.get_color(
["accent_bg_color", "theme_selected_bg_color"],
Colors.fallback_accent,
)
@staticmethod
def get_background_color() -> Gdk.RGBA:
"""Look up the background-color from the current theme."""
return Colors.get_color(
["theme_bg_color"],
Colors.fallback_background,
)
@staticmethod
def get_base_color() -> Gdk.RGBA:
"""Look up the base-color from the current theme."""
return Colors.get_color(
["theme_base_color"],
Colors.fallback_base,
)
@staticmethod
def get_border_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(["borders"], Colors.fallback_border)
@staticmethod
def get_font_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(
["theme_fg_color"],
Colors.fallback_font,
)

@ -61,7 +61,8 @@ DEFAULT_UINPUTS["keyboard + mouse"] = {
class UInput(evdev.UInput):
def __init__(self, *args, **kwargs):
logger.debug(f"creating UInput device: '{kwargs['name']}'")
name = kwargs["name"]
logger.debug(f'creating UInput device: "{name}"')
super().__init__(*args, **kwargs)
def can_emit(self, event):
@ -80,7 +81,7 @@ class FrontendUInput:
self.events = events
self.name = name
logger.debug(f"creating fake UInput device: '{self.name}'")
logger.debug(f'creating fake UInput device: "{self.name}"')
def capabilities(self):
return self.events

@ -38,7 +38,7 @@ from inputremapper.groups import (
classify,
DeviceType,
)
from inputremapper.gui.message_broker import MessageType
from inputremapper.gui.messages.message_broker import MessageType
from inputremapper.injection.context import Context
from inputremapper.injection.event_reader import EventReader
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock

@ -65,7 +65,7 @@ class AbsToBtnHandler(MappingHandler):
def _trigger_point(self, abs_min: int, abs_max: int) -> Tuple[float, float]:
"""Calculate the axis mid and trigger point."""
# TODO: potentially cash this function
# TODO: potentially cache this function
if abs_min == -1 and abs_max == 1:
# this is a hat switch
return (

@ -91,7 +91,7 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines:
logger.warning(
"a mapping handler '%s' for %s is not implemented",
handler_enum,
mapping.name or mapping.event_combination.beautify(),
mapping.format_name(),
)
continue

@ -28,7 +28,7 @@ from evdev import ecodes
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.exceptions import InputEventCreationError
from inputremapper.gui.message_broker import MessageType
from inputremapper.gui.messages.message_broker import MessageType
from inputremapper.logger import logger
InputEventValidationType = Union[
@ -205,7 +205,7 @@ class InputEvent:
actions if actions is not None else self.actions,
)
def json_str(self) -> str:
def json_key(self) -> str:
return ",".join([str(self.type), str(self.code), str(self.value)])
def get_name(self) -> Optional[str]:

@ -21,219 +21,8 @@
"""Utility functions."""
import math
import sys
import evdev
from evdev.ecodes import (
EV_KEY,
EV_ABS,
ABS_X,
ABS_Y,
ABS_RX,
ABS_RY,
EV_REL,
REL_WHEEL,
REL_HWHEEL,
)
from inputremapper.logger import logger
# other events for ABS include buttons
JOYSTICK = [
evdev.ecodes.ABS_X,
evdev.ecodes.ABS_Y,
evdev.ecodes.ABS_RX,
evdev.ecodes.ABS_RY,
]
# drawing table stylus movements
STYLUS = [
(EV_ABS, evdev.ecodes.ABS_DISTANCE),
(EV_ABS, evdev.ecodes.ABS_TILT_X),
(EV_ABS, evdev.ecodes.ABS_TILT_Y),
(EV_KEY, evdev.ecodes.BTN_DIGI),
(EV_ABS, evdev.ecodes.ABS_PRESSURE),
]
# a third of a quarter circle, so that each quarter is divided in 3 areas:
# up, left and up-left. That makes up/down/left/right larger than the
# overlapping sections though, maybe it should be 8 equal areas though, idk
JOYSTICK_BUTTON_THRESHOLD = math.sin((math.pi / 2) / 3 * 1)
PRESS = 1
# D-Pads and joysticks can have a second press event, which moves the knob to the
# opposite side, reporting a negative value
PRESS_NEGATIVE = -1
RELEASE = 0
def sign(value):
"""Return -1, 0 or 1 depending on the input value."""
if value > 0:
return 1
if value < 0:
return -1
return 0
def classify_action(event, abs_range=None):
"""Fit the event value to one of PRESS, PRESS_NEGATIVE or RELEASE
A joystick that is pushed to the very side will probably send a high value, whereas
having it close to the middle might send values close to 0 with some noise. A value
of 1 is usually noise or from touching the joystick very gently and considered in
resting position.
"""
if event.type == EV_ABS and event.code in JOYSTICK:
if abs_range is None:
logger.error(
"Got %s, but abs_range is %s",
(event.type, event.code, event.value),
abs_range,
)
return event.value
# center is the value of the resting position
center = (abs_range[1] + abs_range[0]) / 2
# normalizer is the maximum possible value after centering
normalizer = (abs_range[1] - abs_range[0]) / 2
threshold = normalizer * JOYSTICK_BUTTON_THRESHOLD
triggered = abs(event.value - center) > threshold
return sign(event.value - center) if triggered else 0
# non-joystick abs events (triggers) usually start at 0 and go up to 255,
# but anything that is > 0 was safe to be treated as pressed so far
return sign(event.value)
def is_key_down(action):
"""Is this action a key press."""
return action in [PRESS, PRESS_NEGATIVE]
def is_key_up(action):
"""Is this action a key release."""
return action == RELEASE
def is_wheel(event):
"""Check if this is a wheel event."""
return event.type == EV_REL and event.code in [REL_WHEEL, REL_HWHEEL]
def will_report_key_up(event):
"""Check if the key is expected to report a down event as well."""
return not is_wheel(event)
def should_map_as_btn(event, preset, gamepad):
"""Does this event describe a button that is or can be mapped.
If a new kind of event should be mappable to buttons, this is the place
to add it.
Especially important for gamepad events, some of the buttons
require special rules.
Parameters
----------
event : evdev.InputEvent
preset : Preset
gamepad : bool
If the device is treated as gamepad
"""
if (event.type, event.code) in STYLUS:
return False
is_mousepad = event.type == EV_ABS and 47 <= event.code <= 61
if is_mousepad:
return False
if event.type == EV_ABS:
if event.code == evdev.ecodes.ABS_MISC:
# what is that even supposed to be.
# the intuos 5 spams those with every event
return False
if event.code in JOYSTICK:
if not gamepad:
return False
if event.code in [ABS_X, ABS_Y]:
return True
if event.code in [ABS_RX, ABS_RY]:
return True
else:
# for non-joystick buttons just always offer mapping them to
# buttons
return True
if is_wheel(event):
return True
if event.type == EV_KEY:
# usually all EV_KEY events are allright, except for
if event.code == evdev.ecodes.BTN_TOUCH:
return False
return True
return False
def get_abs_range(device, code=ABS_X):
"""Figure out the max and min value of EV_ABS events of that device.
Like joystick movements or triggers.
"""
# since input_device.absinfo(EV_ABS).max is too new for (some?) ubuntus,
# figure out the max value via the capabilities
capabilities = device.capabilities(absinfo=True)
if EV_ABS not in capabilities:
return None
absinfo = [
entry[1]
for entry in capabilities[EV_ABS]
if (
entry[0] == code
and isinstance(entry, tuple)
and isinstance(entry[1], evdev.AbsInfo)
)
]
if len(absinfo) == 0:
logger.warning(
'Failed to get ABS info of "%s" for key %d: %s',
device,
code,
capabilities,
)
return None
absinfo = absinfo[0]
return absinfo.min, absinfo.max
def get_max_abs(device, code=ABS_X):
"""Figure out the max value of EV_ABS events of that device.
Like joystick movements or triggers.
"""
abs_range = get_abs_range(device, code)
return abs_range and abs_range[1]
def is_service():
return sys.argv[0].endswith("input-remapper-service")

@ -208,7 +208,7 @@ msgid "Starting injection..."
msgstr ""
#: data/input-remapper.glade:217
msgid "Stop Injection"
msgid "Stop"
msgstr ""
#: inputremapper/gui/user_interface.py:481
@ -238,7 +238,7 @@ msgid "Usage"
msgstr ""
#: inputremapper/gui/editor/editor.py:519
msgid "Use \"Stop Injection\" to stop before editing"
msgid "Use \"Stop\" to stop before editing"
msgstr ""
#: data/input-remapper.glade:1015

@ -150,7 +150,7 @@ msgid "Rename"
msgstr "Rinomina"
#: data/input-remapper.glade:120
msgid "Stop Injection"
msgid "Stop"
msgstr "Ripristina impostazioni predefinite"
#: data/input-remapper.glade:509

@ -225,7 +225,7 @@ msgid "Starting injection..."
msgstr "Spúšťanie injektáže..."
#: data/input-remapper.glade:217
msgid "Stop Injection"
msgid "Stop"
msgstr "Zastaviť injektáž"
#: inputremapper/gui/user_interface.py:477
@ -257,7 +257,7 @@ msgid "Usage"
msgstr "Použitie"
#: inputremapper/gui/editor/editor.py:482
msgid "Use \"Stop Injection\" to stop before editing"
msgid "Use \"Stop\" to stop before editing"
msgstr "Pred editovaním použite \"Zastaviť injektáž\""
#: data/input-remapper.glade:1015

@ -222,7 +222,7 @@ msgid "Starting injection..."
msgstr "注入启动中……"
#: data/input-remapper.glade:217
msgid "Stop Injection"
msgid "Stop"
msgstr "停止注入"
#: inputremapper/gui/user_interface.py:481
@ -252,7 +252,7 @@ msgid "Usage"
msgstr "用法"
#: inputremapper/gui/editor/editor.py:519
msgid "Use \"Stop Injection\" to stop before editing"
msgid "Use \"Stop\" to stop before editing"
msgstr "使用“停止注入”停止后再编辑"
#: data/input-remapper.glade:1015

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

@ -9,7 +9,7 @@
</mask>
<g mask="url(#anybadge_1)">
<path fill="#555" d="M0 0h65v20H0z"/>
<path fill="#4c1" d="M65 0h34v20H65z"/>
<path fill="#4C1" d="M65 0h34v20H65z"/>
<path fill="url(#b)" d="M0 0h99v20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
@ -20,4 +20,4 @@
<text x="83.0" y="15" fill="#010101" fill-opacity=".3">93%</text>
<text x="82.0" y="14">93%</text>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@ -1,23 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20">
<svg xmlns="http://www.w3.org/2000/svg" width="73" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<mask id="anybadge_1">
<rect width="80" height="20" rx="3" fill="#fff"/>
<rect width="73" height="20" rx="3" fill="#fff"/>
</mask>
<g mask="url(#anybadge_1)">
<path fill="#555" d="M0 0h44v20H0z"/>
<path fill="#4c1" d="M44 0h36v20H44z"/>
<path fill="url(#b)" d="M0 0h80v20H0z"/>
<path fill="#4C1" d="M44 0h29v20H44z"/>
<path fill="url(#b)" d="M0 0h73v20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="23.0" y="15" fill="#010101" fill-opacity=".3">pylint</text>
<text x="22.0" y="14">pylint</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.18</text>
<text x="62.0" y="14">9.18</text>
<text x="59.5" y="15" fill="#010101" fill-opacity=".3">8.7</text>
<text x="58.5" y="14">8.7</text>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 56 KiB

@ -10,9 +10,9 @@ You can also start it via `input-remapper-gtk`.
<img src="usage_2.png"/>
</p>
First, select your device (like your keyboard) from the large dropdown on the top,
and add a mapping.
Then you can already edit your inputs, as shown in the screenshots.
First, select your device (like your keyboard) on the first page, then create a new
preset on the second page, and add a mapping. Then you can already edit your inputs,
as shown in the screenshots.
In the text input field, type the key to which you would like to map this key.
More information about the possible mappings can be found in [examples.md](./examples.md) and [below](#key-names).
@ -21,7 +21,7 @@ Changes are saved automatically.
Press the "Apply" button to activate (inject) the mapping you created.
If you later want to modify the Input of your mapping you need to use the
"Stop Injection" button, so that the application can read your original input.
"Stop" button, so that the application can read your original input.
It would otherwise be invisible since the daemon maps it independently of the GUI.
## Troubleshooting
@ -42,12 +42,13 @@ the input (`Recorde Input` - Button) press multiple keys and/or move axis at onc
The mapping will be triggered as soon as all the recorded inputs are pressed.
If you use an axis an input you can modify the threshold at which the mapping is
activated in the `Advanced Input Configuration`.
activated in the advanced input configuration, which can be opened by clicking on the
`Advanced` button.
A mapping with an input combination is only injected once all combination keys
are pressed. This means all the input keys you press before the combination is complete
will be injected unmodified. In some cases this can be desirable, in others not.
In the `Advanced Input Configuration` is the `Release Input` toggle.
In the advanced input configuration there is the `Release Input` toggle.
This will release all inputs which are part of the combination before the mapping is
injected. Consider a mapping `Shift+1 -> a` this will inject a lowercase `a` if the
toggle is on and an uppercase `A` if it is off. The exact behaviour if the toggle is off
@ -107,8 +108,8 @@ ultimately decide which character to write.
## Analog Axis
It is possible to map analog inputs to analog outputs. E.g. use a gamepad as a mouse.
For this you need to create a mapping and recorde the input axis. Then go to
`Advanced Input Configuration` and select `Use as Analog`. Make sure to select a target
For this you need to create a mapping and recorde the input axis. Then click on
`Advanced` and select `Use as Analog`. Make sure to select a target
which supports analog axis and switch to the `Analog Axis` tab.
There you can select an output axis and use the different sliders to configure the
sensitivity, non-linearity and other parameters as you like.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

@ -58,6 +58,8 @@ def get_packages(base="inputremapper"):
For example 'inputremapper.gui' or 'inputremapper.injection.mapping_handlers'
"""
# TODO I think there is a built-in tool that does the same,
# forgot where I saw it
if not os.path.exists(os.path.join(base, "__init__.py")):
# only python modules
return []

@ -2,6 +2,7 @@
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")

@ -1,39 +1,61 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2022 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 unittest
from typing import Optional, Tuple
from unittest.mock import MagicMock, patch
from typing import Optional, Tuple, Union
from unittest.mock import MagicMock
import time
import evdev
from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C, KEY_X
from evdev.ecodes import KEY_A, KEY_B, KEY_C
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GLib, GtkSource, Gdk
from tests.test import quick_cleanup, spy
from tests.test import quick_cleanup, spy, logger
from inputremapper.input_event import InputEvent
from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
GroupData,
GroupsData,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
GroupsData,
GroupData,
PresetData,
CombinationUpdate,
StatusData,
CombinationUpdate,
DoStackSwitch,
)
from inputremapper.groups import DeviceType
from inputremapper.gui.components import (
DeviceSelection,
from inputremapper.gui.components.editor import (
TargetSelection,
PresetSelection,
MappingListBox,
SelectionLabel,
MappingSelectionLabel,
CodeEditor,
RecordingToggle,
StatusBar,
AutoloadSwitch,
ReleaseCombinationSwitch,
CombinationListbox,
@ -46,37 +68,109 @@ from inputremapper.gui.components import (
Sliders,
TransformationDrawArea,
RelativeInputCutoffInput,
RecordingStatus,
RequireActiveMapping,
)
from inputremapper.gui.components.main import Stack, StatusBar
from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs
from inputremapper.gui.components.presets import PresetSelection
from inputremapper.gui.components.device_groups import (
DeviceGroupEntry,
DeviceGroupSelection,
)
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
class ComponentBaseTest(unittest.TestCase):
"""test a gui component
ensures to tearDown self.gui
all gtk objects must be a child of self.gui in order to ensure proper cleanup"""
"""Test a gui component."""
def setUp(self) -> None:
self.message_broker = MessageBroker()
self.controller_mock = MagicMock()
self.gui = MagicMock()
def destroy_all_member_widgets(self):
# destroy all Gtk Widgets that are stored in self
# TODO why is this necessary?
for attribute in dir(self):
stuff = getattr(self, attribute, None)
if isinstance(stuff, Gtk.Widget):
logger.info('destroying member "%s" %s', attribute, stuff)
GLib.timeout_add(0, stuff.destroy)
setattr(self, attribute, None)
def tearDown(self) -> None:
super().tearDown()
self.message_broker.signal(MessageType.terminate)
GLib.timeout_add(0, self.gui.destroy)
# Shut down the gui properly
self.destroy_all_member_widgets()
GLib.timeout_add(0, Gtk.main_quit)
# Gtk.main() will start the Gtk event loop and process all pending events.
# So the gui will do whatever is queued up this ensures that the next tests
# starts without pending events.
Gtk.main()
quick_cleanup()
class TestDeviceSelection(ComponentBaseTest):
class FlowBoxTestUtils:
"""Methods to test the FlowBoxes that contain presets and devices.
Those are only used in tests, so I moved them here instead.
"""
@staticmethod
def set_active(flow_box: Gtk.FlowBox, name: str):
"""Change the currently selected group."""
for child in flow_box.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
flow_box_entry.set_active(flow_box_entry.name == name)
@staticmethod
def get_active_entry(flow_box: Gtk.FlowBox) -> Union[DeviceGroupEntry, None]:
"""Find the currently selected DeviceGroupEntry."""
children = flow_box.get_children()
if len(children) == 0:
return None
for child in children:
flow_box_entry: FlowBoxEntry = child.get_children()[0]
if flow_box_entry.get_active():
return flow_box_entry
raise AssertionError("Expected one entry to be selected.")
@staticmethod
def get_child_names(flow_box: Gtk.FlowBox):
names = []
for child in flow_box.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
names.append(flow_box_entry.name)
return names
@staticmethod
def get_child_icons(flow_box: Gtk.FlowBox):
icon_names = []
for child in flow_box.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
icon_names.append(flow_box_entry.icon_name)
return icon_names
class TestDeviceGroupSelection(ComponentBaseTest):
def setUp(self) -> None:
super(TestDeviceSelection, self).setUp()
self.gui = Gtk.ComboBox()
self.selection = DeviceSelection(
self.message_broker, self.controller_mock, self.gui
super(TestDeviceGroupSelection, self).setUp()
self.gui = Gtk.FlowBox()
self.selection = DeviceGroupSelection(
self.message_broker,
self.controller_mock,
self.gui,
)
self.message_broker.send(
GroupsData(
@ -88,10 +182,21 @@ class TestDeviceSelection(ComponentBaseTest):
)
)
def get_displayed_group_keys_and_icons(self):
"""Get a list of all group_keys and icons of the displayed groups."""
group_keys = []
icons = []
for child in self.gui.get_children():
device_group_entry = child.get_children()[0]
group_keys.append(device_group_entry.group_key)
icons.append(device_group_entry.icon_name)
return group_keys, icons
def test_populates_devices(self):
names = [row[0] for row in self.gui.get_model()]
self.assertEqual(names, ["foo", "bar", "baz"])
icons = [row[1] for row in self.gui.get_model()]
# tests that all devices sent via the broker end up in the gui
group_keys, icons = self.get_displayed_group_keys_and_icons()
self.assertEqual(group_keys, ["foo", "bar", "baz"])
self.assertEqual(icons, ["input-gaming", None, "input-tablet"])
self.message_broker.send(
@ -102,19 +207,19 @@ class TestDeviceSelection(ComponentBaseTest):
}
)
)
names = [row[0] for row in self.gui.get_model()]
self.assertEqual(names, ["kuu", "qux"])
icons = [row[1] for row in self.gui.get_model()]
group_keys, icons = self.get_displayed_group_keys_and_icons()
self.assertEqual(group_keys, ["kuu", "qux"])
self.assertEqual(icons, ["input-keyboard", "input-gaming"])
def test_selects_correct_device(self):
self.message_broker.send(GroupData("bar", ()))
self.assertEqual(self.gui.get_active_id(), "bar")
self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "bar")
self.message_broker.send(GroupData("baz", ()))
self.assertEqual(self.gui.get_active_id(), "baz")
self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "baz")
def test_loads_group(self):
self.gui.set_active_id("bar")
FlowBoxTestUtils.set_active(self.gui, "bar")
self.controller_mock.load_group.assert_called_once_with("bar")
def test_avoids_infinite_recursion(self):
@ -168,53 +273,64 @@ class TestTargetSelection(ComponentBaseTest):
self.message_broker.send(MappingData(target_uinput="baz"))
self.controller_mock.update_mapping.assert_not_called()
def test_disabled_with_invalid_mapping(self):
self.controller_mock.is_empty_mapping.return_value = True
self.message_broker.send(MappingData())
self.assertFalse(self.gui.get_sensitive())
self.assertLess(self.gui.get_opacity(), 0.8)
def test_enabled_with_valid_mapping(self):
self.controller_mock.is_empty_mapping.return_value = False
self.message_broker.send(MappingData())
self.assertTrue(self.gui.get_sensitive())
self.assertEqual(self.gui.get_opacity(), 1)
class TestPresetSelection(ComponentBaseTest):
def setUp(self) -> None:
super().setUp()
self.gui = Gtk.ComboBoxText()
self.gui = Gtk.FlowBox()
self.selection = PresetSelection(
self.message_broker, self.controller_mock, self.gui
)
self.message_broker.send(GroupData("foo", ("preset1", "preset2")))
def test_populates_presets(self):
names = [row[0] for row in self.gui.get_model()]
names = FlowBoxTestUtils.get_child_names(self.gui)
self.assertEqual(names, ["preset1", "preset2"])
self.message_broker.send(GroupData("foo", ("preset3", "preset4")))
names = [row[0] for row in self.gui.get_model()]
names = FlowBoxTestUtils.get_child_names(self.gui)
self.assertEqual(names, ["preset3", "preset4"])
def test_selects_preset(self):
self.message_broker.send(
PresetData("preset2", (("m1", EventCombination((1, 2, 3))),))
PresetData(
"preset2",
(
MappingData(
name="m1", event_combination=EventCombination((1, 2, 3))
),
),
)
)
self.assertEqual(self.gui.get_active_id(), "preset2")
self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset2")
self.message_broker.send(
PresetData("preset1", (("m1", EventCombination((1, 2, 3))),))
PresetData(
"preset1",
(
MappingData(
name="m1", event_combination=EventCombination((1, 2, 3))
),
),
)
)
self.assertEqual(self.gui.get_active_id(), "preset1")
self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset1")
def test_avoids_infinite_recursion(self):
self.message_broker.send(
PresetData("preset2", (("m1", EventCombination((1, 2, 3))),))
PresetData(
"preset2",
(
MappingData(
name="m1", event_combination=EventCombination((1, 2, 3))
),
),
)
)
self.controller_mock.load_preset.assert_not_called()
def test_loads_preset(self):
self.gui.set_active_id("preset2")
FlowBoxTestUtils.set_active(self.gui, "preset2")
self.controller_mock.load_preset.assert_called_once_with("preset2")
@ -230,17 +346,28 @@ class TestMappingListbox(ComponentBaseTest):
PresetData(
"preset1",
(
("mapping1", EventCombination((1, KEY_C, 1))),
("", EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])),
("mapping2", EventCombination((1, KEY_B, 1))),
MappingData(
name="mapping1",
event_combination=EventCombination((1, KEY_C, 1)),
),
MappingData(
name="",
event_combination=EventCombination(
[(1, KEY_A, 1), (1, KEY_B, 1)]
),
),
MappingData(
name="mapping2",
event_combination=EventCombination((1, KEY_B, 1)),
),
),
)
)
def get_selected_row(self) -> SelectionLabel:
def get_selected_row(self) -> MappingSelectionLabel:
row = None
def find_row(r: SelectionLabel):
def find_row(r: MappingSelectionLabel):
nonlocal row
if r.is_selected():
row = r
@ -250,7 +377,7 @@ class TestMappingListbox(ComponentBaseTest):
return row
def select_row(self, combination: EventCombination):
def select(row: SelectionLabel):
def select(row: MappingSelectionLabel):
if row.combination == combination:
self.gui.select_row(row)
@ -293,53 +420,71 @@ class TestMappingListbox(ComponentBaseTest):
PresetData(
"preset1",
(
("qux", EventCombination((1, KEY_C, 1))),
("foo", EventCombination.empty_combination()),
("bar", EventCombination((1, KEY_B, 1))),
MappingData(
name="qux",
event_combination=EventCombination((1, KEY_C, 1)),
),
MappingData(
name="foo",
event_combination=EventCombination.empty_combination(),
),
MappingData(
name="bar",
event_combination=EventCombination((1, KEY_B, 1)),
),
),
)
)
bottom_row: SelectionLabel = self.gui.get_row_at_index(2)
bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2)
self.assertEqual(bottom_row.combination, EventCombination.empty_combination())
self.message_broker.send(
PresetData(
"preset1",
(
("foo", EventCombination.empty_combination()),
("qux", EventCombination((1, KEY_C, 1))),
("bar", EventCombination((1, KEY_B, 1))),
MappingData(
name="foo",
event_combination=EventCombination.empty_combination(),
),
MappingData(
name="qux",
event_combination=EventCombination((1, KEY_C, 1)),
),
MappingData(
name="bar",
event_combination=EventCombination((1, KEY_B, 1)),
),
),
)
)
bottom_row: SelectionLabel = self.gui.get_row_at_index(2)
bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2)
self.assertEqual(bottom_row.combination, EventCombination.empty_combination())
class TestSelectionLabel(ComponentBaseTest):
class TestMappingSelectionLabel(ComponentBaseTest):
def setUp(self) -> None:
super().setUp()
self.gui = Gtk.ListBox()
self.label = SelectionLabel(
self.mapping_selection_label = MappingSelectionLabel(
self.message_broker,
self.controller_mock,
"",
EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
self.gui.insert(self.label, -1)
self.gui.insert(self.mapping_selection_label, -1)
def assert_edit_mode(self):
self.assertTrue(self.label.name_input.get_visible())
self.assertFalse(self.label.label.get_visible())
self.assertTrue(self.mapping_selection_label.name_input.get_visible())
self.assertFalse(self.mapping_selection_label.label.get_visible())
def assert_selected(self):
self.assertTrue(self.label.label.get_visible())
self.assertFalse(self.label.name_input.get_visible())
self.assertTrue(self.mapping_selection_label.label.get_visible())
self.assertFalse(self.mapping_selection_label.name_input.get_visible())
def test_shows_combination_without_name(self):
self.assertEqual(self.label.label.get_label(), "a + b")
self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b")
def test_shows_name_when_given(self):
self.gui = SelectionLabel(
self.gui = MappingSelectionLabel(
self.message_broker,
self.controller_mock,
"foo",
@ -348,9 +493,10 @@ class TestSelectionLabel(ComponentBaseTest):
self.assertEqual(self.gui.label.get_label(), "foo")
def test_updates_combination_when_selected(self):
self.gui.select_row(self.label)
self.gui.select_row(self.mapping_selection_label)
self.assertEqual(
self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])
self.mapping_selection_label.combination,
EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
self.message_broker.send(
CombinationUpdate(
@ -358,11 +504,14 @@ class TestSelectionLabel(ComponentBaseTest):
EventCombination((1, KEY_A, 1)),
)
)
self.assertEqual(self.label.combination, EventCombination((1, KEY_A, 1)))
self.assertEqual(
self.mapping_selection_label.combination, EventCombination((1, KEY_A, 1))
)
def test_doesnt_update_combination_when_not_selected(self):
self.assertEqual(
self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])
self.mapping_selection_label.combination,
EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
self.message_broker.send(
CombinationUpdate(
@ -371,7 +520,8 @@ class TestSelectionLabel(ComponentBaseTest):
)
)
self.assertEqual(
self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])
self.mapping_selection_label.combination,
EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
def test_updates_name_when_mapping_changed_and_combination_matches(self):
@ -381,7 +531,7 @@ class TestSelectionLabel(ComponentBaseTest):
name="foo",
)
)
self.assertEqual(self.label.label.get_label(), "foo")
self.assertEqual(self.mapping_selection_label.label.get_label(), "foo")
def test_ignores_mapping_when_combination_does_not_match(self):
self.message_broker.send(
@ -390,11 +540,11 @@ class TestSelectionLabel(ComponentBaseTest):
name="foo",
)
)
self.assertEqual(self.label.label.get_label(), "a + b")
self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b")
def test_edit_button_visibility(self):
# start off invisible
self.assertFalse(self.label.edit_btn.get_visible())
self.assertFalse(self.mapping_selection_label.edit_btn.get_visible())
# load the mapping associated with the ListBoxRow
self.message_broker.send(
@ -402,7 +552,7 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
)
self.assertTrue(self.label.edit_btn.get_visible())
self.assertTrue(self.mapping_selection_label.edit_btn.get_visible())
# load a different row
self.message_broker.send(
@ -410,7 +560,7 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_C, 1)]),
)
)
self.assertFalse(self.label.edit_btn.get_visible())
self.assertFalse(self.mapping_selection_label.edit_btn.get_visible())
def test_enter_edit_mode_focuses_name_input(self):
self.message_broker.send(
@ -418,8 +568,10 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
)
self.label.edit_btn.clicked()
self.controller_mock.set_focus.assert_called_once_with(self.label.name_input)
self.mapping_selection_label.edit_btn.clicked()
self.controller_mock.set_focus.assert_called_once_with(
self.mapping_selection_label.name_input
)
def test_enter_edit_mode_updates_visibility(self):
self.message_broker.send(
@ -428,9 +580,9 @@ class TestSelectionLabel(ComponentBaseTest):
)
)
self.assert_selected()
self.label.edit_btn.clicked()
self.mapping_selection_label.edit_btn.clicked()
self.assert_edit_mode()
self.label.name_input.activate() # aka hit the return key
self.mapping_selection_label.name_input.activate() # aka hit the return key
self.assert_selected()
def test_leaves_edit_mode_on_esc(self):
@ -439,15 +591,17 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
)
self.label.edit_btn.clicked()
self.mapping_selection_label.edit_btn.clicked()
self.assert_edit_mode()
self.label.name_input.set_text("foo")
self.mapping_selection_label.name_input.set_text("foo")
event = Gdk.Event()
event.key.keyval = Gdk.KEY_Escape
self.label._on_gtk_rename_abort(None, event.key) # send the "key-press-event"
self.mapping_selection_label._on_gtk_rename_abort(
None, event.key
) # send the "key-press-event"
self.assert_selected()
self.assertEqual(self.label.label.get_text(), "a + b")
self.assertEqual(self.mapping_selection_label.label.get_text(), "a + b")
self.controller_mock.update_mapping.assert_not_called()
def test_update_name(self):
@ -456,10 +610,10 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
)
self.label.edit_btn.clicked()
self.mapping_selection_label.edit_btn.clicked()
self.label.name_input.set_text("foo")
self.label.name_input.activate()
self.mapping_selection_label.name_input.set_text("foo")
self.mapping_selection_label.name_input.activate()
self.controller_mock.update_mapping.assert_called_once_with(name="foo")
def test_name_input_contains_combination_when_name_not_set(self):
@ -468,8 +622,8 @@ class TestSelectionLabel(ComponentBaseTest):
event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]),
)
)
self.label.edit_btn.clicked()
self.assertEqual(self.label.name_input.get_text(), "a + b")
self.mapping_selection_label.edit_btn.clicked()
self.assertEqual(self.mapping_selection_label.name_input.get_text(), "a + b")
def test_name_input_contains_name(self):
self.message_broker.send(
@ -478,8 +632,8 @@ class TestSelectionLabel(ComponentBaseTest):
name="foo",
)
)
self.label.edit_btn.clicked()
self.assertEqual(self.label.name_input.get_text(), "foo")
self.mapping_selection_label.edit_btn.clicked()
self.assertEqual(self.mapping_selection_label.name_input.get_text(), "foo")
def test_removes_name_when_name_matches_combination(self):
self.message_broker.send(
@ -488,9 +642,9 @@ class TestSelectionLabel(ComponentBaseTest):
name="foo",
)
)
self.label.edit_btn.clicked()
self.label.name_input.set_text("a + b")
self.label.name_input.activate()
self.mapping_selection_label.edit_btn.clicked()
self.mapping_selection_label.name_input.set_text("a + b")
self.mapping_selection_label.name_input.activate()
self.controller_mock.update_mapping.assert_called_once_with(name="")
@ -514,12 +668,6 @@ class TestCodeEditor(ComponentBaseTest):
self.message_broker.send(MappingData(output_symbol="foo"))
self.assertEqual(self.get_text(), "Record the input first")
def test_inactive_when_mapping_is_empty(self):
self.controller_mock.is_empty_mapping.return_value = True
self.message_broker.send(MappingData(output_symbol="foo"))
self.assertFalse(self.gui.get_sensitive())
self.assertLess(self.gui.get_opacity(), 0.6)
def test_active_when_mapping_is_not_empty(self):
self.message_broker.send(MappingData(output_symbol="foo"))
self.assertTrue(self.gui.get_sensitive())
@ -563,42 +711,45 @@ class TestCodeEditor(ComponentBaseTest):
class TestRecordingToggle(ComponentBaseTest):
def setUp(self) -> None:
super(TestRecordingToggle, self).setUp()
self.gui = Gtk.ToggleButton()
self.toggle = RecordingToggle(
self.message_broker, self.controller_mock, self.gui
self.toggle_button = Gtk.ToggleButton()
self.recording_toggle = RecordingToggle(
self.message_broker,
self.controller_mock,
self.toggle_button,
)
def assert_recording(self):
self.assertEqual(self.gui.get_label(), "Recording ...")
self.assertTrue(self.gui.get_active())
self.label = Gtk.Label()
self.recording_status = RecordingStatus(self.message_broker, self.label)
def assert_not_recording(self):
self.assertEqual(self.gui.get_label(), "Record Input")
self.assertFalse(self.gui.get_active())
self.assertFalse(self.label.get_visible())
self.assertFalse(self.toggle_button.get_active())
def test_starts_recording(self):
self.gui.set_active(True)
self.toggle_button.set_active(True)
self.controller_mock.start_key_recording.assert_called_once()
def test_stops_recording_when_clicked(self):
self.gui.set_active(True)
self.gui.set_active(False)
self.toggle_button.set_active(True)
self.toggle_button.set_active(False)
self.controller_mock.stop_key_recording.assert_called_once()
def test_not_recording_initially(self):
self.assert_not_recording()
def test_shows_recording_when_toggled(self):
self.gui.set_active(True)
self.assert_recording()
def test_shows_recording_when_message_sent(self):
self.assertFalse(self.label.get_visible())
self.message_broker.signal(MessageType.recording_started)
self.assertTrue(self.label.get_visible())
def test_shows_not_recording_after_toggle(self):
self.gui.set_active(True)
self.gui.set_active(False)
self.toggle_button.set_active(True)
self.toggle_button.set_active(False)
self.assert_not_recording()
def test_shows_not_recording_when_recording_finished(self):
self.gui.set_active(True)
self.toggle_button.set_active(True)
self.message_broker.signal(MessageType.recording_finished)
self.assert_not_recording()
@ -1089,13 +1240,19 @@ class TestTransformationDrawArea(ComponentBaseTest):
self.draw_area = Gtk.DrawingArea()
self.gui.add(self.draw_area)
self.transform_draw_area = TransformationDrawArea(
self.message_broker, self.controller_mock, self.draw_area
self.message_broker,
self.controller_mock,
self.draw_area,
)
def test_draws_transform(self):
with spy(self.transform_draw_area, "_transformation") as mock:
# show the window, it takes some time and iterations until it pops up
self.gui.show_all()
gtk_iteration()
for _ in range(5):
gtk_iteration()
time.sleep(0.01)
mock.assert_called()
def test_updates_transform_when_mapping_updates(self):
@ -1286,3 +1443,174 @@ class TestRelativeInputCutoffInput(ComponentBaseTest):
)
)
self.assert_active()
class TestRequireActiveMapping(ComponentBaseTest):
def test_no_reqorded_input_required(self):
self.box = Gtk.Box()
RequireActiveMapping(
self.message_broker,
self.box,
require_recorded_input=False,
)
combination = EventCombination([(1, KEY_A, 1)])
self.message_broker.send(MappingData())
self.assert_inactive(self.box)
self.message_broker.send(PresetData(name="preset", mappings=()))
self.assert_inactive(self.box)
# a mapping is available, that is all the widget needs to be activated. one
# mapping is always selected, so there is no need to check the mapping message
self.message_broker.send(PresetData(name="preset", mappings=(combination,)))
self.assert_active(self.box)
self.message_broker.send(MappingData(event_combination=combination))
self.assert_active(self.box)
self.message_broker.send(MappingData())
self.assert_active(self.box)
def test_recorded_input_required(self):
self.box = Gtk.Box()
RequireActiveMapping(
self.message_broker,
self.box,
require_recorded_input=True,
)
combination = EventCombination([(1, KEY_A, 1)])
self.message_broker.send(MappingData())
self.assert_inactive(self.box)
self.message_broker.send(PresetData(name="preset", mappings=()))
self.assert_inactive(self.box)
self.message_broker.send(PresetData(name="preset", mappings=(combination,)))
self.assert_inactive(self.box)
# the widget will be enabled once a mapping with recorded input is selected
self.message_broker.send(MappingData(event_combination=combination))
self.assert_active(self.box)
# this mapping doesn't have input recorded, so the box is disabled
self.message_broker.send(MappingData())
self.assert_inactive(self.box)
def assert_inactive(self, widget: Gtk.Widget):
self.assertFalse(widget.get_sensitive())
self.assertLess(widget.get_opacity(), 0.6)
self.assertGreater(widget.get_opacity(), 0.4)
def assert_active(self, widget: Gtk.Widget):
self.assertTrue(widget.get_sensitive())
self.assertEqual(widget.get_opacity(), 1)
class TestStack(ComponentBaseTest):
def test_switches_pages(self):
self.stack = Gtk.Stack()
self.stack.add_named(Gtk.Label(), "Devices")
self.stack.add_named(Gtk.Label(), "Presets")
self.stack.add_named(Gtk.Label(), "Editor")
self.stack.show_all()
stack_wrapper = Stack(self.message_broker, self.controller_mock, self.stack)
self.message_broker.send(DoStackSwitch(Stack.devices_page))
self.assertEqual(self.stack.get_visible_child_name(), "Devices")
self.message_broker.send(DoStackSwitch(Stack.presets_page))
self.assertEqual(self.stack.get_visible_child_name(), "Presets")
self.message_broker.send(DoStackSwitch(Stack.editor_page))
self.assertEqual(self.stack.get_visible_child_name(), "Editor")
class TestBreadcrumbs(ComponentBaseTest):
def test_breadcrumbs(self):
self.label_1 = Gtk.Label()
self.label_2 = Gtk.Label()
self.label_3 = Gtk.Label()
self.label_4 = Gtk.Label()
self.label_5 = Gtk.Label()
Breadcrumbs(
self.message_broker,
self.label_1,
show_device_group=False,
show_preset=False,
show_mapping=False,
)
Breadcrumbs(
self.message_broker,
self.label_2,
show_device_group=True,
show_preset=False,
show_mapping=False,
)
Breadcrumbs(
self.message_broker,
self.label_3,
show_device_group=True,
show_preset=True,
show_mapping=False,
)
Breadcrumbs(
self.message_broker,
self.label_4,
show_device_group=True,
show_preset=True,
show_mapping=True,
)
Breadcrumbs(
self.message_broker,
self.label_5,
show_device_group=False,
show_preset=False,
show_mapping=True,
)
self.assertEqual(self.label_1.get_text(), "")
self.assertEqual(self.label_2.get_text(), "?")
self.assertEqual(self.label_3.get_text(), "? / ?")
self.assertEqual(self.label_4.get_text(), "? / ? / ?")
self.assertEqual(self.label_5.get_text(), "?")
self.message_broker.send(PresetData("preset", None))
self.assertEqual(self.label_1.get_text(), "")
self.assertEqual(self.label_2.get_text(), "?")
self.assertEqual(self.label_3.get_text(), "? / preset")
self.assertEqual(self.label_4.get_text(), "? / preset / ?")
self.assertEqual(self.label_5.get_text(), "?")
self.message_broker.send(GroupData("group", ()))
self.assertEqual(self.label_1.get_text(), "")
self.assertEqual(self.label_2.get_text(), "group")
self.assertEqual(self.label_3.get_text(), "group / preset")
self.assertEqual(self.label_4.get_text(), "group / preset / ?")
self.assertEqual(self.label_5.get_text(), "?")
self.message_broker.send(MappingData())
self.assertEqual(self.label_1.get_text(), "")
self.assertEqual(self.label_2.get_text(), "group")
self.assertEqual(self.label_3.get_text(), "group / preset")
self.assertEqual(self.label_4.get_text(), "group / preset / Empty Mapping")
self.assertEqual(self.label_5.get_text(), "Empty Mapping")
self.message_broker.send(MappingData(name="mapping"))
self.assertEqual(self.label_4.get_text(), "group / preset / mapping")
self.assertEqual(self.label_5.get_text(), "mapping")
combination = EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])
self.message_broker.send(MappingData(event_combination=combination))
self.assertEqual(self.label_4.get_text(), "group / preset / a + b")
self.assertEqual(self.label_5.get_text(), "a + b")
combination = EventCombination([(1, KEY_A, 1)])
self.message_broker.send(MappingData(name="qux", event_combination=combination))
self.assertEqual(self.label_4.get_text(), "group / preset / qux")
self.assertEqual(self.label_5.get_text(), "qux")

@ -27,6 +27,13 @@ from inputremapper.configs.data import get_data_path
class TestData(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.original_location = pkg_resources.require("input-remapper")[0].location
def tearDown(self):
pkg_resources.require("input-remapper")[0].location = self.original_location
def test_data_editable(self):
path = os.getcwd()
pkg_resources.require("input-remapper")[0].location = path

@ -26,21 +26,19 @@ from typing import Tuple, List
from tests.test import (
get_project_root,
logger,
tmp,
push_events,
new_event,
spy,
cleanup,
uinput_write_history_pipe,
MAX_ABS,
EVENT_READ_TIMEOUT,
MIN_ABS,
get_ui_mapping,
prepare_presets,
fixtures,
push_event,
)
from tests.integration.test_components import FlowBoxTestUtils
import random
import sys
import time
import atexit
@ -54,12 +52,8 @@ from evdev.ecodes import (
KEY_LEFTSHIFT,
KEY_A,
KEY_Q,
ABS_RX,
EV_REL,
REL_X,
ABS_X,
)
import json
from unittest.mock import patch, MagicMock, call
from importlib.util import spec_from_loader, module_from_spec
from importlib.machinery import SourceFileLoader
@ -67,30 +61,30 @@ from importlib.machinery import SourceFileLoader
import gi
from inputremapper.input_event import InputEvent
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GLib, Gdk, GtkSource
from inputremapper.configs.system_mapping import system_mapping, XMODMAP_FILENAME
from inputremapper.configs.mapping import UIMapping, Mapping
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.configs.mapping import Mapping
from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path
from inputremapper.configs.global_config import global_config, WHEEL, MOUSE, BUTTONS
from inputremapper.configs.global_config import global_config
from inputremapper.groups import _Groups
from inputremapper.gui.data_manager import DataManager
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
StatusData,
CombinationRecorded,
)
from inputremapper.gui.components import SelectionLabel, SET_KEY_FIRST
from inputremapper.gui.reader import Reader
from inputremapper.gui.messages.message_data import StatusData, CombinationRecorded
from inputremapper.gui.components.editor import MappingSelectionLabel, SET_KEY_FIRST
from inputremapper.gui.components.device_groups import DeviceGroupEntry
from inputremapper.gui.controller import Controller
from inputremapper.gui.helper import RootHelper
from inputremapper.gui.utils import gtk_iteration
from inputremapper.gui.utils import gtk_iteration, Colors
from inputremapper.gui.user_interface import UserInterface
from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN, STOPPED
from inputremapper.injection.injector import RUNNING, UNKNOWN, STOPPED
from inputremapper.event_combination import EventCombination
from inputremapper.daemon import Daemon, DaemonProxy
@ -241,24 +235,28 @@ class PatchedConfirmDelete:
def __init__(self, user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT):
self.response = response
self.user_interface = user_interface
self._original_create_dialog = user_interface._create_dialog
self.patch = None
def _confirm_delete_run_patch(self):
def _create_dialog_patch(self, *args, **kwargs):
"""A patch for the deletion confirmation that briefly shows the dialog."""
confirm_cancel_dialog = self.user_interface.confirm_cancel_dialog
confirm_cancel_dialog = self._original_create_dialog(*args, **kwargs)
# the emitted signal causes the dialog to close
GLib.timeout_add(
100,
lambda: confirm_cancel_dialog.emit("response", self.response),
)
Gtk.MessageDialog.run(confirm_cancel_dialog) # don't recursively call the patch
return self.response
confirm_cancel_dialog.run = lambda: self.response
return confirm_cancel_dialog
def __enter__(self):
self.patch = patch.object(
self.user_interface.get("confirm-cancel"),
"run",
self._confirm_delete_run_patch,
self.user_interface,
"_create_dialog",
self._create_dialog_patch,
)
self.patch.__enter__()
@ -279,20 +277,22 @@ class GuiTestBase(unittest.TestCase):
) = launch()
get = self.user_interface.get
self.device_selection: Gtk.ComboBox = get("device_selection")
self.device_selection: Gtk.FlowBox = get("device_selection")
self.preset_selection: Gtk.ComboBoxText = get("preset_selection")
self.selection_label_listbox: Gtk.ListBox = get("selection_label_listbox")
self.target_selection: Gtk.ComboBox = get("target-selector")
self.recording_toggle: Gtk.ToggleButton = get("key_recording_toggle")
self.recording_status: Gtk.ToggleButton = get("recording_status")
self.status_bar: Gtk.Statusbar = get("status_bar")
self.autoload_toggle: Gtk.Switch = get("preset_autoload_switch")
self.code_editor: GtkSource.View = get("code_editor")
self.output_box: GtkSource.View = get("output")
self.delete_preset_btn: Gtk.Button = get("delete_preset")
self.copy_preset_btn: Gtk.Button = get("copy_preset")
self.create_preset_btn: Gtk.Button = get("create_preset")
self.start_injector_btn: Gtk.Button = get("apply_preset")
self.stop_injector_btn: Gtk.Button = get("apply_system_layout")
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.create_mapping_btn: Gtk.Button = get("create_mapping_button")
@ -349,18 +349,6 @@ class GuiTestBase(unittest.TestCase):
gtk_iteration()
time.sleep(0.002)
def activate_recording_toggle(self):
logger.info("Activating the recording toggle")
self.recording_toggle.set_active(True)
gtk_iteration()
def disable_recording_toggle(self):
logger.info("Deactivating the recording toggle")
self.recording_toggle.set_active(False)
gtk_iteration()
# should happen automatically:
self.assertFalse(self.recording_toggle.get_active())
def set_focus(self, widget):
logger.info("Focusing %s", widget)
@ -368,7 +356,7 @@ class GuiTestBase(unittest.TestCase):
self.throttle()
def get_selection_labels(self) -> List[SelectionLabel]:
def get_selection_labels(self) -> List[MappingSelectionLabel]:
return self.selection_label_listbox.get_children()
def get_status_text(self):
@ -417,12 +405,71 @@ class GuiTestBase(unittest.TestCase):
gtk_iteration()
class TestColors(GuiTestBase):
# requires a running ui, otherwise fails with segmentation faults
def test_get_color_falls_back(self):
fallback = Gdk.RGBA(0, 0.5, 1, 0.8)
color = Colors.get_color(["doesnt_exist_1234"], fallback)
self.assertIsInstance(color, Gdk.RGBA)
self.assertAlmostEqual(color.red, fallback.red, delta=0.01)
self.assertAlmostEqual(color.green, fallback.green, delta=0.01)
self.assertAlmostEqual(color.blue, fallback.blue, delta=0.01)
self.assertAlmostEqual(color.alpha, fallback.alpha, delta=0.01)
def test_get_color_works(self):
fallback = Gdk.RGBA(1, 0, 1, 0.1)
color = Colors.get_color(
["accent_bg_color", "theme_selected_bg_color"], fallback
)
self.assertIsInstance(color, Gdk.RGBA)
self.assertNotAlmostEquals(color.red, fallback.red, delta=0.01)
self.assertNotAlmostEquals(color.green, fallback.blue, delta=0.01)
self.assertNotAlmostEquals(color.blue, fallback.green, delta=0.01)
self.assertNotAlmostEquals(color.alpha, fallback.alpha, delta=0.01)
def _test_color_wont_fallback(self, get_color, fallback):
color = get_color()
self.assertIsInstance(color, Gdk.RGBA)
if (
(abs(color.green - fallback.green) < 0.01)
and (abs(color.red - fallback.red) < 0.01)
and (abs(color.blue - fallback.blue) < 0.01)
and (abs(color.alpha - fallback.alpha) < 0.01)
):
raise AssertionError(
f"Color {color.to_string()} is similar to {fallback.toString()}"
)
def test_get_colors(self):
self._test_color_wont_fallback(Colors.get_accent_color, Colors.fallback_accent)
self._test_color_wont_fallback(Colors.get_border_color, Colors.fallback_border)
self._test_color_wont_fallback(
Colors.get_background_color, Colors.fallback_background
)
self._test_color_wont_fallback(Colors.get_base_color, Colors.fallback_base)
self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font)
class TestGui(GuiTestBase):
"""For tests that use the window.
It is intentional that there is no access to the Components.
Try to modify the configuration only by calling functions of the window.
For example by simulating clicks on buttons. Get the widget to interact with
by going through the windows children. (See click_on_group for inspiration)
"""
def click_on_group(self, group_key):
for child in self.device_selection.get_children():
device_group_entry = child.get_children()[0]
if device_group_entry.group_key == group_key:
device_group_entry.set_active(True)
def test_can_start(self):
self.assertIsNotNone(self.user_interface)
self.assertTrue(self.user_interface.window.get_visible())
@ -431,15 +478,21 @@ class TestGui(GuiTestBase):
selection_labels = self.selection_label_listbox.get_children()
self.assertEqual(len(selection_labels), 0)
self.assertEqual(len(self.data_manager.active_preset), 0)
self.assertEqual(self.preset_selection.get_active_id(), "new preset")
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset"
)
self.assertEqual(self.recording_toggle.get_label(), "Record")
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
def test_initial_state(self):
self.assertEqual(self.data_manager.active_group.key, "Foo Device")
self.assertEqual(self.device_selection.get_active_id(), "Foo Device")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.device_selection).name, "Foo Device"
)
self.assertEqual(self.data_manager.active_preset.name, "preset3")
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3"
)
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.assertEqual(
@ -480,8 +533,9 @@ class TestGui(GuiTestBase):
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
self.preset_selection.set_active_id("preset2")
self.click_on_group("Foo Device 2")
FlowBoxTestUtils.set_active(self.preset_selection, "preset2")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
@ -490,17 +544,17 @@ class TestGui(GuiTestBase):
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
self.preset_selection.set_active_id("preset2")
self.click_on_group("Foo Device 2")
FlowBoxTestUtils.set_active(self.preset_selection, "preset2")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.preset_selection.set_active_id("preset3")
FlowBoxTestUtils.set_active(self.preset_selection, "preset3")
gtk_iteration()
self.autoload_toggle.set_active(True)
gtk_iteration()
self.preset_selection.set_active_id("preset2")
FlowBoxTestUtils.set_active(self.preset_selection, "preset2")
gtk_iteration()
self.assertFalse(self.data_manager.get_autoload())
self.assertFalse(self.autoload_toggle.get_active())
@ -511,22 +565,24 @@ class TestGui(GuiTestBase):
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device 2")
self.click_on_group("Foo Device 2")
gtk_iteration()
self.autoload_toggle.set_active(True)
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
self.device_selection.set_active_id("Foo Device")
self.click_on_group("Foo Device")
gtk_iteration()
self.assertTrue(self.data_manager.get_autoload())
self.assertTrue(self.autoload_toggle.get_active())
def test_select_device_without_preset(self):
# creates a new empty preset when no preset exists for the device
self.device_selection.set_active_id("Bar Device")
self.assertEqual(self.preset_selection.get_active_id(), "new preset")
self.click_on_group("Bar Device")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset"
)
self.assertEqual(len(self.data_manager.active_preset), 0)
# it creates the file for that right away. It may have been possible
@ -538,22 +594,26 @@ class TestGui(GuiTestBase):
self.assertEqual(file.read(), "")
def test_recording_toggle_labels(self):
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertFalse(self.recording_status.get_visible())
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Recording ...")
self.assertTrue(self.recording_status.get_visible())
self.recording_toggle.set_active(False)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertFalse(self.recording_status.get_visible())
def test_recording_label_updates_on_recording_finished(self):
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertFalse(self.recording_status.get_visible())
self.recording_toggle.set_active(True)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Recording ...")
self.assertTrue(self.recording_status.get_visible())
self.message_broker.signal(MessageType.recording_finished)
gtk_iteration()
self.assertEqual(self.recording_toggle.get_label(), "Record Input")
self.assertFalse(self.recording_status.get_visible())
self.assertFalse(self.recording_toggle.get_active())
def test_events_from_helper_arrive(self):
@ -562,9 +622,12 @@ class TestGui(GuiTestBase):
gtk_iteration()
mock1 = MagicMock()
mock2 = MagicMock()
mock3 = MagicMock()
self.message_broker.subscribe(MessageType.combination_recorded, mock1)
self.message_broker.subscribe(MessageType.recording_finished, mock2)
self.message_broker.subscribe(MessageType.recording_started, mock3)
self.recording_toggle.set_active(True)
mock3.assert_called_once()
gtk_iteration()
push_events(
@ -595,6 +658,7 @@ class TestGui(GuiTestBase):
mock2.assert_called_once()
self.assertFalse(self.recording_toggle.get_active())
mock3.assert_called_once()
def test_cannot_create_duplicate_event_combination(self):
# load a device with more capabilities
@ -682,7 +746,7 @@ class TestGui(GuiTestBase):
)
def test_create_simple_mapping(self):
self.device_selection.set_active_id("Foo Device 2")
self.click_on_group("Foo Device 2")
# 1. create a mapping
self.create_mapping_btn.clicked()
gtk_iteration()
@ -764,7 +828,7 @@ class TestGui(GuiTestBase):
)
def test_show_status(self):
self.message_broker.send(StatusData(0, "a" * 100))
self.message_broker.send(StatusData(0, "a" * 500))
gtk_iteration()
text = self.get_status_text()
self.assertIn("...", text)
@ -1043,7 +1107,7 @@ class TestGui(GuiTestBase):
self.controller.create_mapping()
gtk_iteration()
row: SelectionLabel = self.selection_label_listbox.get_selected_row()
row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row()
self.assertEqual(row.combination, EventCombination.empty_combination())
self.assertEqual(row.label.get_text(), "Empty Mapping")
self.assertIs(self.selection_label_listbox.get_row_at_index(2), row)
@ -1064,7 +1128,7 @@ class TestGui(GuiTestBase):
def test_selection_label_uses_name_if_available(self):
self.controller.load_preset("preset1")
gtk_iteration()
row: SelectionLabel = self.selection_label_listbox.get_selected_row()
row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row()
self.assertEqual(row.label.get_text(), "1")
self.assertIs(row, self.selection_label_listbox.get_row_at_index(0))
@ -1278,22 +1342,23 @@ class TestGui(GuiTestBase):
def test_select_device(self):
# simple test to make sure we can switch between devices
# more detailed tests in TestController and TestDataManager
self.device_selection.set_active_id("Bar Device")
self.click_on_group("Bar Device")
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)}
self.assertEqual(entries, {"new preset"})
self.device_selection.set_active_id("Foo Device")
self.click_on_group("Foo Device")
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)}
self.assertEqual(entries, {"preset1", "preset2", "preset3"})
# make sure a preset and mapping was loaded
self.assertIsNotNone(self.data_manager.active_preset)
self.assertEqual(
self.data_manager.active_preset.name, self.preset_selection.get_active_id()
self.data_manager.active_preset.name,
FlowBoxTestUtils.get_active_entry(self.preset_selection).name,
)
self.assertIsNotNone(self.data_manager.active_mapping)
self.assertEqual(
@ -1304,9 +1369,9 @@ class TestGui(GuiTestBase):
def test_select_preset(self):
# simple test to make sure we can switch between presets
# more detailed tests in TestController and TestDataManager
self.device_selection.set_active_id("Foo Device 2")
self.click_on_group("Foo Device 2")
gtk_iteration()
self.preset_selection.set_active_id("preset1")
FlowBoxTestUtils.set_active(self.preset_selection, "preset1")
gtk_iteration()
mappings = {
@ -1321,7 +1386,7 @@ class TestGui(GuiTestBase):
)
self.assertFalse(self.autoload_toggle.get_active())
self.preset_selection.set_active_id("preset2")
FlowBoxTestUtils.set_active(self.preset_selection, "preset2")
gtk_iteration()
mappings = {
@ -1341,20 +1406,25 @@ class TestGui(GuiTestBase):
# more detailed tests in TestController and TestDataManager
# check the initial state
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)}
self.assertEqual(entries, {"preset1", "preset2", "preset3"})
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3"
)
self.copy_preset_btn.clicked()
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)}
self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"})
self.assertEqual(self.preset_selection.get_active_id(), "preset3 copy")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name,
"preset3 copy",
)
self.copy_preset_btn.clicked()
gtk_iteration()
entries = {entry[0] for entry in self.preset_selection.get_child().get_model()}
entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)}
self.assertEqual(
entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"}
)
@ -1456,7 +1526,7 @@ class TestGui(GuiTestBase):
def test_cannot_record_keys(self):
self.controller.load_group("Foo Device 2")
self.assertNotEqual(self.data_manager.get_state(), RUNNING)
self.assertNotIn("Stop Injection", self.get_status_text())
self.assertNotIn("Stop", self.get_status_text())
self.recording_toggle.set_active(True)
gtk_iteration()
@ -1481,7 +1551,7 @@ class TestGui(GuiTestBase):
gtk_iteration()
self.assertFalse(self.recording_toggle.get_active())
text = self.get_status_text()
self.assertIn("Stop Injection", text)
self.assertIn("Stop", text)
def test_start_injecting(self):
self.controller.load_group("Foo Device 2")
@ -1533,10 +1603,9 @@ class TestGui(GuiTestBase):
# the input-remapper device will not be shown
self.controller.refresh_groups()
gtk_iteration()
for entry in self.device_selection.get_child().get_model():
# whichever attribute contains "input-remapper"
self.assertNotIn("input-remapper", "".join(entry))
for child in self.device_selection.get_children():
device_group_entry = child.get_children()[0]
self.assertNotIn("input-remapper", device_group_entry.name)
def test_stop_injecting(self):
self.controller.load_group("Foo Device 2")
@ -1594,7 +1663,6 @@ class TestGui(GuiTestBase):
def test_delete_preset(self):
# as per test_initial_state we already have preset3 loaded
self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3")))
with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL):
@ -1613,17 +1681,21 @@ class TestGui(GuiTestBase):
def test_refresh_groups(self):
# sanity check: preset3 should be the newest
self.assertEqual(self.preset_selection.get_active_id(), "preset3")
self.assertEqual(
FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3"
)
# select the older one
self.preset_selection.set_active_id("preset1")
FlowBoxTestUtils.set_active(self.preset_selection, "preset1")
gtk_iteration()
self.assertEqual(self.data_manager.active_preset.name, "preset1")
# add a device that doesn't exist to the dropdown
unknown_key = "key-1234"
self.device_selection.get_child().get_model().insert(
0, [unknown_key, None, "foo"]
self.device_selection.insert(
DeviceGroupEntry(self.message_broker, self.controller, None, unknown_key),
0
# 0, [unknown_key, None, "foo"]
)
self.controller.refresh_groups()
@ -1635,17 +1707,19 @@ class TestGui(GuiTestBase):
# the list contains correct entries
# and the non-existing entry should be removed
entries = [
tuple(entry) for entry in self.device_selection.get_child().get_model()
]
keys = [entry[0] for entry in self.device_selection.get_child().get_model()]
self.assertNotIn(unknown_key, keys)
self.assertIn("Foo Device", keys)
self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries)
self.assertIn(("Foo Device 2", "input-gaming", "Foo Device 2"), entries)
self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries)
self.assertIn(("gamepad", "input-gaming", "gamepad"), entries)
names = FlowBoxTestUtils.get_child_names(self.device_selection)
icons = FlowBoxTestUtils.get_child_icons(self.device_selection)
self.assertNotIn(unknown_key, names)
self.assertIn("Foo Device", names)
self.assertIn("Foo Device 2", names)
self.assertIn("Bar Device", names)
self.assertIn("gamepad", names)
self.assertIn("input-keyboard", icons)
self.assertIn("input-gaming", icons)
self.assertIn("input-keyboard", icons)
self.assertIn("input-gaming", icons)
# it won't crash due to "list index out of range"
# when `types` is an empty list. Won't show an icon
@ -1653,8 +1727,8 @@ class TestGui(GuiTestBase):
self.data_manager._reader.send_groups()
gtk_iteration()
self.assertIn(
("Foo Device 2", None, "Foo Device 2"),
[tuple(entry) for entry in self.device_selection.get_child().get_model()],
"Foo Device 2",
FlowBoxTestUtils.get_child_names(self.device_selection),
)
def test_shared_presets(self):
@ -1677,6 +1751,7 @@ class TestGui(GuiTestBase):
def test_delete_last_preset(self):
with PatchedConfirmDelete(self.user_interface):
# as per test_initial_state we already have preset3 loaded
self.assertEqual(self.data_manager.active_preset.name, "preset3")
self.delete_preset_btn.clicked()
gtk_iteration()
@ -1697,13 +1772,13 @@ class TestGui(GuiTestBase):
device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}"
self.assertTrue(os.path.exists(f"{device_path}/new preset.json"))
def test_enable_disable_symbol_input(self):
def test_enable_disable_output(self):
# load a group without any presets
self.controller.load_group("Bar Device")
# should be disabled by default since no key is recorded yet
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
self.assertFalse(self.output_box.get_sensitive())
# create a mapping
self.controller.create_mapping()
@ -1711,7 +1786,7 @@ class TestGui(GuiTestBase):
# should still be disabled
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
self.assertFalse(self.output_box.get_sensitive())
# enable it by sending a combination
self.controller.start_key_recording()
@ -1726,7 +1801,7 @@ class TestGui(GuiTestBase):
self.throttle(50) # give time for the input to arrive
self.assertEqual(self.get_unfiltered_symbol_input_text(), "")
self.assertTrue(self.code_editor.get_sensitive())
self.assertTrue(self.output_box.get_sensitive())
# disable it by deleting the mapping
with PatchedConfirmDelete(self.user_interface):
@ -1734,7 +1809,7 @@ class TestGui(GuiTestBase):
gtk_iteration()
self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST)
self.assertFalse(self.code_editor.get_sensitive())
self.assertFalse(self.output_box.get_sensitive())
class TestAutocompletion(GuiTestBase):

@ -1,17 +1,18 @@
import unittest
from unittest.mock import MagicMock, patch
from evdev.ecodes import EV_KEY, KEY_A
from unittest.mock import MagicMock
import gi
from evdev.ecodes import EV_KEY, KEY_A
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GtkSource, Gdk, GObject, GLib
from gi.repository import Gtk, Gdk, GLib
from inputremapper.gui.utils import gtk_iteration
from tests.test import quick_cleanup
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
from inputremapper.gui.user_interface import UserInterface
from inputremapper.configs.mapping import MappingData
from inputremapper.event_combination import EventCombination
@ -54,7 +55,7 @@ class TestUserInterface(unittest.TestCase):
mock.assert_not_called()
def test_connected_shortcuts(self):
should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete}
should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete, Gdk.KEY_n}
connected = set(self.user_interface.shortcuts.keys())
self.assertEqual(connected, should_be_connected)
@ -104,4 +105,6 @@ class TestUserInterface(unittest.TestCase):
gtk_iteration()
label: Gtk.Label = self.user_interface.get("combination-label")
self.assertEqual(label.get_text(), "no input configured")
self.assertEqual(label.get_opacity(), 0.4)
# 0.5 != 0.501960..., for whatever reason this number is all screwed up
self.assertAlmostEqual(label.get_opacity(), 0.5, delta=0.1)

@ -775,7 +775,7 @@ from inputremapper.configs.global_config import global_config
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.groups import groups, _Groups
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.gui.message_broker import MessageBroker
from inputremapper.gui.messages.message_broker import MessageBroker
from inputremapper.gui.reader import Reader
from inputremapper.configs.paths import get_config_path, get_preset_path
from inputremapper.configs.preset import Preset

@ -39,6 +39,7 @@ from inputremapper.injection.injector import (
)
from inputremapper.input_event import InputEvent
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
@ -47,17 +48,19 @@ from gi.repository import Gtk
# from inputremapper.gui.helper import is_helper_running
from inputremapper.event_combination import EventCombination
from inputremapper.groups import _Groups
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
Signal,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
GroupsData,
GroupData,
PresetData,
StatusData,
CombinationUpdate,
CombinationRecorded,
CombinationUpdate,
UserConfirmRequest,
)
from inputremapper.gui.reader import Reader
@ -620,6 +623,23 @@ class TestController(unittest.TestCase):
self.controller.stop_key_recording()
self.user_interface.connect_shortcuts.assert_called_once()
def test_recording_messages(self):
mock1 = MagicMock()
mock2 = MagicMock()
self.message_broker.subscribe(MessageType.recording_started, mock1)
self.message_broker.subscribe(MessageType.recording_finished, mock2)
self.message_broker.signal(MessageType.init)
self.controller.start_key_recording()
mock1.assert_called_once()
mock2.assert_not_called()
self.controller.stop_key_recording()
mock1.assert_called_once()
mock2.assert_called_once()
def test_key_recording_updates_mapping_combination(self):
prepare_presets()
self.data_manager.load_group("Foo Device 2")
@ -1198,7 +1218,7 @@ class TestController(unittest.TestCase):
self.message_broker.subscribe(MessageType.user_confirm_request, f)
self.controller.update_mapping(mapping_type="analog")
self.assertIn("This will remove the 'a' from the text input", request.msg)
self.assertIn('This will remove "a" from the text input', request.msg)
def test_update_mapping_type_will_notify_user_to_recorde_analog_input(self):
prepare_presets()
@ -1214,7 +1234,7 @@ class TestController(unittest.TestCase):
self.message_broker.subscribe(MessageType.user_confirm_request, f)
self.controller.update_mapping(mapping_type="analog")
self.assertIn("Note: you need to recorde an analog input.", request.msg)
self.assertIn("You need to record an analog input.", request.msg)
def test_update_mapping_type_will_tell_user_which_input_is_used_as_analog(self):
prepare_presets()
@ -1232,7 +1252,7 @@ class TestController(unittest.TestCase):
self.message_broker.subscribe(MessageType.user_confirm_request, f)
self.controller.update_mapping(mapping_type="analog")
self.assertIn("The input 'Y Down 1' will be used as analog input.", request.msg)
self.assertIn('The input "Y Down 1" will be used as analog input.', request.msg)
def test_update_mapping_type_will_will_autoconfigure_the_input(self):
prepare_presets()
@ -1303,7 +1323,7 @@ class TestController(unittest.TestCase):
self.message_broker.subscribe(MessageType.user_confirm_request, f)
self.controller.update_mapping(mapping_type="key_macro")
self.assertIn("and set a 'Trigger Threshold' for 'Y'.", request.msg)
self.assertIn('and set a "Trigger Threshold" for "Y".', request.msg)
def test_update_mapping_update_to_analog_without_asking(self):
prepare_presets()

@ -31,9 +31,11 @@ from inputremapper.configs.system_mapping import system_mapping
from inputremapper.event_combination import EventCombination
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Groups
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
CombinationUpdate,
@ -137,9 +139,7 @@ class TestDataManager(unittest.TestCase):
expected_preset = Preset(get_preset_path("Foo Device", "preset1"))
expected_preset.load()
expected_mappings = [
(mapping.name, mapping.event_combination) for mapping in expected_preset
]
expected_mappings = list(expected_preset)
self.assertEqual(preset_name, "preset1")
for mapping in expected_mappings:
@ -504,7 +504,7 @@ class TestDataManager(unittest.TestCase):
preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping)
preset.load()
mapping = preset.get_mapping(EventCombination("1,4,1"))
self.assertEqual(mapping.name, "foo")
self.assertEqual(mapping.format_name(), "foo")
self.assertEqual(mapping.output_symbol, "f")
self.assertEqual(mapping.release_timeout, 0.3)
@ -639,17 +639,13 @@ class TestDataManager(unittest.TestCase):
preset_name = listener.calls[0].name
expected_preset = Preset(get_preset_path("Foo Device", "preset2"))
expected_preset.load()
expected_mappings = [
(mapping.name, mapping.event_combination) for mapping in expected_preset
]
expected_mappings = list(expected_preset)
self.assertEqual(preset_name, "preset2")
for mapping in expected_mappings:
self.assertIn(mapping, mappings)
self.assertNotIn(
(deleted_mapping.name, deleted_mapping.event_combination), mappings
)
self.assertNotIn(deleted_mapping, mappings)
def test_cannot_delete_mapping(self):
"""deleting a mapping should not be possible if the mapping was not loaded"""

@ -38,151 +38,3 @@ from evdev.ecodes import (
from inputremapper.configs.global_config import global_config, BUTTONS
from inputremapper.configs.preset import Preset
from inputremapper import utils
class TestDevUtils(unittest.TestCase):
def test_max_abs(self):
self.assertEqual(
utils.get_abs_range(InputDevice("/dev/input/event30"))[1], MAX_ABS
)
self.assertIsNone(utils.get_abs_range(InputDevice("/dev/input/event10")))
def test_will_report_key_up(self):
self.assertFalse(utils.will_report_key_up(new_event(EV_REL, REL_WHEEL, 1)))
self.assertFalse(utils.will_report_key_up(new_event(EV_REL, REL_HWHEEL, -1)))
self.assertTrue(utils.will_report_key_up(new_event(EV_KEY, KEY_A, 1)))
self.assertTrue(utils.will_report_key_up(new_event(EV_ABS, ABS_HAT0X, -1)))
def test_is_wheel(self):
self.assertTrue(utils.is_wheel(new_event(EV_REL, REL_WHEEL, 1)))
self.assertTrue(utils.is_wheel(new_event(EV_REL, REL_HWHEEL, -1)))
self.assertFalse(utils.is_wheel(new_event(EV_KEY, KEY_A, 1)))
self.assertFalse(utils.is_wheel(new_event(EV_ABS, ABS_HAT0X, -1)))
def test_should_map_as_btn(self):
mapping = Preset()
def do(gamepad, event):
return utils.should_map_as_btn(event, mapping, gamepad)
"""D-Pad"""
self.assertTrue(do(1, new_event(EV_ABS, ABS_HAT0X, 1)))
self.assertTrue(do(0, new_event(EV_ABS, ABS_HAT0X, -1)))
"""Mouse movements"""
self.assertTrue(do(1, new_event(EV_REL, REL_WHEEL, 1)))
self.assertTrue(do(0, new_event(EV_REL, REL_WHEEL, -1)))
self.assertTrue(do(1, new_event(EV_REL, REL_HWHEEL, 1)))
self.assertTrue(do(0, new_event(EV_REL, REL_HWHEEL, -1)))
self.assertFalse(do(1, new_event(EV_REL, REL_X, -1)))
"""Regular keys and buttons"""
self.assertTrue(do(1, new_event(EV_KEY, KEY_A, 1)))
self.assertTrue(do(0, new_event(EV_KEY, KEY_A, 1)))
self.assertTrue(do(1, new_event(EV_ABS, ABS_HAT0X, -1)))
self.assertTrue(do(0, new_event(EV_ABS, ABS_HAT0X, -1)))
"""Mousepad events"""
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_SLOT, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_SLOT, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_POSITION_X, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_POSITION_X, 1)))
self.assertFalse(do(1, new_event(EV_KEY, ecodes.BTN_TOUCH, 1)))
self.assertFalse(do(0, new_event(EV_KEY, ecodes.BTN_TOUCH, 1)))
"""Stylus movements"""
self.assertFalse(do(0, new_event(EV_KEY, ecodes.BTN_DIGI, 1)))
self.assertFalse(do(1, new_event(EV_KEY, ecodes.BTN_DIGI, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_TILT_X, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_TILT_X, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_TILT_Y, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_TILT_Y, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_DISTANCE, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_DISTANCE, 1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_PRESSURE, 1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_PRESSURE, 1)))
"""Joysticks"""
# we no longer track the purpose for the gamepad sticks, it is always allowed to map them as buttons
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertTrue(do(1, new_event(EV_ABS, ecodes.ABS_Y, -1)))
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RY, -1)))
self.assertTrue(do(1, new_event(EV_ABS, ecodes.ABS_RY, -1)))
"""Weird events"""
self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MISC, -1)))
self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MISC, -1)))
def test_classify_action(self):
""""""
"""0 to MAX_ABS"""
def do(event):
return utils.classify_action(event, (0, MAX_ABS))
event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS)
self.assertEqual(do(event), 1)
event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS)
self.assertEqual(do(event), 1)
event = new_event(EV_ABS, ecodes.ABS_Y, 0)
self.assertEqual(do(event), -1)
event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4)
self.assertEqual(do(event), -1)
event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 2)
self.assertEqual(do(event), 0)
"""MIN_ABS to MAX_ABS"""
def do2(event):
return utils.classify_action(event, (MIN_ABS, MAX_ABS))
event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS)
self.assertEqual(do2(event), 1)
event = new_event(EV_ABS, ecodes.ABS_Y, MIN_ABS)
self.assertEqual(do2(event), -1)
event = new_event(EV_ABS, ecodes.ABS_X, MIN_ABS // 4)
self.assertEqual(do2(event), 0)
event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS)
self.assertEqual(do2(event), 1)
event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS)
self.assertEqual(do2(event), 1)
event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4)
self.assertEqual(do2(event), 0)
"""None"""
# it just forwards the value
event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS)
self.assertEqual(utils.classify_action(event, None), MAX_ABS)
"""Not a joystick"""
event = new_event(EV_ABS, ecodes.ABS_Z, 1234)
self.assertEqual(do(event), 1)
self.assertEqual(do2(event), 1)
event = new_event(EV_ABS, ecodes.ABS_Z, 0)
self.assertEqual(do(event), 0)
self.assertEqual(do2(event), 0)
event = new_event(EV_ABS, ecodes.ABS_Z, -1234)
self.assertEqual(do(event), -1)
self.assertEqual(do2(event), -1)
event = new_event(EV_KEY, ecodes.KEY_A, 1)
self.assertEqual(do(event), 1)
self.assertEqual(do2(event), 1)
event = new_event(EV_ABS, ecodes.ABS_HAT0X, 0)
self.assertEqual(do(event), 0)
self.assertEqual(do2(event), 0)
event = new_event(EV_ABS, ecodes.ABS_HAT0X, -1)
self.assertEqual(do(event), -1)
self.assertEqual(do2(event), -1)

@ -131,11 +131,11 @@ class TestKey(unittest.TestCase):
EventCombination(("1, 2, 3", (1, 3, 4), InputEvent.from_string(" 1,5 , 1 ")))
EventCombination(((1, 2, 3), (1, 2, "3")))
def test_json_str(self):
def test_json_key(self):
c1 = EventCombination((1, 2, 3))
c2 = EventCombination(((1, 2, 3), (4, 5, 6)))
self.assertEqual(c1.json_str(), "1,2,3")
self.assertEqual(c2.json_str(), "1,2,3+4,5,6")
self.assertEqual(c1.json_key(), "1,2,3")
self.assertEqual(c2.json_key(), "1,2,3+4,5,6")
def test_beautify(self):
# not an integration test, but I have all the selection_label tests here already

@ -1277,7 +1277,7 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase):
# left x to mouse x if left y is above 10%
combination = EventCombination(((EV_ABS, ABS_X, 0), (EV_ABS, ABS_Y, 10)))
cfg = {
"event_combination": combination.json_str(),
"event_combination": combination.json_key(),
"target_uinput": "mouse",
"output_type": EV_REL,
"output_code": REL_X,

@ -19,13 +19,11 @@
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from tests.test import logger, quick_cleanup, new_event
import time
import unittest
import re
import asyncio
import multiprocessing
import re
import time
import unittest
from unittest import mock
from evdev.ecodes import (
@ -34,10 +32,7 @@ from evdev.ecodes import (
EV_KEY,
ABS_Y,
REL_Y,
REL_X,
REL_WHEEL,
REL_HWHEEL,
REL_WHEEL_HI_RES,
REL_HWHEEL_HI_RES,
KEY_A,
KEY_B,
@ -45,6 +40,10 @@ from evdev.ecodes import (
KEY_E,
)
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.exceptions import MacroParsingError
from inputremapper.injection.context import Context
from inputremapper.injection.macros.macro import (
Macro,
_type_check,
@ -66,12 +65,7 @@ from inputremapper.injection.macros.parse import (
get_macro_argument_names,
get_num_parameters,
)
from inputremapper.exceptions import MacroParsingError
from inputremapper.injection.context import Context
from inputremapper.configs.global_config import global_config
from inputremapper.configs.preset import Preset
from inputremapper.configs.system_mapping import system_mapping
from inputremapper.utils import PRESS, RELEASE
from tests.test import logger, quick_cleanup, new_event
class MacroTestBase(unittest.IsolatedAsyncioTestCase):

@ -26,7 +26,7 @@ from pydantic import ValidationError
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME
from inputremapper.gui.message_broker import MessageType
from inputremapper.gui.messages.message_broker import MessageType
from inputremapper.input_event import EventActions
from inputremapper.event_combination import EventCombination
@ -401,6 +401,12 @@ class TestUIMapping(unittest.IsolatedAsyncioTestCase):
m.output_symbol = "a"
self.assertEqual(m.output_symbol, "a")
def test_has_input_defined(self):
m = UIMapping()
self.assertFalse(m.has_input_defined())
m.event_combination = EventCombination((EV_KEY, 1, 1))
self.assertTrue(m.has_input_defined())
if __name__ == "__main__":
unittest.main()

@ -1,7 +1,7 @@
import unittest
from dataclasses import dataclass
from inputremapper.gui.message_broker import MessageBroker, MessageType
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
class Listener:

@ -20,12 +20,12 @@
import json
from typing import List
from inputremapper.gui.message_broker import (
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
CombinationRecorded,
Signal,
)
from inputremapper.gui.messages.message_data import CombinationRecorded
from tests.test import (
new_event,
push_events,

@ -17,7 +17,7 @@
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from inputremapper.gui.message_broker import MessageBroker
from inputremapper.gui.messages.message_broker import MessageBroker
from tests.test import (
InputDevice,
quick_cleanup,

Loading…
Cancel
Save