Frontend Refactor (#375)
* Tests for the GuiEventHandler * Implement GuiEventHandler * tests for data manager * Implemented data_manager * Remove Ellipsis from type hint * workaround for old pydantic version * workaround for old pydantic version * some more tests for data_manager * Updated Data Manager * move DeviceSelection to its own class * Data Manager no longer listens for events * Moved PresetSelection to its own class * MappingListBox and SelectionLable Listen to the EventHandler * DataManager no longer creates its own data objects in the init * removed global reader object * Changed UI startup * created backend Interface * event_handler debug logs show function which emit a event * some cleanup * added target selector to components * created code editor component * adapted autocompletion & some cleanup * black * connected some buttons to the event_handler * tests for data_manager newest_preset and group * cleanup presets and test_presets * migrated confirm delete dialog * backend tests * controller tests * add python3-gi to ci * more dependencies * and more ... * Github-Actions workaround remove this commit * not so many permission denyed errors in test.yml * Fix #404 (hopefully) * revert Github-Actions workaround * More tests * event_handler allows for event supression * more tests * WIP Implement Key recording * Start and Stop Injection * context no longer stores preset * restructured the RelToBtnHandler * Simplified read_loop * Implement async iterator for ipc.pipe * multiple event actions * helper now implements mapping handlers to read inputs all with async * updated and simplified reader the helper uses the mapping handlers, so the reader now can be much simpler * Fixed race condition in tests * implemented DataBus * Fixed a UIMapping bug where the last_error would not be deleted * added a immutable variant of the UIMapping * updated data_manager to use data_bus * Uptdated tests to use the DataBus * Gui uses DataBus * removed EventHandler * Renamed controller methods * Implemented recording toggle * implemented StatusBar * Sending validation errors to status bar * sending injection status to status bar * proper preset renaming * implemented copy preset in the data manager * implemented copy_preset in controller * fixed a bug where a wron selection lable would update * no longer send invalid data over the bus, if the preset or group changes * Implement create and delete mapping * Allow for frontend specific mapping defaults * implemented autoload toggle * cleanup user_interface * removed editor * Docstings renaming and ordering of methods * more simplifications to user_interface * integrated backend into data_manager * removed active preset * transformation tests * controller tests * fix missing uinputs in gui * moved some tests and implemented basic tests for mapping handlers * docstring reformatting Co-authored-by: Tobi <proxima@sezanzeb.de> * allow for empty groups * docstring * fixed TestGroupFromHelper * some work on integration tests * test for annoying import error in tests * testing if test_user_interface works * I feel lucky * not so lucky * some more tests * fixed but where the group_key was used as folder name * Fixed a bug where state=NO_GRAB would never be read from the injector * allow to stop the recorder * working on integration tests * integration tests * fixed more integration tests * updated coveragerc * no longer attempt to record keys when injecting * event_reader cleans up not finished tasks * More integration tests * All tests pass * renamed data_bus * WIP fixing typing issues * more typing fixes * added keyboard+mouse device to tests * cleanup imports * new read loop because the evdev async read loop can not be cancelled * Added field to modify mapping name * created tests for components * even more component tests * do component tests need a screen? * apparently they do :_( * created release_input switch * Don't record relative axis when movement is slow * show delete dialog above main window * wip basic dialog to edit combination * some gui changes to the combination-editor * Simple implementation of CombinationListbox * renamed attach_to_events method and mark as private * shorter str() for UInputsData * moved logic to generate readable event string from combination to event * new mapping parameter force release timeout this helps with the helper when recording multiple relative axis at once * make it possible to rearange the event_combination * more work on the combination editor * tests for DataManager.load_event * simplyfied test_controller * more controller tests * Implement input threshold in gui * greater range for time dependent unit test * implemented a output-axis selector * data_manager now provides injector state * black * mypy * Updated confirm cancel dialog * created release timeout input * implemented transformation graph * Added sliders for gain, expo and deadzone * fix bug where the system_mapping was overridden in each injector thread * updated slider settings * removed debug statement * explicitly checking output code against None (0 is a valid code) * usage * Allow for multiple axis to be activated by same button * readme * only warn about not implemented mapping-handler don't fail to create event-pipelines * More accurate event names * Allow removal of single events from the input-combination * rename callback to notify_callback * rename event message to selected_event * made read_continuisly private * typing for autocompletion * docstrings for message_broker messages * make components methods and propreties private * gui spacings * removed eval * make some controller functions private * move status message generation from data_manager to controller * parse mapping errors in controller for more helpful messages * remove system_mapping from code editor * More component tests * more tests * mypy * make grab_devices less greedy (partial mitigation for #435) only grab one device if there are multiple which can satisfy the same mapping * accumulate more values in test * docstrings * Updated status messages * comments, docstrings, imports Co-authored-by: Tobi <proxima@sezanzeb.de>pull/439/head
@ -1,29 +0,0 @@
|
||||
#!/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/>.
|
||||
|
||||
|
||||
"""One preset object for the GUI application."""
|
||||
|
||||
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.configs.mapping import UIMapping
|
||||
|
||||
|
||||
active_preset = Preset(mapping_factory=UIMapping)
|
@ -0,0 +1,557 @@
|
||||
#!/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 # needed for the TYPE_CHECKING import
|
||||
|
||||
import re
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Optional, Union, Literal, Sequence, Dict, Callable
|
||||
|
||||
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
|
||||
from gi.repository import Gtk
|
||||
|
||||
from inputremapper.configs.mapping import MappingData, UIMapping
|
||||
from inputremapper.event_combination import EventCombination
|
||||
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.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
|
||||
from inputremapper.injection.injector import (
|
||||
RUNNING,
|
||||
FAILED,
|
||||
NO_GRAB,
|
||||
UPGRADE_EVDEV,
|
||||
STARTING,
|
||||
STOPPED,
|
||||
InjectorState,
|
||||
)
|
||||
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
|
||||
from inputremapper.gui.user_interface import UserInterface
|
||||
|
||||
|
||||
MAPPING_DEFAULTS = {"target_uinput": "keyboard"}
|
||||
|
||||
|
||||
class Controller:
|
||||
"""implements the behaviour of the gui"""
|
||||
|
||||
def __init__(self, message_broker: MessageBroker, data_manager: DataManager):
|
||||
self.message_broker = message_broker
|
||||
self.data_manager = data_manager
|
||||
self.gui: Optional[UserInterface] = None
|
||||
|
||||
self.button_left_warn = False
|
||||
self._attach_to_events()
|
||||
|
||||
def set_gui(self, gui: UserInterface):
|
||||
self.gui = gui
|
||||
|
||||
def _attach_to_events(self) -> None:
|
||||
self.message_broker.subscribe(MessageType.groups, self._on_groups_changed)
|
||||
self.message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||
self.message_broker.subscribe(MessageType.init, self._on_init)
|
||||
self.message_broker.subscribe(
|
||||
MessageType.preset, self._send_mapping_errors_as_status_msg
|
||||
)
|
||||
self.message_broker.subscribe(
|
||||
MessageType.mapping, self._send_mapping_errors_as_status_msg
|
||||
)
|
||||
|
||||
def _on_init(self, __):
|
||||
"""initialize the gui and the data_manager"""
|
||||
# make sure we get a groups_changed event when everything is ready
|
||||
# this might not be necessary if the helper takes longer to provide the
|
||||
# initial groups
|
||||
self.data_manager.send_groups()
|
||||
self.data_manager.send_uinputs()
|
||||
if not is_helper_running():
|
||||
self.show_status(CTX_ERROR, _("The helper did not start"))
|
||||
|
||||
def _on_groups_changed(self, _):
|
||||
"""load the newest group as soon as everyone got notified
|
||||
about the updated groups"""
|
||||
group_key = self.get_a_group()
|
||||
if group_key:
|
||||
self.load_group(self.get_a_group())
|
||||
|
||||
def _on_preset_changed(self, data: PresetData):
|
||||
"""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]
|
||||
self.load_mapping(combination)
|
||||
self.load_event(combination[0])
|
||||
else:
|
||||
# send an empty mapping to make sure the ui is reset to default values
|
||||
self.message_broker.send(MappingData(**MAPPING_DEFAULTS))
|
||||
|
||||
def _on_combination_recorded(self, data: CombinationRecorded):
|
||||
self.update_combination(data.combination)
|
||||
|
||||
def _send_mapping_errors_as_status_msg(self, *__):
|
||||
"""send mapping ValidationErrors to the MessageBroker."""
|
||||
if not self.data_manager.active_preset:
|
||||
return
|
||||
if self.data_manager.active_preset.is_valid():
|
||||
self.message_broker.send(StatusData(CTX_MAPPING))
|
||||
return
|
||||
|
||||
for mapping in self.data_manager.active_preset:
|
||||
if not mapping.get_error():
|
||||
continue
|
||||
|
||||
position = mapping.name or mapping.event_combination.beautify()
|
||||
msg = _("Mapping error at %s, hover for info") % position
|
||||
self.show_status(CTX_MAPPING, msg, self._get_ui_error_string(mapping))
|
||||
|
||||
@staticmethod
|
||||
def _get_ui_error_string(mapping: UIMapping) -> str:
|
||||
"""get a human readable error message from a mapping error"""
|
||||
error_string = str(mapping.get_error())
|
||||
|
||||
# check all the different error messages which are not useful for the user
|
||||
if (
|
||||
"output_symbol is a macro:" in error_string
|
||||
or "output_symbol and output_code mismatch:" in error_string
|
||||
) and mapping.event_combination.has_input_axis():
|
||||
return _(
|
||||
"Remove the macro or key from the macro input field "
|
||||
"when specifying an analog output"
|
||||
)
|
||||
elif (
|
||||
"output_symbol is a macro:" in error_string
|
||||
or "output_symbol and output_code mismatch:" in error_string
|
||||
) and not mapping.event_combination.has_input_axis():
|
||||
return _(
|
||||
"Remove the Analog Output Axis "
|
||||
"when specifying an macro or key output"
|
||||
)
|
||||
if "missing output axis:" in error_string:
|
||||
return _(
|
||||
"The input specifies a analog axis, but no output axis is selected"
|
||||
)
|
||||
|
||||
return error_string
|
||||
|
||||
def get_a_preset(self) -> str:
|
||||
"""attempts to get the newest preset in the current group
|
||||
creates a new preset if that fails"""
|
||||
try:
|
||||
return self.data_manager.get_newest_preset_name()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.data_manager.create_preset(self.data_manager.get_available_preset_name())
|
||||
return self.data_manager.get_newest_preset_name()
|
||||
|
||||
def get_a_group(self) -> Optional[str]:
|
||||
"""attempts to get the group with the newest preset
|
||||
returns any if that fails"""
|
||||
try:
|
||||
return self.data_manager.get_newest_group_key()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
keys = self.data_manager.get_group_keys()
|
||||
return keys[0] if keys else None
|
||||
|
||||
def copy_preset(self):
|
||||
"""create a copy of the active preset and name it `preset_name copy`"""
|
||||
name = self.data_manager.active_preset.name
|
||||
match = re.search(" copy *\d*$", name)
|
||||
if match:
|
||||
name = name[: match.start()]
|
||||
|
||||
self.data_manager.copy_preset(
|
||||
self.data_manager.get_available_preset_name(f"{name} copy")
|
||||
)
|
||||
|
||||
def update_combination(self, combination: EventCombination):
|
||||
"""update the event_combination of the active mapping"""
|
||||
try:
|
||||
self.data_manager.update_mapping(event_combination=combination)
|
||||
self.save()
|
||||
except KeyError:
|
||||
# the combination was a duplicate
|
||||
return
|
||||
|
||||
if combination.is_problematic():
|
||||
self.show_status(
|
||||
CTX_WARNING,
|
||||
_("ctrl, alt and shift may not combine properly"),
|
||||
_("Your system might reinterpret combinations ")
|
||||
+ _("with those after they are injected, and by doing so ")
|
||||
+ _("break them."),
|
||||
)
|
||||
|
||||
def move_event_in_combination(
|
||||
self, event: InputEvent, direction: Union[Literal["up"], Literal["down"]]
|
||||
):
|
||||
"""move the active_event up or down in the event_combination of the
|
||||
active_mapping"""
|
||||
if (
|
||||
not self.data_manager.active_mapping
|
||||
or len(self.data_manager.active_mapping.event_combination) == 1
|
||||
):
|
||||
return
|
||||
combination: Sequence[
|
||||
InputEvent
|
||||
] = self.data_manager.active_mapping.event_combination
|
||||
|
||||
i = combination.index(event)
|
||||
if (
|
||||
i + 1 == len(combination)
|
||||
and direction == "down"
|
||||
or i == 0
|
||||
and direction == "up"
|
||||
):
|
||||
return
|
||||
|
||||
if direction == "up":
|
||||
combination = (
|
||||
list(combination[: i - 1])
|
||||
+ [event]
|
||||
+ [combination[i - 1]]
|
||||
+ list(combination[i + 1 :])
|
||||
)
|
||||
elif direction == "down":
|
||||
combination = (
|
||||
list(combination[:i])
|
||||
+ [combination[i + 1]]
|
||||
+ [event]
|
||||
+ list(combination[i + 2 :])
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unknown direction: {direction}")
|
||||
self.update_combination(EventCombination(combination))
|
||||
self.load_event(event)
|
||||
|
||||
def load_event(self, event: InputEvent):
|
||||
"""load an InputEvent form the active mapping event combination"""
|
||||
self.data_manager.load_event(event)
|
||||
|
||||
def update_event(self, new_event: InputEvent):
|
||||
"""modify the active event"""
|
||||
try:
|
||||
self.data_manager.update_event(new_event)
|
||||
except KeyError:
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.send_mapping()
|
||||
self.data_manager.send_event()
|
||||
|
||||
def remove_event(self):
|
||||
"""remove the active InputEvent from the active mapping event combination"""
|
||||
if not self.data_manager.active_mapping or not self.data_manager.active_event:
|
||||
return
|
||||
|
||||
combination = list(self.data_manager.active_mapping.event_combination)
|
||||
combination.remove(self.data_manager.active_event)
|
||||
try:
|
||||
self.data_manager.update_mapping(
|
||||
event_combination=EventCombination(combination)
|
||||
)
|
||||
self.load_event(combination[0])
|
||||
except (KeyError, ValueError):
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.send_mapping()
|
||||
self.data_manager.send_event()
|
||||
|
||||
def set_event_as_analog(self, analog: bool):
|
||||
"""use the active event as an analog input"""
|
||||
assert self.data_manager.active_event is not None
|
||||
event = self.data_manager.active_event
|
||||
if event.type == EV_KEY:
|
||||
pass
|
||||
|
||||
elif analog:
|
||||
try:
|
||||
self.data_manager.update_event(event.modify(value=0))
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]}
|
||||
for value in try_values[event.type]:
|
||||
try:
|
||||
self.data_manager.update_event(event.modify(value=value))
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# didn't update successfully
|
||||
# we need to synchronize the gui
|
||||
self.data_manager.send_mapping()
|
||||
self.data_manager.send_event()
|
||||
|
||||
def load_groups(self):
|
||||
"""refresh the groups"""
|
||||
self.data_manager.refresh_groups()
|
||||
|
||||
def load_group(self, group_key: str):
|
||||
"""load the group and then a preset of that group"""
|
||||
self.data_manager.load_group(group_key)
|
||||
self.load_preset(self.get_a_preset())
|
||||
|
||||
def load_preset(self, name: str):
|
||||
"""load the preset"""
|
||||
self.data_manager.load_preset(name)
|
||||
# self.load_mapping(...) # not needed because we have on_preset_changed()
|
||||
|
||||
def rename_preset(self, new_name: str):
|
||||
"""rename the active_preset"""
|
||||
if (
|
||||
not self.data_manager.active_preset
|
||||
or not new_name
|
||||
or new_name == self.data_manager.active_preset.name
|
||||
):
|
||||
return
|
||||
name = self.data_manager.get_available_preset_name(new_name)
|
||||
self.data_manager.rename_preset(name)
|
||||
|
||||
def add_preset(self, name: str = DEFAULT_PRESET_NAME):
|
||||
"""create a new preset, add it to the active_group and name it `new preset n`"""
|
||||
name = self.data_manager.get_available_preset_name(name)
|
||||
try:
|
||||
self.data_manager.create_preset(name)
|
||||
self.data_manager.load_preset(name)
|
||||
except PermissionError as e:
|
||||
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
|
||||
|
||||
def delete_preset(self):
|
||||
"""delete the active_preset from the disc"""
|
||||
|
||||
def f(answer: bool):
|
||||
if answer:
|
||||
self.data_manager.delete_preset()
|
||||
self.data_manager.load_preset(self.get_a_preset())
|
||||
|
||||
if not self.data_manager.active_preset:
|
||||
return
|
||||
msg = (
|
||||
_("Are you sure you want to delete the \npreset: '%s' ?")
|
||||
% self.data_manager.active_preset.name
|
||||
)
|
||||
self.message_broker.send(UserConfirmRequest(msg, f))
|
||||
|
||||
def load_mapping(self, event_combination: EventCombination):
|
||||
"""load the mapping with the given event_combination form the active_preset"""
|
||||
self.data_manager.load_mapping(event_combination)
|
||||
self.load_event(event_combination[0])
|
||||
|
||||
def update_mapping(self, **kwargs):
|
||||
"""update the active_mapping with the given keywords and values"""
|
||||
self.data_manager.update_mapping(**kwargs)
|
||||
self.save()
|
||||
|
||||
def create_mapping(self):
|
||||
"""create a new empty mapping in the active_preset"""
|
||||
try:
|
||||
self.data_manager.create_mapping()
|
||||
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)
|
||||
|
||||
def delete_mapping(self):
|
||||
"""remove the active_mapping form the active_preset"""
|
||||
|
||||
def f(answer: bool):
|
||||
if answer:
|
||||
self.data_manager.delete_mapping()
|
||||
self.save()
|
||||
|
||||
if not self.data_manager.active_mapping:
|
||||
return
|
||||
self.message_broker.send(
|
||||
UserConfirmRequest(_("Are you sure you want to delete \nthis mapping?"), f)
|
||||
)
|
||||
|
||||
def set_autoload(self, autoload: bool):
|
||||
"""set the autoload state for the active_preset and active_group"""
|
||||
self.data_manager.set_autoload(autoload)
|
||||
self.data_manager.refresh_service_config_path()
|
||||
|
||||
def save(self):
|
||||
"""save all data to the disc"""
|
||||
try:
|
||||
self.data_manager.save()
|
||||
except PermissionError as e:
|
||||
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"""
|
||||
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')
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug("Recording Keys")
|
||||
|
||||
def f(_):
|
||||
self.message_broker.unsubscribe(f)
|
||||
self.message_broker.unsubscribe(self._on_combination_recorded)
|
||||
self.gui.connect_shortcuts()
|
||||
|
||||
self.gui.disconnect_shortcuts()
|
||||
self.message_broker.subscribe(
|
||||
MessageType.combination_recorded, self._on_combination_recorded
|
||||
)
|
||||
self.message_broker.subscribe(MessageType.recording_finished, f)
|
||||
self.data_manager.start_combination_recording()
|
||||
|
||||
def stop_key_recording(self):
|
||||
"""stop recording the input"""
|
||||
logger.debug("Stopping Key recording")
|
||||
self.data_manager.stop_combination_recording()
|
||||
|
||||
def start_injecting(self):
|
||||
"""inject the active_preset for the active_group"""
|
||||
if len(self.data_manager.active_preset) == 0:
|
||||
logger.error(_("Cannot apply empty preset file"))
|
||||
# also helpful for first time use
|
||||
self.show_status(CTX_ERROR, _("You need to add keys and save first"))
|
||||
return
|
||||
|
||||
if not self.button_left_warn:
|
||||
if self.data_manager.active_preset.dangerously_mapped_btn_left():
|
||||
self.show_status(
|
||||
CTX_ERROR,
|
||||
"This would disable your click button",
|
||||
"Map a button to BTN_LEFT to avoid this.\n"
|
||||
"To overwrite this warning, press apply again.",
|
||||
)
|
||||
self.button_left_warn = True
|
||||
return
|
||||
|
||||
# todo: warn about unreleased keys
|
||||
self.button_left_warn = False
|
||||
self.message_broker.subscribe(
|
||||
MessageType.injector_state, self.show_injector_result
|
||||
)
|
||||
self.show_status(CTX_APPLY, _("Starting injection..."))
|
||||
if not self.data_manager.start_injecting():
|
||||
self.message_broker.unsubscribe(self.show_injector_result)
|
||||
self.show_status(
|
||||
CTX_APPLY,
|
||||
_("Failed to apply preset %s") % self.data_manager.active_preset.name,
|
||||
)
|
||||
|
||||
def show_injector_result(self, msg: InjectorState):
|
||||
"""Show if the injection was successfully started."""
|
||||
self.message_broker.unsubscribe(self.show_injector_result)
|
||||
state = msg.state
|
||||
|
||||
def running():
|
||||
msg = _("Applied preset %s") % self.data_manager.active_preset.name
|
||||
if self.data_manager.active_preset.get_mapping(
|
||||
EventCombination(InputEvent.btn_left())
|
||||
):
|
||||
msg += _(", CTRL + DEL to stop")
|
||||
self.show_status(CTX_APPLY, msg)
|
||||
logger.info(
|
||||
'Group "%s" is currently mapped', self.data_manager.active_group.key
|
||||
)
|
||||
|
||||
assert self.data_manager.active_preset # make mypy happy
|
||||
state_calls: Dict[int, Callable] = {
|
||||
RUNNING: running,
|
||||
FAILED: partial(
|
||||
self.show_status,
|
||||
CTX_ERROR,
|
||||
_("Failed to apply preset %s") % self.data_manager.active_preset.name,
|
||||
),
|
||||
NO_GRAB: partial(
|
||||
self.show_status,
|
||||
CTX_ERROR,
|
||||
"The device was not grabbed",
|
||||
"Either another application is already grabbing it or "
|
||||
"your preset doesn't contain anything that is sent by the "
|
||||
"device.",
|
||||
),
|
||||
UPGRADE_EVDEV: partial(
|
||||
self.show_status,
|
||||
CTX_ERROR,
|
||||
"Upgrade python-evdev",
|
||||
"Your python-evdev version is too old.",
|
||||
),
|
||||
}
|
||||
state_calls[state]()
|
||||
|
||||
def stop_injecting(self):
|
||||
"""stop injecting any preset for the active_group"""
|
||||
|
||||
def show_result(msg: InjectorState):
|
||||
self.message_broker.unsubscribe(show_result)
|
||||
assert msg.state == STOPPED
|
||||
self.show_status(CTX_APPLY, _("Applied the system default"))
|
||||
|
||||
try:
|
||||
self.message_broker.subscribe(MessageType.injector_state, show_result)
|
||||
self.data_manager.stop_injecting()
|
||||
except DataManagementError:
|
||||
self.message_broker.unsubscribe(show_result)
|
||||
|
||||
def show_status(
|
||||
self, ctx_id: int, msg: Optional[str] = None, tooltip: Optional[str] = None
|
||||
):
|
||||
"""send a status message to the ui to show it in the status-bar"""
|
||||
self.message_broker.send(StatusData(ctx_id, msg, tooltip))
|
||||
|
||||
def is_empty_mapping(self) -> bool:
|
||||
"""check if the active_mapping is empty"""
|
||||
return (
|
||||
self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS)
|
||||
or self.data_manager.active_mapping is None
|
||||
)
|
||||
|
||||
def refresh_groups(self):
|
||||
"""reload the connected devices and send them as a groups message
|
||||
|
||||
runs asynchronously"""
|
||||
self.data_manager.refresh_groups()
|
||||
|
||||
def close(self):
|
||||
"""safely close the application"""
|
||||
logger.debug("Closing Application")
|
||||
self.save()
|
||||
self.message_broker.signal(MessageType.terminate)
|
||||
logger.debug("Quitting")
|
||||
Gtk.main_quit()
|
||||
|
||||
def set_focus(self, component):
|
||||
"""focus the given component"""
|
||||
self.gui.window.set_focus(component)
|
@ -0,0 +1,564 @@
|
||||
#!/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 glob
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
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.paths import get_preset_path, mkdir, split_all
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.configs.system_mapping import SystemMapping
|
||||
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 (
|
||||
MessageBroker,
|
||||
GroupData,
|
||||
PresetData,
|
||||
CombinationUpdate,
|
||||
UInputsData,
|
||||
)
|
||||
from inputremapper.gui.reader import Reader
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.injection.injector import (
|
||||
STOPPED,
|
||||
RUNNING,
|
||||
FAILED,
|
||||
UPGRADE_EVDEV,
|
||||
NO_GRAB,
|
||||
InjectorState,
|
||||
)
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logger import logger
|
||||
|
||||
DEFAULT_PRESET_NAME = "new preset"
|
||||
|
||||
# useful type aliases
|
||||
Name = str
|
||||
GroupKey = str
|
||||
|
||||
|
||||
class DataManager:
|
||||
"""DataManager provides an interface to create and modify configurations as well
|
||||
as modify the state of the Service.
|
||||
|
||||
Any state changes will be announced via the MessageBroker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_broker: MessageBroker,
|
||||
config: GlobalConfig,
|
||||
reader: Reader,
|
||||
daemon: DaemonProxy,
|
||||
uinputs: GlobalUInputs,
|
||||
system_mapping: SystemMapping,
|
||||
):
|
||||
self.message_broker = message_broker
|
||||
self._reader = reader
|
||||
self._daemon = daemon
|
||||
self._uinputs = uinputs
|
||||
self._system_mapping = system_mapping
|
||||
uinputs.prepare_all()
|
||||
|
||||
self._config = config
|
||||
self._config.load_config()
|
||||
|
||||
self._active_preset: Optional[Preset[UIMapping]] = None
|
||||
self._active_mapping: Optional[UIMapping] = None
|
||||
self._active_event: Optional[InputEvent] = None
|
||||
|
||||
def send_group(self):
|
||||
"""send active group to the MessageBroker.
|
||||
|
||||
This is internally called whenever the group changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager"""
|
||||
self.message_broker.send(
|
||||
GroupData(self.active_group.key, self.get_preset_names())
|
||||
)
|
||||
|
||||
def send_preset(self):
|
||||
"""send active preset to the MessageBroker.
|
||||
|
||||
This is internally called whenever the preset changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager"""
|
||||
self.message_broker.send(
|
||||
PresetData(
|
||||
self.active_preset.name, self.get_mappings(), self.get_autoload()
|
||||
)
|
||||
)
|
||||
|
||||
def send_mapping(self):
|
||||
"""send active mapping to the MessageBroker
|
||||
|
||||
This is internally called whenever the mapping changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager"""
|
||||
if self.active_mapping:
|
||||
self.message_broker.send(self.active_mapping.get_bus_message())
|
||||
|
||||
def send_event(self):
|
||||
"""send active event to the MessageBroker.
|
||||
|
||||
This is internally called whenever the event changes.
|
||||
It is usually not necessary to call this explicitly from
|
||||
outside DataManager"""
|
||||
if self.active_event:
|
||||
assert self.active_event in self.active_mapping.event_combination
|
||||
self.message_broker.send(self.active_event)
|
||||
|
||||
def send_uinputs(self):
|
||||
"""send the "uinputs" message on the MessageBroker"""
|
||||
self.message_broker.send(
|
||||
UInputsData(
|
||||
{
|
||||
name: uinput.capabilities()
|
||||
for name, uinput in self._uinputs.devices.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def send_groups(self):
|
||||
"""send the "groups" message on the MessageBroker"""
|
||||
self._reader.send_groups()
|
||||
|
||||
def send_injector_state(self):
|
||||
"""send the "injector_state" message with the state of the injector
|
||||
for the active_group"""
|
||||
if not self.active_group:
|
||||
return
|
||||
self.message_broker.send(InjectorState(self.get_state()))
|
||||
|
||||
@property
|
||||
def active_group(self) -> Optional[_Group]:
|
||||
"""the currently loaded group"""
|
||||
return self._reader.group
|
||||
|
||||
@property
|
||||
def active_preset(self) -> Optional[Preset[UIMapping]]:
|
||||
"""the currently loaded preset"""
|
||||
return self._active_preset
|
||||
|
||||
@property
|
||||
def active_mapping(self) -> Optional[UIMapping]:
|
||||
"""the currently loaded mapping"""
|
||||
return self._active_mapping
|
||||
|
||||
@property
|
||||
def active_event(self) -> Optional[InputEvent]:
|
||||
"""the currently loaded event"""
|
||||
return self._active_event
|
||||
|
||||
def get_group_keys(self) -> Tuple[GroupKey, ...]:
|
||||
"""Get all group keys (plugged devices)"""
|
||||
return tuple(group.key for group in self._reader.groups.filter())
|
||||
|
||||
def get_preset_names(self) -> Tuple[Name, ...]:
|
||||
"""Get all preset names for active_group and current user,
|
||||
starting with the newest."""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("cannot find presets: Group is not set")
|
||||
device_folder = get_preset_path(self.active_group.name)
|
||||
mkdir(device_folder)
|
||||
|
||||
paths = glob.glob(os.path.join(device_folder, "*.json"))
|
||||
presets = [
|
||||
os.path.splitext(os.path.basename(path))[0]
|
||||
for path in sorted(paths, key=os.path.getmtime)
|
||||
]
|
||||
# the highest timestamp to the front
|
||||
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"""
|
||||
if not self._active_preset:
|
||||
return None
|
||||
return [
|
||||
(mapping.name, mapping.event_combination) for mapping in self._active_preset
|
||||
]
|
||||
|
||||
def get_autoload(self) -> bool:
|
||||
"""the autoload status of the active_preset"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
return False
|
||||
return self._config.is_autoloaded(
|
||||
self.active_group.key, self.active_preset.name
|
||||
)
|
||||
|
||||
def set_autoload(self, status: bool):
|
||||
"""set the autoload status of the active_preset.
|
||||
Will send "preset" message on the MessageBroker
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("cannot set autoload status: Preset is not set")
|
||||
|
||||
if status:
|
||||
self._config.set_autoload_preset(
|
||||
self.active_group.key, self.active_preset.name
|
||||
)
|
||||
elif self.get_autoload:
|
||||
self._config.set_autoload_preset(self.active_group.key, None)
|
||||
|
||||
self.send_preset()
|
||||
|
||||
def get_newest_group_key(self) -> GroupKey:
|
||||
"""group_key of the group with the most recently modified preset"""
|
||||
paths = []
|
||||
for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")):
|
||||
if self._reader.groups.find(key=split_all(path)[-2]):
|
||||
paths.append((path, os.path.getmtime(path)))
|
||||
|
||||
if not paths:
|
||||
raise FileNotFoundError()
|
||||
|
||||
path, _ = max(paths, key=lambda x: x[1])
|
||||
return split_all(path)[-2]
|
||||
|
||||
def get_newest_preset_name(self) -> Name:
|
||||
"""preset name of the most recently modified preset in the active group"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("cannot find newest preset: Group is not set")
|
||||
|
||||
paths = [
|
||||
(path, os.path.getmtime(path))
|
||||
for path in glob.glob(
|
||||
os.path.join(get_preset_path(self.active_group.name), "*.json")
|
||||
)
|
||||
]
|
||||
if not paths:
|
||||
raise FileNotFoundError()
|
||||
|
||||
path, _ = max(paths, key=lambda x: x[1])
|
||||
return os.path.split(path)[-1].split(".")[0]
|
||||
|
||||
def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name:
|
||||
"""the first available preset in the active group"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("unable find preset name. Group is not set")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# find a name that is not already taken
|
||||
if os.path.exists(get_preset_path(self.active_group.name, name)):
|
||||
# if there already is a trailing number, increment it instead of
|
||||
# adding another one
|
||||
match = re.match(r"^(.+) (\d+)$", name)
|
||||
if match:
|
||||
name = match[1]
|
||||
i = int(match[2]) + 1
|
||||
else:
|
||||
i = 2
|
||||
|
||||
while os.path.exists(
|
||||
get_preset_path(self.active_group.name, f"{name} {i}")
|
||||
):
|
||||
i += 1
|
||||
|
||||
return f"{name} {i}"
|
||||
|
||||
return name
|
||||
|
||||
def load_group(self, group_key: str):
|
||||
"""Load a group. will send "groups" and "injector_state"
|
||||
messages on the MessageBroker.
|
||||
|
||||
this will render the active_mapping and active_preset invalid
|
||||
"""
|
||||
if group_key not in self.get_group_keys():
|
||||
raise DataManagementError("Unable to load non existing group")
|
||||
|
||||
self._active_event = None
|
||||
self._active_mapping = None
|
||||
self._active_preset = None
|
||||
group = self._reader.groups.find(key=group_key)
|
||||
self._reader.set_group(group)
|
||||
self.send_group()
|
||||
self.send_injector_state()
|
||||
|
||||
def load_preset(self, name: str):
|
||||
"""Load a preset. Will send "preset" message on the MessageBroker
|
||||
|
||||
this will render the active_mapping invalid
|
||||
"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Unable to load preset. Group is not set")
|
||||
|
||||
preset_path = get_preset_path(self.active_group.name, name)
|
||||
preset = Preset(preset_path, mapping_factory=UIMapping)
|
||||
preset.load()
|
||||
self._active_event = None
|
||||
self._active_mapping = None
|
||||
self._active_preset = preset
|
||||
self.send_preset()
|
||||
|
||||
def load_mapping(self, combination: EventCombination):
|
||||
"""Load a mapping. Will send "mapping" message on the MessageBroker"""
|
||||
if not self._active_preset:
|
||||
raise DataManagementError("Unable to load mapping. Preset is not set")
|
||||
|
||||
mapping = self._active_preset.get_mapping(combination)
|
||||
if not mapping:
|
||||
raise KeyError(
|
||||
f"the mapping with {combination = } does not "
|
||||
f"exist in the {self._active_preset.path}"
|
||||
)
|
||||
self._active_event = None
|
||||
self._active_mapping = mapping
|
||||
self.send_mapping()
|
||||
|
||||
def load_event(self, event: InputEvent):
|
||||
"""Load a InputEvent from the combination in the active mapping.
|
||||
|
||||
Will send "event" message on the MessageBroker"""
|
||||
if not self.active_mapping:
|
||||
raise DataManagementError("Unable to load event. mapping is not set")
|
||||
if event not in self.active_mapping.event_combination:
|
||||
raise ValueError(
|
||||
f"{event} is not member of active_mapping.event_combination: "
|
||||
f"{self.active_mapping.event_combination}"
|
||||
)
|
||||
self._active_event = event
|
||||
self.send_event()
|
||||
|
||||
def rename_preset(self, new_name: str):
|
||||
"""rename the current preset and move the correct file
|
||||
|
||||
Will send "group" and then "preset" message on the MessageBroker
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Unable rename preset: Preset is not set")
|
||||
|
||||
if self.active_preset.path == get_preset_path(self.active_group.name, new_name):
|
||||
return
|
||||
|
||||
old_path = self.active_preset.path
|
||||
assert old_path is not None
|
||||
old_name = os.path.basename(old_path).split(".")[0]
|
||||
new_path = get_preset_path(self.active_group.name, new_name)
|
||||
if os.path.exists(new_path):
|
||||
raise ValueError(
|
||||
f"cannot rename {old_name} to " f"{new_name}, preset already exists"
|
||||
)
|
||||
|
||||
logger.info('Moving "%s" to "%s"', old_path, new_path)
|
||||
os.rename(old_path, new_path)
|
||||
now = time.time()
|
||||
os.utime(new_path, (now, now))
|
||||
|
||||
if self._config.is_autoloaded(self.active_group.key, old_name):
|
||||
self._config.set_autoload_preset(self.active_group.key, new_name)
|
||||
|
||||
self.active_preset.path = get_preset_path(self.active_group.name, new_name)
|
||||
self.send_group()
|
||||
self.send_preset()
|
||||
|
||||
def copy_preset(self, name: str):
|
||||
"""copy the current preset to the given name.
|
||||
Will send "group" and "preset" message to the MessageBroker and load the copy
|
||||
"""
|
||||
# todo: Do we want to load the copy here? or is this up to the controller?
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("Unable to copy preset: Preset is not set")
|
||||
|
||||
if self.active_preset.path == get_preset_path(self.active_group.name, name):
|
||||
return
|
||||
|
||||
if name in self.get_preset_names():
|
||||
raise ValueError(f"a preset with the name {name} already exits")
|
||||
|
||||
new_path = get_preset_path(self.active_group.name, name)
|
||||
logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path)
|
||||
self.active_preset.path = new_path
|
||||
self.save()
|
||||
self.send_group()
|
||||
self.send_preset()
|
||||
|
||||
def create_preset(self, name: str):
|
||||
"""create empty preset in the active_group.
|
||||
Will send "group" message to the MessageBroker
|
||||
"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("Unable to add preset. Group is not set")
|
||||
|
||||
path = get_preset_path(self.active_group.name, name)
|
||||
if os.path.exists(path):
|
||||
raise DataManagementError("Unable to add preset. Preset exists")
|
||||
|
||||
Preset(path).save()
|
||||
self.send_group()
|
||||
|
||||
def delete_preset(self):
|
||||
"""delete the active preset
|
||||
Will send "group" message to the MessageBroker
|
||||
this will invalidate the active mapping,
|
||||
"""
|
||||
preset_path = self._active_preset.path
|
||||
logger.info('Removing "%s"', preset_path)
|
||||
os.remove(preset_path)
|
||||
self._active_mapping = None
|
||||
self._active_preset = None
|
||||
self.send_group()
|
||||
|
||||
def update_mapping(self, **kwargs):
|
||||
"""update the active mapping with the given keywords and values.
|
||||
|
||||
Will send "mapping" message to the MessageBroker. In case of a new event_combination
|
||||
this will first send a "combination_update" message
|
||||
"""
|
||||
if not self._active_mapping:
|
||||
raise DataManagementError("Cannot modify Mapping: mapping is not set")
|
||||
|
||||
if symbol := kwargs.get("output_symbol"):
|
||||
kwargs["output_symbol"] = self._system_mapping.correct_case(symbol)
|
||||
|
||||
combination = self.active_mapping.event_combination
|
||||
for key, value in kwargs.items():
|
||||
setattr(self._active_mapping, key, value)
|
||||
|
||||
if (
|
||||
"event_combination" in kwargs
|
||||
and combination != self.active_mapping.event_combination
|
||||
):
|
||||
self._active_event = None
|
||||
self.message_broker.send(
|
||||
CombinationUpdate(combination, self._active_mapping.event_combination)
|
||||
)
|
||||
self.send_mapping()
|
||||
|
||||
def update_event(self, new_event: InputEvent):
|
||||
"""update the active event.
|
||||
|
||||
Will send "combination_update", "mapping" and "event" messages to the
|
||||
MessageBroker (in that order)
|
||||
"""
|
||||
if not self.active_mapping or not self.active_event:
|
||||
raise DataManagementError("Cannot modify event: event is not set")
|
||||
|
||||
combination = list(self.active_mapping.event_combination)
|
||||
combination[combination.index(self.active_event)] = new_event
|
||||
self.update_mapping(event_combination=EventCombination(combination))
|
||||
self._active_event = new_event
|
||||
self.send_event()
|
||||
|
||||
def create_mapping(self):
|
||||
"""create empty mapping in the active preset.
|
||||
Will send "preset" message to the MessageBroker
|
||||
"""
|
||||
if not self._active_preset:
|
||||
raise DataManagementError("cannot create mapping: preset is not set")
|
||||
self._active_preset.add(UIMapping())
|
||||
self.send_preset()
|
||||
|
||||
def delete_mapping(self):
|
||||
"""delete the active mapping
|
||||
Will send "preset" message to the MessageBroker
|
||||
"""
|
||||
if not self._active_mapping:
|
||||
raise DataManagementError(
|
||||
"cannot delete active mapping: active mapping is not set"
|
||||
)
|
||||
|
||||
self._active_preset.remove(self._active_mapping.event_combination)
|
||||
self._active_mapping = None
|
||||
self.send_preset()
|
||||
|
||||
def save(self):
|
||||
"""save the active preset"""
|
||||
if self._active_preset:
|
||||
self._active_preset.save()
|
||||
|
||||
def refresh_groups(self):
|
||||
"""refresh the groups (plugged devices)
|
||||
Should send "groups" message to MessageBroker this will not happen immediately
|
||||
because the system might take a bit until the groups are available
|
||||
"""
|
||||
self._reader.refresh_groups()
|
||||
|
||||
def start_combination_recording(self):
|
||||
"""Record user input.
|
||||
|
||||
Will send "combination_recorded" messages as new input arrives.
|
||||
Will eventually send a "recording_finished" message.
|
||||
"""
|
||||
self._reader.start_recorder()
|
||||
|
||||
def stop_combination_recording(self):
|
||||
"""Stop recording user input.
|
||||
|
||||
Will send RecordingFinished message if a recording is running.
|
||||
"""
|
||||
self._reader.stop_recorder()
|
||||
|
||||
def stop_injecting(self) -> None:
|
||||
"""stop injecting for the active group
|
||||
|
||||
Will send "injector_state" message once the injector has stopped"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("cannot stop injection: group is not set")
|
||||
self._daemon.stop_injecting(self.active_group.key)
|
||||
self.do_when_injector_state({STOPPED}, self.send_injector_state)
|
||||
|
||||
def start_injecting(self) -> bool:
|
||||
"""start injecting the active preset for the active group.
|
||||
|
||||
returns if the startup was successfully initialized.
|
||||
Will send "injector_state" message once the startup is complete.
|
||||
"""
|
||||
if not self.active_preset or not self.active_group:
|
||||
raise DataManagementError("cannot start injection: preset is not set")
|
||||
|
||||
self._daemon.set_config_dir(self._config.get_dir())
|
||||
assert self.active_preset.name is not None
|
||||
if self._daemon.start_injecting(self.active_group.key, self.active_preset.name):
|
||||
self.do_when_injector_state(
|
||||
{RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV}, self.send_injector_state
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_state(self) -> int:
|
||||
"""the state of the injector"""
|
||||
if not self.active_group:
|
||||
raise DataManagementError("cannot read state: group is not set")
|
||||
return self._daemon.get_state(self.active_group.key)
|
||||
|
||||
def refresh_service_config_path(self):
|
||||
"""tell the service to refresh its config path"""
|
||||
self._daemon.set_config_dir(self._config.get_dir())
|
||||
|
||||
def do_when_injector_state(self, states: Set[int], callback):
|
||||
"""run callback once the injector state is one of states"""
|
||||
|
||||
def do():
|
||||
if self.get_state() in states:
|
||||
callback()
|
||||
return False
|
||||
return True
|
||||
|
||||
GLib.timeout_add(100, do)
|
@ -1,748 +0,0 @@
|
||||
#!/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/>.
|
||||
|
||||
|
||||
"""The editor with multiline code input, recording toggle and autocompletion."""
|
||||
|
||||
|
||||
import re
|
||||
import locale
|
||||
import gettext
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from inputremapper.configs.data import get_data_path
|
||||
from inputremapper.configs.mapping import UIMapping
|
||||
from inputremapper.gui.gettext import _
|
||||
|
||||
from gi.repository import Gtk, GLib, Gdk, GtkSource
|
||||
from inputremapper.gui.gettext import _
|
||||
from inputremapper.gui.editor.autocompletion import Autocompletion
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.gui.active_preset import active_preset
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.input_event import InputEvent
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.gui.reader import reader
|
||||
from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING, CTX_ERROR
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
class SelectionLabel(Gtk.ListBoxRow):
|
||||
"""One label per mapping in the preset.
|
||||
|
||||
This wrapper serves as a storage for the information the inherited label represents.
|
||||
"""
|
||||
|
||||
__gtype_name__ = "SelectionLabel"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.combination = None
|
||||
self.symbol = ""
|
||||
|
||||
label = Gtk.Label()
|
||||
|
||||
# Make the child label widget break lines, important for
|
||||
# long combinations
|
||||
label.set_line_wrap(True)
|
||||
label.set_line_wrap_mode(Gtk.WrapMode.WORD)
|
||||
label.set_justify(Gtk.Justification.CENTER)
|
||||
|
||||
self.label = label
|
||||
self.add(label)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def set_combination(self, combination: EventCombination):
|
||||
"""Set the combination this button represents
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combination : EventCombination
|
||||
"""
|
||||
self.combination = combination
|
||||
if combination:
|
||||
self.label.set_label(combination.beautify())
|
||||
else:
|
||||
self.label.set_label(_("new entry"))
|
||||
|
||||
def get_combination(self) -> EventCombination:
|
||||
return self.combination
|
||||
|
||||
def set_label(self, label):
|
||||
return self.label.set_label(label)
|
||||
|
||||
def get_label(self):
|
||||
return self.label.get_label()
|
||||
|
||||
def __str__(self):
|
||||
return f"SelectionLabel({str(self.combination)})"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
|
||||
class CombinationEntry(Gtk.ListBoxRow):
|
||||
"""One row per InputEvent in the EventCombination."""
|
||||
|
||||
__gtype_name__ = "CombinationEntry"
|
||||
|
||||
def __init__(self, event: InputEvent):
|
||||
super().__init__()
|
||||
|
||||
self.event = event
|
||||
hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_label(event.json_str())
|
||||
hbox.pack_start(label, False, False, 0)
|
||||
|
||||
up_btn = Gtk.Button()
|
||||
up_btn.set_halign(Gtk.Align.END)
|
||||
up_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
up_btn.get_style_context().add_class("no-v-padding")
|
||||
up_img = Gtk.Image.new_from_icon_name("go-up", Gtk.IconSize.BUTTON)
|
||||
up_btn.add(up_img)
|
||||
|
||||
down_btn = Gtk.Button()
|
||||
down_btn.set_halign(Gtk.Align.END)
|
||||
down_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
down_btn.get_style_context().add_class("no-v-padding")
|
||||
down_img = Gtk.Image.new_from_icon_name("go-down", Gtk.IconSize.BUTTON)
|
||||
down_btn.add(down_img)
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
vbox.pack_start(up_btn, False, True, 0)
|
||||
vbox.pack_end(down_btn, False, True, 0)
|
||||
hbox.pack_end(vbox, False, False, 0)
|
||||
|
||||
self.add(hbox)
|
||||
self.show_all()
|
||||
|
||||
|
||||
def ensure_everything_saved(func):
|
||||
"""Make sure the editor has written its changes to active_preset and save."""
|
||||
|
||||
def wrapped(self, *args, **kwargs):
|
||||
if self.user_interface.preset_name:
|
||||
self.gather_changes_and_save()
|
||||
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
SET_KEY_FIRST = _("Set the key first")
|
||||
|
||||
RECORD_ALL = float("inf")
|
||||
RECORD_NONE = 0
|
||||
|
||||
|
||||
class Editor:
|
||||
"""Maintains the widgets of the editor."""
|
||||
|
||||
def __init__(self, user_interface):
|
||||
self.user_interface = user_interface
|
||||
|
||||
self.autocompletion = None
|
||||
|
||||
self.active_mapping: Optional[UIMapping] = None
|
||||
|
||||
self._setup_target_selector()
|
||||
self._setup_source_view()
|
||||
self._setup_recording_toggle()
|
||||
|
||||
self.window = self.get("window")
|
||||
self.timeouts = [
|
||||
GLib.timeout_add(100, self.check_add_new_key),
|
||||
GLib.timeout_add(1000, self.update_toggle_opacity),
|
||||
]
|
||||
self.active_selection_label: Optional[SelectionLabel] = None
|
||||
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
selection_label_listbox.connect("row-selected", self.on_mapping_selected)
|
||||
|
||||
self.device = user_interface.group
|
||||
|
||||
# keys were not pressed yet
|
||||
self._input_has_arrived = False
|
||||
|
||||
self.record_events_until = RECORD_NONE
|
||||
|
||||
code_editor = self.get_code_editor()
|
||||
code_editor.connect("focus-out-event", self.on_text_input_unfocus)
|
||||
code_editor.get_buffer().connect("changed", self.on_text_input_changed)
|
||||
|
||||
delete_button = self.get_delete_button()
|
||||
delete_button.connect("clicked", self._on_delete_button_clicked)
|
||||
|
||||
target_selector = self.get_target_selector()
|
||||
target_selector.connect("changed", self._on_target_input_changed)
|
||||
|
||||
def __del__(self):
|
||||
for timeout in self.timeouts:
|
||||
GLib.source_remove(timeout)
|
||||
self.timeouts = []
|
||||
|
||||
def _on_toggle_clicked(self, toggle, event=None):
|
||||
if toggle.get_active():
|
||||
self._show_press_key()
|
||||
else:
|
||||
self._show_change_key()
|
||||
|
||||
@ensure_everything_saved
|
||||
def _on_toggle_unfocus(self, toggle, event=None):
|
||||
toggle.set_active(False)
|
||||
|
||||
@ensure_everything_saved
|
||||
def on_text_input_unfocus(self, *_):
|
||||
"""When unfocusing the text it saves.
|
||||
|
||||
Input Remapper doesn't save the editor on change, because that would cause
|
||||
an incredible amount of logs for every single input. The active_preset would
|
||||
need to be changed, which causes two logs, then it has to be saved
|
||||
to disk which is another two log messages. So every time a single character
|
||||
is typed it writes 4 lines.
|
||||
|
||||
Instead, it will save the preset when it is really needed, i.e. when a button
|
||||
that requires a saved preset is pressed. For this there exists the
|
||||
@ensure_everything_saved decorator.
|
||||
|
||||
To avoid maybe forgetting to add this decorator somewhere, it will also save
|
||||
when unfocusing the text input.
|
||||
|
||||
If the scroll wheel is used to interact with gtk widgets it won't unfocus,
|
||||
so this focus-out handler is not the solution to everything as well.
|
||||
|
||||
One could debounce saving on text-change to avoid those logs, but that just
|
||||
sounds like a huge source of race conditions and is also hard to test.
|
||||
"""
|
||||
pass # the decorator will be triggered
|
||||
|
||||
def on_text_input_changed(self, *_):
|
||||
# correct case
|
||||
symbol = self.get_symbol_input_text()
|
||||
correct_case = system_mapping.correct_case(symbol)
|
||||
if symbol != correct_case:
|
||||
self.get_code_editor().get_buffer().set_text(correct_case)
|
||||
|
||||
if self.active_mapping:
|
||||
# might be None if the empty mapping was selected, and the text input cleared
|
||||
self.active_mapping.output_symbol = correct_case
|
||||
|
||||
def _on_target_input_changed(self, *_):
|
||||
"""Save when target changed."""
|
||||
self.active_mapping.target_uinput = self.get_target_selection()
|
||||
self.gather_changes_and_save()
|
||||
|
||||
def clear(self):
|
||||
"""Clear all inputs, labels, etc. Reset the state.
|
||||
|
||||
This is really important to do before loading a different preset.
|
||||
Otherwise the inputs will be read and then saved into the next preset.
|
||||
"""
|
||||
if self.active_selection_label:
|
||||
self.set_combination(None)
|
||||
|
||||
self.disable_symbol_input(clear=True)
|
||||
self.set_target_selection("keyboard") # sane default
|
||||
self.disable_target_selector()
|
||||
self._reset_keycode_consumption()
|
||||
|
||||
self.clear_mapping_list()
|
||||
|
||||
def clear_mapping_list(self):
|
||||
"""Clear the labels from the mapping selection and add an empty one."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
selection_label_listbox.forall(selection_label_listbox.remove)
|
||||
self.add_empty()
|
||||
selection_label_listbox.select_row(selection_label_listbox.get_children()[0])
|
||||
|
||||
def _setup_target_selector(self):
|
||||
"""Prepare the target selector combobox."""
|
||||
target_store = Gtk.ListStore(str)
|
||||
for uinput in global_uinputs.devices:
|
||||
target_store.append([uinput])
|
||||
|
||||
target_input = self.get_target_selector()
|
||||
target_input.set_model(target_store)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
target_input.pack_start(renderer_text, False)
|
||||
target_input.add_attribute(renderer_text, "text", 0)
|
||||
target_input.set_id_column(0)
|
||||
|
||||
def _setup_recording_toggle(self):
|
||||
"""Prepare the toggle button for recording key inputs."""
|
||||
toggle = self.get_recording_toggle()
|
||||
toggle.connect("focus-out-event", self._show_change_key)
|
||||
toggle.connect("focus-in-event", self._show_press_key)
|
||||
toggle.connect("clicked", self._on_toggle_clicked)
|
||||
toggle.connect("focus-out-event", self._reset_keycode_consumption)
|
||||
toggle.connect("focus-out-event", self._on_toggle_unfocus)
|
||||
toggle.connect("toggled", self._on_recording_toggle_toggle)
|
||||
# Don't leave the input when using arrow keys or tab. wait for the
|
||||
# window to consume the keycode from the reader. I.e. a tab input should
|
||||
# be recorded, instead of causing the recording to stop.
|
||||
toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP)
|
||||
|
||||
def _show_press_key(self, *args):
|
||||
"""Show user friendly instructions."""
|
||||
self.get_recording_toggle().set_label(_("Press Key"))
|
||||
|
||||
def _show_change_key(self, *args):
|
||||
"""Show user friendly instructions."""
|
||||
self.get_recording_toggle().set_label(_("Change Key"))
|
||||
|
||||
def _setup_source_view(self):
|
||||
"""Prepare the code editor."""
|
||||
source_view = self.get_code_editor()
|
||||
|
||||
# without this the wrapping ScrolledWindow acts weird when new lines are added,
|
||||
# not offering enough space to the text editor so the whole thing is suddenly
|
||||
# scrollable by a few pixels.
|
||||
# Found this after making blind guesses with settings in glade, and then
|
||||
# actually looking at the snaphot preview! In glades editor this didn have an
|
||||
# effect.
|
||||
source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE)
|
||||
|
||||
source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline)
|
||||
|
||||
# Syntax Highlighting
|
||||
# Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example
|
||||
# language_manager = GtkSource.LanguageManager()
|
||||
# fun fact: without saving LanguageManager into its own variable it doesn't work
|
||||
# python = language_manager.get_language("python")
|
||||
# source_view.get_buffer().set_language(python)
|
||||
# TODO there are some similarities with python, but overall it's quite useless.
|
||||
# commented out until there is proper highlighting for input-remappers syntax.
|
||||
|
||||
autocompletion = Autocompletion(source_view, self.get_target_selector())
|
||||
autocompletion.set_relative_to(self.get("code_editor_container"))
|
||||
autocompletion.connect("suggestion-inserted", self.gather_changes_and_save)
|
||||
self.autocompletion = autocompletion
|
||||
|
||||
def show_line_numbers_if_multiline(self, *_):
|
||||
"""Show line numbers if a macro is being edited."""
|
||||
code_editor = self.get_code_editor()
|
||||
symbol = self.get_symbol_input_text() or ""
|
||||
|
||||
if "\n" in symbol:
|
||||
code_editor.set_show_line_numbers(True)
|
||||
code_editor.set_monospace(True)
|
||||
code_editor.get_style_context().add_class("multiline")
|
||||
else:
|
||||
code_editor.set_show_line_numbers(False)
|
||||
code_editor.set_monospace(False)
|
||||
code_editor.get_style_context().remove_class("multiline")
|
||||
|
||||
def check_add_new_key(self):
|
||||
"""If needed, add a new empty mapping to the list for the user to configure."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
|
||||
selection_label_listbox = selection_label_listbox.get_children()
|
||||
|
||||
for selection_label in selection_label_listbox:
|
||||
combination = selection_label.get_combination()
|
||||
if (
|
||||
combination is None
|
||||
or active_preset.get_mapping(combination) is None
|
||||
or not active_preset.get_mapping(combination).is_valid()
|
||||
):
|
||||
# unfinished row found
|
||||
break
|
||||
else:
|
||||
self.add_empty()
|
||||
|
||||
return True
|
||||
|
||||
def disable_symbol_input(self, clear=False):
|
||||
"""Display help information and dont allow entering a symbol.
|
||||
|
||||
Without this, maybe a user enters a symbol or writes a macro, switches
|
||||
presets accidentally before configuring the key and then it's gone. It can
|
||||
only be saved to the preset if a key is configured. This avoids that pitfall.
|
||||
"""
|
||||
logger.debug("Disabling the code editor")
|
||||
text_input = self.get_code_editor()
|
||||
|
||||
# beware that this also appeared to disable event listeners like
|
||||
# focus-out-event:
|
||||
text_input.set_sensitive(False)
|
||||
text_input.set_opacity(0.5)
|
||||
|
||||
if clear or self.get_symbol_input_text() == "":
|
||||
# don't overwrite user input
|
||||
self.set_symbol_input_text(SET_KEY_FIRST)
|
||||
|
||||
def enable_symbol_input(self):
|
||||
"""Don't display help information anymore and allow changing the symbol."""
|
||||
logger.debug("Enabling the code editor")
|
||||
text_input = self.get_code_editor()
|
||||
text_input.set_sensitive(True)
|
||||
text_input.set_opacity(1)
|
||||
|
||||
buffer = text_input.get_buffer()
|
||||
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
|
||||
if symbol == SET_KEY_FIRST:
|
||||
# don't overwrite user input
|
||||
self.set_symbol_input_text("")
|
||||
|
||||
def disable_target_selector(self):
|
||||
"""Don't allow any selection."""
|
||||
selector = self.get_target_selector()
|
||||
selector.set_sensitive(False)
|
||||
selector.set_opacity(0.5)
|
||||
|
||||
def enable_target_selector(self):
|
||||
selector = self.get_target_selector()
|
||||
selector.set_sensitive(True)
|
||||
selector.set_opacity(1)
|
||||
|
||||
@ensure_everything_saved
|
||||
def on_mapping_selected(self, _=None, selection_label=None):
|
||||
"""One of the buttons in the left "combination" column was clicked.
|
||||
|
||||
Load the information from that mapping entry into the editor.
|
||||
"""
|
||||
self.active_selection_label = selection_label
|
||||
|
||||
if selection_label is None:
|
||||
return
|
||||
|
||||
combination = selection_label.combination
|
||||
self.set_combination(combination)
|
||||
|
||||
if combination is None:
|
||||
# the empty mapping was selected
|
||||
self.active_mapping = UIMapping()
|
||||
# active_preset.add(self.active_mapping)
|
||||
self.disable_symbol_input(clear=True)
|
||||
# default target should fit in most cases
|
||||
self.set_target_selection("keyboard")
|
||||
self.active_mapping.target_uinput = "keyboard"
|
||||
# target input disabled until a combination is configured
|
||||
self.disable_target_selector()
|
||||
# symbol input disabled until a combination is configured
|
||||
else:
|
||||
mapping = active_preset.get_mapping(combination)
|
||||
if mapping is not None:
|
||||
self.active_mapping = mapping
|
||||
self.set_symbol_input_text(mapping.output_symbol)
|
||||
self.set_target_selection(mapping.target_uinput)
|
||||
self.enable_symbol_input()
|
||||
self.enable_target_selector()
|
||||
|
||||
self.get("window").set_focus(self.get_code_editor())
|
||||
|
||||
def add_empty(self):
|
||||
"""Add one empty row for a single mapped key."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
mapping_selection = SelectionLabel()
|
||||
mapping_selection.set_label(_("new entry"))
|
||||
mapping_selection.show_all()
|
||||
selection_label_listbox.insert(mapping_selection, -1)
|
||||
|
||||
@ensure_everything_saved
|
||||
def load_custom_mapping(self):
|
||||
"""Display the entries in active_preset."""
|
||||
selection_label_listbox = self.get("selection_label_listbox")
|
||||
|
||||
selection_label_listbox.forall(selection_label_listbox.remove)
|
||||
|
||||
for mapping in active_preset:
|
||||
selection_label = SelectionLabel()
|
||||
selection_label.set_combination(mapping.event_combination)
|
||||
selection_label_listbox.insert(selection_label, -1)
|
||||
|
||||
self.check_add_new_key()
|
||||
|
||||
# select the first entry
|
||||
selection_labels = selection_label_listbox.get_children()
|
||||
|
||||
if len(selection_labels) == 0:
|
||||
self.add_empty()
|
||||
selection_labels = selection_label_listbox.get_children()
|
||||
|
||||
selection_label_listbox.select_row(selection_labels[0])
|
||||
|
||||
def get_recording_toggle(self) -> Gtk.ToggleButton:
|
||||
return self.get("key_recording_toggle")
|
||||
|
||||
def get_code_editor(self) -> GtkSource.View:
|
||||
return self.get("code_editor")
|
||||
|
||||
def get_target_selector(self) -> Gtk.ComboBox:
|
||||
return self.get("target-selector")
|
||||
|
||||
def get_combination_listbox(self) -> Gtk.ListBox:
|
||||
return self.get("combination-listbox")
|
||||
|
||||
def get_add_axis_btn(self) -> Gtk.Button:
|
||||
return self.get("add-axis-as-btn")
|
||||
|
||||
def get_delete_button(self) -> Gtk.Button:
|
||||
return self.get("delete-mapping")
|
||||
|
||||
def set_combination(self, combination):
|
||||
"""Show what the user is currently pressing in the user interface."""
|
||||
self.active_selection_label.set_combination(combination)
|
||||
listbox = self.get_combination_listbox()
|
||||
listbox.forall(listbox.remove)
|
||||
|
||||
if combination:
|
||||
for event in combination:
|
||||
listbox.insert(CombinationEntry(event), -1)
|
||||
|
||||
if combination and len(combination) > 0:
|
||||
self.enable_symbol_input()
|
||||
else:
|
||||
self.disable_symbol_input()
|
||||
|
||||
def get_combination(self):
|
||||
"""Get the EventCombination object from the left column.
|
||||
|
||||
Or None if no code is mapped on this row.
|
||||
"""
|
||||
if self.active_selection_label is None:
|
||||
return None
|
||||
|
||||
return self.active_selection_label.combination
|
||||
|
||||
def set_symbol_input_text(self, symbol):
|
||||
code_editor = self.get_code_editor()
|
||||
code_editor.get_buffer().set_text(symbol or "")
|
||||
# move cursor location to the beginning, like any code editor does
|
||||
Gtk.TextView.do_move_cursor(
|
||||
code_editor,
|
||||
Gtk.MovementStep.BUFFER_ENDS,
|
||||
-1,
|
||||
False,
|
||||
)
|
||||
|
||||
def get_symbol_input_text(self):
|
||||
"""Get the assigned symbol from the text input.
|
||||
|
||||
This might not be stored in active_preset yet, and might therefore also not
|
||||
be part of the preset json file yet.
|
||||
|
||||
If there is no symbol, this returns None. This is important for some other
|
||||
logic down the road in active_preset or something.
|
||||
"""
|
||||
buffer = self.get_code_editor().get_buffer()
|
||||
symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)
|
||||
|
||||
if symbol == SET_KEY_FIRST:
|
||||
# not configured yet
|
||||
return ""
|
||||
|
||||
return symbol
|
||||
|
||||
def set_target_selection(self, target):
|
||||
selector = self.get_target_selector()
|
||||
selector.set_active_id(target)
|
||||
|
||||
def get_target_selection(self):
|
||||
return self.get_target_selector().get_active_id()
|
||||
|
||||
def get(self, name):
|
||||
"""Get a widget from the window."""
|
||||
return self.user_interface.builder.get_object(name)
|
||||
|
||||
def update_toggle_opacity(self):
|
||||
"""If the key can't be mapped, grey it out.
|
||||
|
||||
During injection, when the device is grabbed and weird things are being
|
||||
done, it is not possible.
|
||||
"""
|
||||
toggle = self.get_recording_toggle()
|
||||
if not self.user_interface.can_modify_preset():
|
||||
toggle.set_opacity(0.4)
|
||||
else:
|
||||
toggle.set_opacity(1)
|
||||
|
||||
return True
|
||||
|
||||
def _on_recording_toggle_toggle(self, toggle):
|
||||
"""Refresh useful usage information."""
|
||||
if not toggle.get_active():
|
||||
# if more events arrive from the time when the toggle was still on,
|
||||
# use them.
|
||||
self.record_events_until = time.time()
|
||||
return
|
||||
|
||||
self.record_events_until = RECORD_ALL
|
||||
|
||||
self._reset_keycode_consumption()
|
||||
reader.clear()
|
||||
if not self.user_interface.can_modify_preset():
|
||||
# because the device is in grab mode by the daemon and
|
||||
# therefore the original keycode inaccessible
|
||||
logger.info("Cannot change keycodes while injecting")
|
||||
self.user_interface.show_status(
|
||||
CTX_ERROR, _('Use "Stop Injection" to stop before editing')
|
||||
)
|
||||
toggle.set_active(False)
|
||||
|
||||
def _on_delete_button_clicked(self, *_):
|
||||
"""Destroy the row and remove it from the config."""
|
||||
accept = Gtk.ResponseType.ACCEPT
|
||||
if (
|
||||
len(self.get_symbol_input_text()) > 0
|
||||
and self._show_confirm_delete() != accept
|
||||
):
|
||||
return
|
||||
|
||||
combination = self.get_combination()
|
||||
if combination is not None:
|
||||
active_preset.remove(combination)
|
||||
|
||||
# make sure there is no outdated information lying around in memory
|
||||
self.set_combination(None)
|
||||
|
||||
self.load_custom_mapping()
|
||||
|
||||
def _show_confirm_delete(self):
|
||||
"""Blocks until the user decided about an action."""
|
||||
confirm_delete = self.get("confirm-delete")
|
||||
|
||||
text = _("Are you sure to delete this mapping?")
|
||||
self.get("confirm-delete-label").set_text(text)
|
||||
|
||||
confirm_delete.show()
|
||||
response = confirm_delete.run()
|
||||
confirm_delete.hide()
|
||||
return response
|
||||
|
||||
def gather_changes_and_save(self, *_):
|
||||
"""Look into the ui if new changes should be written, and save the preset."""
|
||||
# correct case
|
||||
symbol = self.get_symbol_input_text()
|
||||
target = self.get_target_selection()
|
||||
|
||||
if not symbol or not target:
|
||||
return
|
||||
|
||||
# save to disk if required
|
||||
if active_preset.is_valid():
|
||||
self.user_interface.save_preset()
|
||||
|
||||
def is_waiting_for_input(self):
|
||||
"""Check if the user is trying to record buttons."""
|
||||
return self.get_recording_toggle().get_active()
|
||||
|
||||
def should_record_combination(self, combination):
|
||||
"""Check if the combination was written when the toggle was active."""
|
||||
# At this point the toggle might already be off, because some keys that are
|
||||
# used while the toggle was still on might cause the focus of the toggle to
|
||||
# be lost, like multimedia keys. This causes the toggle to be disabled.
|
||||
# Yet, this event should be mapped.
|
||||
timestamp = max([event.timestamp() for event in combination])
|
||||
return timestamp < self.record_events_until
|
||||
|
||||
def consume_newest_keycode(self, combination: EventCombination):
|
||||
"""To capture events from keyboards, mice and gamepads."""
|
||||
self._switch_focus_if_complete()
|
||||
|
||||
if combination is None:
|
||||
return
|
||||
|
||||
if not self.should_record_combination(combination):
|
||||
# the event arrived after the toggle has been deactivated
|
||||
logger.debug("Recording toggle is not on")
|
||||
return
|
||||
|
||||
if not isinstance(combination, EventCombination):
|
||||
raise TypeError("Expected new_key to be a EventCombination object")
|
||||
|
||||
# keycode is already set by some other row
|
||||
existing = active_preset.get_mapping(combination)
|
||||
if existing is not None:
|
||||
msg = _('"%s" already mapped to "%s"') % (
|
||||
combination.beautify(),
|
||||
existing.event_combination.beautify(),
|
||||
)
|
||||
logger.info("%s %s", combination, msg)
|
||||
self.user_interface.show_status(CTX_KEYCODE, msg)
|
||||
return
|
||||
|
||||
if combination.is_problematic():
|
||||
self.user_interface.show_status(
|
||||
CTX_WARNING,
|
||||
_("ctrl, alt and shift may not combine properly"),
|
||||
_("Your system might reinterpret combinations ")
|
||||
+ _("with those after they are injected, and by doing so ")
|
||||
+ _("break them."),
|
||||
)
|
||||
|
||||
# the newest_keycode is populated since the ui regularly polls it
|
||||
# in order to display it in the status bar.
|
||||
previous_key = self.get_combination()
|
||||
|
||||
# it might end up being a key combination, wait for more
|
||||
self._input_has_arrived = True
|
||||
|
||||
# keycode didn't change, do nothing
|
||||
if combination == previous_key:
|
||||
logger.debug("%s didn't change", previous_key)
|
||||
return
|
||||
|
||||
self.set_combination(combination)
|
||||
self.active_mapping.event_combination = combination
|
||||
if previous_key is None and combination is not None:
|
||||
logger.debug(f"adding new mapping to preset\n{self.active_mapping}")
|
||||
active_preset.add(self.active_mapping)
|
||||
|
||||
def _switch_focus_if_complete(self):
|
||||
"""If keys are released, it will switch to the text_input.
|
||||
|
||||
States:
|
||||
1. not doing anything, waiting for the user to start using it
|
||||
2. user focuses it, no keys pressed
|
||||
3. user presses keys
|
||||
4. user releases keys. no keys are pressed, just like in step 2, but this time
|
||||
the focus needs to switch.
|
||||
"""
|
||||
if not self.is_waiting_for_input():
|
||||
self._reset_keycode_consumption()
|
||||
return
|
||||
|
||||
all_keys_released = reader.get_unreleased_keys() is None
|
||||
if all_keys_released and self._input_has_arrived and self.get_combination():
|
||||
logger.debug("Recording complete")
|
||||
# A key was pressed and then released.
|
||||
# Switch to the symbol. idle_add this so that the
|
||||
# keycode event won't write into the symbol input as well.
|
||||
window = self.user_interface.window
|
||||
self.enable_symbol_input()
|
||||
self.enable_target_selector()
|
||||
GLib.idle_add(lambda: window.set_focus(self.get_code_editor()))
|
||||
|
||||
if not all_keys_released:
|
||||
# currently the user is using the widget, and certain keys have already
|
||||
# reached it.
|
||||
self._input_has_arrived = True
|
||||
return
|
||||
|
||||
self._reset_keycode_consumption()
|
||||
|
||||
def _reset_keycode_consumption(self, *_):
|
||||
self._input_has_arrived = False
|
@ -0,0 +1,238 @@
|
||||
#!/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 os.path
|
||||
import re
|
||||
import traceback
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Callable,
|
||||
Dict,
|
||||
Set,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Deque,
|
||||
Optional,
|
||||
List,
|
||||
Any,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from inputremapper.groups import DeviceType
|
||||
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"
|
||||
|
||||
|
||||
class Message(Protocol):
|
||||
"""the protocol any message must follow to be sent with the MessageBroker"""
|
||||
|
||||
message_type: MessageType
|
||||
|
||||
|
||||
# useful type aliases
|
||||
MessageListener = Callable[[Any], None]
|
||||
Capabilities = Dict[int, List]
|
||||
Name = str
|
||||
Key = str
|
||||
DeviceTypes = List[DeviceType]
|
||||
|
||||
|
||||
class MessageBroker:
|
||||
shorten_path = re.compile("inputremapper/")
|
||||
|
||||
def __init__(self):
|
||||
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
|
||||
self._messages: Deque[Tuple[Message, str, int]] = deque()
|
||||
self._sending = False
|
||||
|
||||
def send(self, data: Message):
|
||||
"""schedule a massage to be sent.
|
||||
The message will be sent after all currently pending messages are sent"""
|
||||
self._messages.append((data, *self.get_caller()))
|
||||
self._send_all()
|
||||
|
||||
def signal(self, signal: MessageType):
|
||||
"""send a signal without any data payload"""
|
||||
self.send(Signal(signal))
|
||||
|
||||
def _send(self, data: Message, file: str, line: int):
|
||||
logger.debug(f"from {file}:{line}: Signal={data.message_type.name}: {data}")
|
||||
for listener in self._listeners[data.message_type].copy():
|
||||
listener(data)
|
||||
|
||||
def _send_all(self):
|
||||
"""send all scheduled messages in order"""
|
||||
if self._sending:
|
||||
# don't run this twice, so we not mess up the order
|
||||
return
|
||||
|
||||
self._sending = True
|
||||
try:
|
||||
while self._messages:
|
||||
self._send(*self._messages.popleft())
|
||||
finally:
|
||||
self._sending = False
|
||||
|
||||
def subscribe(self, massage_type: MessageType, listener: MessageListener):
|
||||
"""attach a listener to an event"""
|
||||
logger.debug("adding new Listener: %s", listener)
|
||||
self._listeners[massage_type].add(listener)
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def get_caller(position: int = 3) -> Tuple[str, int]:
|
||||
"""extract a file and line from current stack and format for logging"""
|
||||
tb = traceback.extract_stack(limit=position)[0]
|
||||
return os.path.basename(tb.filename), tb.lineno or 0
|
||||
|
||||
def unsubscribe(self, listener: MessageListener) -> None:
|
||||
for listeners in self._listeners.values():
|
||||
try:
|
||||
listeners.remove(listener)
|
||||
except KeyError:
|
||||
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"""
|
||||
|
||||
def __init__(self, message_type: MessageType):
|
||||
self.message_type: MessageType = message_type
|
||||
|
||||
def __str__(self):
|
||||
return f"Signal: {self.message_type}"
|
||||
|
||||
def __eq__(self, other):
|
||||
return str(self) == str(other)
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,107 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from evdev.ecodes import EV_KEY, KEY_A
|
||||
|
||||
import gi
|
||||
|
||||
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 inputremapper.gui.utils import gtk_iteration
|
||||
from tests.test import quick_cleanup
|
||||
from inputremapper.gui.message_broker import MessageBroker, MessageType
|
||||
from inputremapper.gui.user_interface import UserInterface
|
||||
from inputremapper.configs.mapping import MappingData
|
||||
from inputremapper.event_combination import EventCombination
|
||||
|
||||
|
||||
class TestUserInterface(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.message_broker = MessageBroker()
|
||||
self.controller_mock = MagicMock()
|
||||
self.user_interface = UserInterface(self.message_broker, self.controller_mock)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.message_broker.signal(MessageType.terminate)
|
||||
GLib.timeout_add(0, self.user_interface.window.destroy)
|
||||
GLib.timeout_add(0, Gtk.main_quit)
|
||||
Gtk.main()
|
||||
quick_cleanup()
|
||||
|
||||
def test_shortcut(self):
|
||||
mock = MagicMock()
|
||||
self.user_interface.shortcuts[Gdk.KEY_x] = mock
|
||||
|
||||
event = Gdk.Event()
|
||||
event.key.keyval = Gdk.KEY_x
|
||||
event.key.state = Gdk.ModifierType.SHIFT_MASK
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
event.key.state = Gdk.ModifierType.CONTROL_MASK
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_called_once()
|
||||
|
||||
mock.reset_mock()
|
||||
event.key.keyval = Gdk.KEY_y
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
def test_connected_shortcuts(self):
|
||||
should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete}
|
||||
connected = set(self.user_interface.shortcuts.keys())
|
||||
self.assertEqual(connected, should_be_connected)
|
||||
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close
|
||||
)
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_r],
|
||||
self.controller_mock.refresh_groups,
|
||||
)
|
||||
self.assertIs(
|
||||
self.user_interface.shortcuts[Gdk.KEY_Delete],
|
||||
self.controller_mock.stop_injecting,
|
||||
)
|
||||
|
||||
def test_connect_disconnect_shortcuts(self):
|
||||
mock = MagicMock()
|
||||
self.user_interface.shortcuts[Gdk.KEY_x] = mock
|
||||
|
||||
event = Gdk.Event()
|
||||
event.key.keyval = Gdk.KEY_x
|
||||
event.key.state = Gdk.ModifierType.CONTROL_MASK
|
||||
self.user_interface.disconnect_shortcuts()
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_not_called()
|
||||
|
||||
self.user_interface.connect_shortcuts()
|
||||
gtk_iteration()
|
||||
self.user_interface.window.emit("key-press-event", event)
|
||||
gtk_iteration()
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_combination_label_shows_combination(self):
|
||||
self.message_broker.send(
|
||||
MappingData(
|
||||
event_combination=EventCombination((EV_KEY, KEY_A, 1)), name="foo"
|
||||
)
|
||||
)
|
||||
gtk_iteration()
|
||||
label: Gtk.Label = self.user_interface.get("combination-label")
|
||||
self.assertEqual(label.get_text(), "a")
|
||||
self.assertEqual(label.get_opacity(), 1)
|
||||
|
||||
def test_combination_label_shows_text_when_empty_mapping(self):
|
||||
self.message_broker.send(MappingData())
|
||||
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,0 +1,893 @@
|
||||
#!/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 json
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
from itertools import permutations
|
||||
from typing import List, Dict, Any
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
from inputremapper.configs.global_config import global_config
|
||||
from inputremapper.configs.mapping import UIMapping, MappingData
|
||||
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 (
|
||||
MessageBroker,
|
||||
MessageType,
|
||||
GroupData,
|
||||
PresetData,
|
||||
CombinationUpdate,
|
||||
)
|
||||
from inputremapper.gui.reader import Reader
|
||||
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||
from inputremapper.input_event import InputEvent
|
||||
from tests.test import get_key_mapping, quick_cleanup, FakeDaemonProxy, prepare_presets
|
||||
|
||||
from inputremapper.configs.paths import get_preset_path, get_config_path
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
|
||||
|
||||
|
||||
class Listener:
|
||||
def __init__(self):
|
||||
self.calls: List = []
|
||||
|
||||
def __call__(self, data):
|
||||
self.calls.append(data)
|
||||
|
||||
|
||||
class TestDataManager(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.message_broker = MessageBroker()
|
||||
self.reader = Reader(self.message_broker, _Groups())
|
||||
self.uinputs = GlobalUInputs()
|
||||
self.uinputs.prepare_all()
|
||||
self.data_manager = DataManager(
|
||||
self.message_broker,
|
||||
global_config,
|
||||
self.reader,
|
||||
FakeDaemonProxy(),
|
||||
self.uinputs,
|
||||
system_mapping,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
quick_cleanup()
|
||||
|
||||
def test_load_group_provides_presets(self):
|
||||
"""we should get all preset of a group, when loading it"""
|
||||
prepare_presets()
|
||||
response: List[GroupData] = []
|
||||
|
||||
def listener(data: GroupData):
|
||||
response.append(data)
|
||||
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
self.data_manager.load_group("Foo Device 2")
|
||||
|
||||
for preset_name in response[0].presets:
|
||||
self.assertIn(
|
||||
preset_name,
|
||||
(
|
||||
"preset1",
|
||||
"preset2",
|
||||
"preset3",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(response[0].group_key, "Foo Device 2")
|
||||
|
||||
def test_load_group_without_presets_provides_none(self):
|
||||
"""we should get no presets when loading a group without presets"""
|
||||
response: List[GroupData] = []
|
||||
|
||||
def listener(data: GroupData):
|
||||
response.append(data)
|
||||
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertEqual(len(response[0].presets), 0)
|
||||
|
||||
def test_load_non_existing_group(self):
|
||||
"""we should not be able to load an unknown group"""
|
||||
with self.assertRaises(DataManagementError):
|
||||
self.data_manager.load_group(group_key="Some Unknown Device")
|
||||
|
||||
def test_cannot_load_preset_without_group(self):
|
||||
"""loading a preset without a loaded group should
|
||||
raise a DataManagementError"""
|
||||
prepare_presets()
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.load_preset,
|
||||
name="preset1",
|
||||
)
|
||||
|
||||
def test_load_preset(self):
|
||||
"""loading an existing preset should be possible"""
|
||||
prepare_presets()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.data_manager.load_preset(name="preset1")
|
||||
mappings = listener.calls[0].mappings
|
||||
preset_name = listener.calls[0].name
|
||||
|
||||
expected_preset = Preset(get_preset_path("Foo Device", "preset1"))
|
||||
expected_preset.load()
|
||||
expected_mappings = [
|
||||
(mapping.name, mapping.event_combination) for mapping in expected_preset
|
||||
]
|
||||
|
||||
self.assertEqual(preset_name, "preset1")
|
||||
for mapping in expected_mappings:
|
||||
self.assertIn(mapping, mappings)
|
||||
|
||||
def test_cannot_load_non_existing_preset(self):
|
||||
"""loading a non-existing preset should raise an KeyError"""
|
||||
prepare_presets()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device")
|
||||
self.assertRaises(
|
||||
FileNotFoundError,
|
||||
self.data_manager.load_preset,
|
||||
name="unknownPreset",
|
||||
)
|
||||
|
||||
def test_save_preset(self):
|
||||
"""modified preses should be saved to the disc"""
|
||||
prepare_presets()
|
||||
# make sure the correct preset is loaded
|
||||
self.data_manager.load_group(group_key="Foo Device")
|
||||
self.data_manager.load_preset(name="preset1")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,1,1"))
|
||||
|
||||
mapping: MappingData = listener.calls[0]
|
||||
control_preset = Preset(get_preset_path("Foo Device", "preset1"))
|
||||
control_preset.load()
|
||||
self.assertEqual(
|
||||
control_preset.get_mapping(EventCombination("1,1,1")).output_symbol,
|
||||
mapping.output_symbol,
|
||||
)
|
||||
|
||||
# change the mapping provided with the mapping_changed event and save
|
||||
self.data_manager.update_mapping(output_symbol="key(a)")
|
||||
self.data_manager.save()
|
||||
|
||||
# reload the control_preset
|
||||
control_preset.empty()
|
||||
control_preset.load()
|
||||
self.assertEqual(
|
||||
control_preset.get_mapping(EventCombination("1,1,1")).output_symbol,
|
||||
"key(a)",
|
||||
)
|
||||
|
||||
def test_copy_preset(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
|
||||
self.data_manager.copy_preset("foo")
|
||||
|
||||
# we expect the first data to be group data and the second
|
||||
# one a preset data of the new copy
|
||||
presets_in_group = [preset for preset in listener.calls[0].presets]
|
||||
self.assertIn("preset2", presets_in_group)
|
||||
self.assertIn("foo", presets_in_group)
|
||||
self.assertEqual(listener.calls[1].name, "foo")
|
||||
|
||||
# this should pass without error:
|
||||
self.data_manager.load_preset("preset2")
|
||||
self.data_manager.copy_preset("preset2")
|
||||
|
||||
def test_cannot_copy_preset(self):
|
||||
prepare_presets()
|
||||
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.copy_preset,
|
||||
"foo",
|
||||
)
|
||||
self.data_manager.load_group("Foo Device 2")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.copy_preset,
|
||||
"foo",
|
||||
)
|
||||
|
||||
def test_copy_preset_to_existing_name_raises_error(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
|
||||
self.assertRaises(
|
||||
ValueError,
|
||||
self.data_manager.copy_preset,
|
||||
"preset3",
|
||||
)
|
||||
|
||||
def test_rename_preset(self):
|
||||
"""should be able to rename a preset"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
|
||||
self.data_manager.rename_preset(new_name="new preset")
|
||||
|
||||
# we expect the first data to be group data and the second
|
||||
# one a preset data
|
||||
presets_in_group = [preset for preset in listener.calls[0].presets]
|
||||
self.assertNotIn("preset2", presets_in_group)
|
||||
self.assertIn("new preset", presets_in_group)
|
||||
self.assertEqual(listener.calls[1].name, "new preset")
|
||||
|
||||
# this should pass without error:
|
||||
self.data_manager.load_preset(name="new preset")
|
||||
self.data_manager.rename_preset(new_name="new preset")
|
||||
|
||||
def test_rename_preset_sets_autoload_correct(self):
|
||||
"""when renaming a preset the autoload status should still be set correctly"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.data_manager.load_preset(name="preset2") # sends PresetData
|
||||
# sends PresetData with updated name, e. e. should be equal
|
||||
self.data_manager.rename_preset(new_name="foo")
|
||||
self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload)
|
||||
|
||||
def test_cannot_rename_preset(self):
|
||||
"""rename preset should raise a DataManagementError if a preset
|
||||
with the new name already exists in the current group"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
|
||||
self.assertRaises(
|
||||
ValueError,
|
||||
self.data_manager.rename_preset,
|
||||
new_name="preset3",
|
||||
)
|
||||
|
||||
def test_cannot_rename_preset_without_preset(self):
|
||||
prepare_presets()
|
||||
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.rename_preset,
|
||||
new_name="foo",
|
||||
)
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.rename_preset,
|
||||
new_name="foo",
|
||||
)
|
||||
|
||||
def test_add_preset(self):
|
||||
"""should be able to add a preset"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
|
||||
# should emit group_changed
|
||||
self.data_manager.create_preset(name="new preset")
|
||||
|
||||
presets_in_group = [preset for preset in listener.calls[0].presets]
|
||||
self.assertIn("preset2", presets_in_group)
|
||||
self.assertIn("preset3", presets_in_group)
|
||||
self.assertIn("new preset", presets_in_group)
|
||||
|
||||
def test_cannot_add_preset(self):
|
||||
"""adding a preset with the same name as an already existing
|
||||
preset (of the current group) should raise a DataManagementError"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.create_preset,
|
||||
name="preset3",
|
||||
)
|
||||
|
||||
def test_cannot_add_preset_without_group(self):
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.create_preset,
|
||||
name="foo",
|
||||
)
|
||||
|
||||
def test_delete_preset(self):
|
||||
"""should be able to delete the current preset"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
|
||||
# should emit only group_changed
|
||||
self.data_manager.delete_preset()
|
||||
|
||||
presets_in_group = [preset for preset in listener.calls[0].presets]
|
||||
self.assertEqual(len(presets_in_group), 2)
|
||||
self.assertNotIn("preset2", presets_in_group)
|
||||
self.assertEqual(len(listener.calls), 1)
|
||||
|
||||
def test_load_mapping(self):
|
||||
"""should be able to load a mapping"""
|
||||
preset, _, _ = prepare_presets()
|
||||
expected_mapping = preset.get_mapping(EventCombination("1,1,1"))
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device")
|
||||
self.data_manager.load_preset(name="preset1")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,1,1"))
|
||||
mapping = listener.calls[0]
|
||||
|
||||
self.assertEqual(mapping, expected_mapping)
|
||||
|
||||
def test_cannot_load_non_existing_mapping(self):
|
||||
"""loading a mapping tha is not present in the preset should raise a KeyError"""
|
||||
prepare_presets()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
self.data_manager.load_mapping,
|
||||
combination=EventCombination("1,1,1"),
|
||||
)
|
||||
|
||||
def test_cannot_load_mapping_without_preset(self):
|
||||
"""loading a mapping if no preset is loaded
|
||||
should raise an DataManagementError"""
|
||||
prepare_presets()
|
||||
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.load_mapping,
|
||||
combination=EventCombination("1,1,1"),
|
||||
)
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.load_mapping,
|
||||
combination=EventCombination("1,1,1"),
|
||||
)
|
||||
|
||||
def test_load_event(self):
|
||||
prepare_presets()
|
||||
mock = MagicMock()
|
||||
self.message_broker.subscribe(MessageType.selected_event, mock)
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
|
||||
mock.assert_called_once_with(InputEvent.from_string("1,1,1"))
|
||||
self.assertEqual(
|
||||
self.data_manager.active_event, InputEvent.from_string("1,1,1")
|
||||
)
|
||||
|
||||
def test_cannot_load_event_when_mapping_not_set(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
with self.assertRaises(DataManagementError):
|
||||
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
|
||||
|
||||
def test_cannot_load_event_when_not_in_mapping_combination(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
with self.assertRaises(ValueError):
|
||||
self.data_manager.load_event(InputEvent.from_string("1,5,1"))
|
||||
|
||||
def test_update_event(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
|
||||
self.data_manager.update_event(InputEvent.from_string("1,5,1"))
|
||||
self.assertEqual(
|
||||
self.data_manager.active_event, InputEvent.from_string("1,5,1")
|
||||
)
|
||||
|
||||
def test_update_event_sends_messages(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
|
||||
|
||||
mock = MagicMock()
|
||||
self.message_broker.subscribe(MessageType.selected_event, mock)
|
||||
self.message_broker.subscribe(MessageType.combination_update, mock)
|
||||
self.message_broker.subscribe(MessageType.mapping, mock)
|
||||
self.data_manager.update_event(InputEvent.from_string("1,5,1"))
|
||||
expected = [
|
||||
call(
|
||||
CombinationUpdate(EventCombination("1,1,1"), EventCombination("1,5,1"))
|
||||
),
|
||||
call(self.data_manager.active_mapping.get_bus_message()),
|
||||
call(InputEvent.from_string("1,5,1")),
|
||||
]
|
||||
mock.assert_has_calls(expected, any_order=False)
|
||||
|
||||
def test_cannot_update_event_when_resulting_combination_exists(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
self.data_manager.load_event(InputEvent.from_string("1,1,1"))
|
||||
with self.assertRaises(KeyError):
|
||||
self.data_manager.update_event(InputEvent.from_string("1,2,1"))
|
||||
|
||||
def test_cannot_update_event_when_not_loaded(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.load_mapping(EventCombination("1,1,1"))
|
||||
with self.assertRaises(DataManagementError):
|
||||
self.data_manager.update_event(InputEvent.from_string("1,2,1"))
|
||||
|
||||
def test_update_mapping_emits_mapping_changed(self):
|
||||
"""update mapping should emit a mapping_changed event"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
self.data_manager.update_mapping(
|
||||
name="foo",
|
||||
output_symbol="f",
|
||||
release_timeout=0.3,
|
||||
)
|
||||
|
||||
response = listener.calls[0]
|
||||
self.assertEqual(response.name, "foo")
|
||||
self.assertEqual(response.output_symbol, "f")
|
||||
self.assertEqual(response.release_timeout, 0.3)
|
||||
|
||||
def test_updated_mapping_can_be_saved(self):
|
||||
"""make sure that updated changes can be saved"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
|
||||
|
||||
self.data_manager.update_mapping(
|
||||
name="foo",
|
||||
output_symbol="f",
|
||||
release_timeout=0.3,
|
||||
)
|
||||
self.data_manager.save()
|
||||
|
||||
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.output_symbol, "f")
|
||||
self.assertEqual(mapping.release_timeout, 0.3)
|
||||
|
||||
def test_updated_mapping_saves_invalid_mapping(self):
|
||||
"""make sure that updated changes can be saved even if they are not valid"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
|
||||
|
||||
self.data_manager.update_mapping(
|
||||
output_symbol="bar", # not a macro and not a valid symbol
|
||||
)
|
||||
self.data_manager.save()
|
||||
|
||||
preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping)
|
||||
preset.load()
|
||||
mapping = preset.get_mapping(EventCombination("1,4,1"))
|
||||
self.assertIsNotNone(mapping.get_error())
|
||||
self.assertEqual(mapping.output_symbol, "bar")
|
||||
|
||||
def test_update_mapping_combination_sends_massage(self):
|
||||
prepare_presets()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
self.message_broker.subscribe(MessageType.combination_update, listener)
|
||||
|
||||
# we expect a message for combination update first, and then for mapping
|
||||
self.data_manager.update_mapping(
|
||||
event_combination=EventCombination.from_string("1,5,1+1,6,1")
|
||||
)
|
||||
self.assertEqual(listener.calls[0].message_type, MessageType.combination_update)
|
||||
self.assertEqual(
|
||||
listener.calls[0].old_combination,
|
||||
EventCombination.from_string("1,4,1"),
|
||||
)
|
||||
self.assertEqual(
|
||||
listener.calls[0].new_combination,
|
||||
EventCombination.from_string("1,5,1+1,6,1"),
|
||||
)
|
||||
self.assertEqual(listener.calls[1].message_type, MessageType.mapping)
|
||||
self.assertEqual(
|
||||
listener.calls[1].event_combination,
|
||||
EventCombination.from_string("1,5,1+1,6,1"),
|
||||
)
|
||||
|
||||
def test_cannot_update_mapping_combination(self):
|
||||
"""updating a mapping with an already existing combination
|
||||
should raise a KeyError"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,4,1"))
|
||||
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
self.data_manager.update_mapping,
|
||||
event_combination=EventCombination("1,3,1"),
|
||||
)
|
||||
|
||||
def test_cannot_update_mapping(self):
|
||||
"""updating a mapping should not be possible if the mapping was not loaded"""
|
||||
prepare_presets()
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.update_mapping,
|
||||
name="foo",
|
||||
)
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.update_mapping,
|
||||
name="foo",
|
||||
)
|
||||
self.data_manager.load_preset("preset2")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.update_mapping,
|
||||
name="foo",
|
||||
)
|
||||
|
||||
def test_create_mapping(self):
|
||||
"""should be able to add a mapping to the current preset"""
|
||||
prepare_presets()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.data_manager.create_mapping() # emits preset_changed
|
||||
|
||||
self.data_manager.load_mapping(combination=EventCombination.empty_combination())
|
||||
|
||||
self.assertEqual(listener.calls[0].name, "preset2")
|
||||
self.assertEqual(len(listener.calls[0].mappings), 3)
|
||||
self.assertEqual(listener.calls[1], UIMapping())
|
||||
|
||||
def test_cannot_create_mapping_without_preset(self):
|
||||
"""adding a mapping if not preset is loaded
|
||||
should raise an DataManagementError"""
|
||||
prepare_presets()
|
||||
|
||||
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertRaises(DataManagementError, self.data_manager.create_mapping)
|
||||
|
||||
def test_delete_mapping(self):
|
||||
"""should be able to delete a mapping"""
|
||||
prepare_presets()
|
||||
|
||||
old_preset = Preset(get_preset_path("Foo Device", "preset2"))
|
||||
old_preset.load()
|
||||
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.data_manager.load_mapping(combination=EventCombination("1,3,1"))
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.message_broker.subscribe(MessageType.mapping, listener)
|
||||
|
||||
self.data_manager.delete_mapping() # emits preset
|
||||
self.data_manager.save()
|
||||
|
||||
deleted_mapping = old_preset.get_mapping(EventCombination("1,3,1"))
|
||||
mappings = listener.calls[0].mappings
|
||||
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
|
||||
]
|
||||
|
||||
self.assertEqual(preset_name, "preset2")
|
||||
for mapping in expected_mappings:
|
||||
self.assertIn(mapping, mappings)
|
||||
|
||||
self.assertNotIn(
|
||||
(deleted_mapping.name, deleted_mapping.event_combination), mappings
|
||||
)
|
||||
|
||||
def test_cannot_delete_mapping(self):
|
||||
"""deleting a mapping should not be possible if the mapping was not loaded"""
|
||||
prepare_presets()
|
||||
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
|
||||
self.data_manager.load_preset(name="preset2")
|
||||
self.assertRaises(DataManagementError, self.data_manager.delete_mapping)
|
||||
|
||||
def test_set_autoload(self):
|
||||
"""should be able to set the autoload status"""
|
||||
prepare_presets()
|
||||
self.data_manager.load_group(group_key="Foo Device")
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.preset, listener)
|
||||
self.data_manager.load_preset(name="preset1") # sends updated preset data
|
||||
self.data_manager.set_autoload(True) # sends updated preset data
|
||||
self.data_manager.set_autoload(False) # sends updated preset data
|
||||
|
||||
self.assertFalse(listener.calls[0].autoload)
|
||||
self.assertTrue(listener.calls[1].autoload)
|
||||
self.assertFalse(listener.calls[2].autoload)
|
||||
|
||||
def test_each_device_can_have_autoload(self):
|
||||
prepare_presets()
|
||||
self.data_manager.load_group("Foo Device 2")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.set_autoload(True)
|
||||
|
||||
# switch to another device
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.data_manager.set_autoload(True)
|
||||
|
||||
# now check that both are set to autoload
|
||||
self.data_manager.load_group("Foo Device 2")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.assertTrue(self.data_manager.get_autoload())
|
||||
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.data_manager.load_preset("preset1")
|
||||
self.assertTrue(self.data_manager.get_autoload())
|
||||
|
||||
def test_cannot_set_autoload_without_preset(self):
|
||||
prepare_presets()
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.set_autoload,
|
||||
True,
|
||||
)
|
||||
self.data_manager.load_group(group_key="Foo Device 2")
|
||||
self.assertRaises(
|
||||
DataManagementError,
|
||||
self.data_manager.set_autoload,
|
||||
True,
|
||||
)
|
||||
|
||||
def test_finds_newest_group(self):
|
||||
Preset(get_preset_path("Foo Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Bar Device", "preset 2")).save()
|
||||
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
|
||||
|
||||
def test_finds_newest_preset(self):
|
||||
Preset(get_preset_path("Foo Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Foo Device", "preset 2")).save()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2")
|
||||
|
||||
def test_newest_group_ignores_unknown_filetypes(self):
|
||||
Preset(get_preset_path("Foo Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Bar Device", "preset 2")).save()
|
||||
|
||||
# not a preset, ignore
|
||||
time.sleep(0.01)
|
||||
path = os.path.join(get_preset_path("Foo Device"), "picture.png")
|
||||
os.mknod(path)
|
||||
|
||||
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
|
||||
|
||||
def test_newest_preset_ignores_unknown_filetypes(self):
|
||||
Preset(get_preset_path("Bar Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Bar Device", "preset 2")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Bar Device", "preset 3")).save()
|
||||
|
||||
# not a preset, ignore
|
||||
time.sleep(0.01)
|
||||
path = os.path.join(get_preset_path("Bar Device"), "picture.png")
|
||||
os.mknod(path)
|
||||
|
||||
self.data_manager.load_group("Bar Device")
|
||||
|
||||
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
|
||||
|
||||
def test_newest_group_ignores_unknon_groups(self):
|
||||
Preset(get_preset_path("Bar Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("unknown_group", "preset 2")).save() # not a known group
|
||||
|
||||
self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device")
|
||||
|
||||
def test_newest_group_and_preset_raises_file_not_found(self):
|
||||
"""should raise file not found error when all preset folders are empty"""
|
||||
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
|
||||
os.makedirs(get_preset_path("Bar Device"))
|
||||
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key)
|
||||
self.data_manager.load_group("Bar Device")
|
||||
self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name)
|
||||
|
||||
def test_newest_preset_raises_data_management_error(self):
|
||||
"""should raise data management error without a active group"""
|
||||
self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name)
|
||||
|
||||
def test_newest_preset_only_searches_active_group(self):
|
||||
Preset(get_preset_path("Foo Device", "preset 1")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Foo Device", "preset 3")).save()
|
||||
time.sleep(0.01)
|
||||
Preset(get_preset_path("Bar Device", "preset 2")).save()
|
||||
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3")
|
||||
|
||||
def test_available_preset_name_default(self):
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(
|
||||
self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME
|
||||
)
|
||||
|
||||
def test_available_preset_name_adds_number_to_default(self):
|
||||
Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(
|
||||
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2"
|
||||
)
|
||||
|
||||
def test_available_preset_name_returns_provided_name(self):
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar")
|
||||
|
||||
def test_available_preset_name__adds_number_to_provided_name(self):
|
||||
Preset(get_preset_path("Foo Device", "bar")).save()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2")
|
||||
|
||||
def test_available_preset_name_raises_data_management_error(self):
|
||||
"""should raise DataManagementError when group is not set"""
|
||||
self.assertRaises(
|
||||
DataManagementError, self.data_manager.get_available_preset_name
|
||||
)
|
||||
|
||||
def test_available_preset_name_increments_default(self):
|
||||
Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save()
|
||||
Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")).save()
|
||||
Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3")).save()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(
|
||||
self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4"
|
||||
)
|
||||
|
||||
def test_available_preset_name_increments_provided_name(self):
|
||||
Preset(get_preset_path("Foo Device", "foo")).save()
|
||||
Preset(get_preset_path("Foo Device", "foo 1")).save()
|
||||
Preset(get_preset_path("Foo Device", "foo 2")).save()
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3")
|
||||
|
||||
def test_should_send_groups(self):
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.groups, listener)
|
||||
|
||||
self.data_manager.send_groups()
|
||||
data = listener.calls[0]
|
||||
|
||||
# we expect a list of tuples with the group key and their device types
|
||||
self.assertEqual(
|
||||
data.groups,
|
||||
{
|
||||
"Foo Device": ["keyboard"],
|
||||
"Foo Device 2": ["gamepad", "keyboard", "mouse"],
|
||||
"Bar Device": ["keyboard"],
|
||||
"gamepad": ["gamepad"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_should_load_group(self):
|
||||
prepare_presets()
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.group, listener)
|
||||
|
||||
self.data_manager.load_group("Foo Device 2")
|
||||
|
||||
self.assertEqual(self.data_manager.active_group.key, "Foo Device 2")
|
||||
data = (
|
||||
GroupData("Foo Device 2", (p1, p2, p3))
|
||||
for p1, p2, p3 in permutations(("preset3", "preset2", "preset1"))
|
||||
)
|
||||
self.assertIn(listener.calls[0], data)
|
||||
|
||||
def test_should_start_reading_active_group(self):
|
||||
def f(*_):
|
||||
raise AssertionError()
|
||||
|
||||
self.reader.set_group = f
|
||||
self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device")
|
||||
|
||||
def test_should_send_uinputs(self):
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.uinputs, listener)
|
||||
|
||||
self.data_manager.send_uinputs()
|
||||
data = listener.calls[0]
|
||||
|
||||
# we expect a list of tuples with the group key and their device types
|
||||
self.assertEqual(
|
||||
data.uinputs,
|
||||
{
|
||||
"gamepad": self.uinputs.get_uinput("gamepad").capabilities(),
|
||||
"keyboard": self.uinputs.get_uinput("keyboard").capabilities(),
|
||||
"mouse": self.uinputs.get_uinput("mouse").capabilities(),
|
||||
"keyboard + mouse": self.uinputs.get_uinput(
|
||||
"keyboard + mouse"
|
||||
).capabilities(),
|
||||
},
|
||||
)
|
||||
|
||||
def test_cannot_stop_injecting_without_group(self):
|
||||
self.assertRaises(DataManagementError, self.data_manager.stop_injecting)
|
||||
|
||||
def test_cannot_start_injecting_without_preset(self):
|
||||
self.data_manager.load_group("Foo Device")
|
||||
self.assertRaises(DataManagementError, self.data_manager.start_injecting)
|
||||
|
||||
def test_cannot_get_injector_state_without_group(self):
|
||||
self.assertRaises(DataManagementError, self.data_manager.get_state)
|
@ -0,0 +1,261 @@
|
||||
#!/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 asyncio
|
||||
import unittest
|
||||
from typing import Iterable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import evdev
|
||||
from evdev.ecodes import (
|
||||
EV_KEY,
|
||||
EV_ABS,
|
||||
EV_REL,
|
||||
ABS_X,
|
||||
ABS_Y,
|
||||
REL_X,
|
||||
REL_Y,
|
||||
BTN_A,
|
||||
REL_HWHEEL,
|
||||
REL_WHEEL,
|
||||
REL_WHEEL_HI_RES,
|
||||
REL_HWHEEL_HI_RES,
|
||||
ABS_HAT0X,
|
||||
BTN_LEFT,
|
||||
BTN_RIGHT,
|
||||
BTN_B,
|
||||
KEY_A,
|
||||
ABS_HAT0Y,
|
||||
KEY_B,
|
||||
KEY_C,
|
||||
BTN_TL,
|
||||
)
|
||||
|
||||
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||
from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler
|
||||
from inputremapper.injection.mapping_handlers.axis_switch_handler import (
|
||||
AxisSwitchHandler,
|
||||
)
|
||||
from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler
|
||||
from inputremapper.injection.mapping_handlers.key_handler import KeyHandler
|
||||
from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler
|
||||
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
|
||||
from inputremapper.logger import logger
|
||||
from inputremapper.configs.mapping import Mapping
|
||||
from inputremapper.injection.context import Context
|
||||
from inputremapper.injection.event_reader import EventReader
|
||||
from tests.test import (
|
||||
get_key_mapping,
|
||||
InputDevice,
|
||||
cleanup,
|
||||
convert_to_internal_events,
|
||||
MAX_ABS,
|
||||
MIN_ABS,
|
||||
)
|
||||
|
||||
from inputremapper.input_event import InputEvent, EventActions
|
||||
from inputremapper.event_combination import EventCombination
|
||||
from inputremapper.configs.system_mapping import system_mapping
|
||||
from inputremapper.configs.preset import Preset
|
||||
from inputremapper.injection.global_uinputs import global_uinputs
|
||||
|
||||
|
||||
class BaseTests:
|
||||
"""implements test that should pass on most mapping handlers
|
||||
in special cases override specific tests.
|
||||
"""
|
||||
|
||||
handler: MappingHandler
|
||||
|
||||
def setUp(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def tearDown(self) -> None:
|
||||
cleanup()
|
||||
|
||||
def test_reset(self):
|
||||
mock = MagicMock()
|
||||
self.handler.set_sub_handler(mock)
|
||||
self.handler.reset()
|
||||
mock.reset.assert_called()
|
||||
|
||||
|
||||
class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = AxisSwitchHandler(
|
||||
EventCombination.from_string("2,5,0+1,3,1"),
|
||||
Mapping(
|
||||
event_combination="2,5,0+1,3,1",
|
||||
target_uinput="mouse",
|
||||
output_type=2,
|
||||
output_code=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = AbsToBtnHandler(
|
||||
EventCombination.from_string("3,5,10"),
|
||||
Mapping(
|
||||
event_combination="3,5,10",
|
||||
target_uinput="mouse",
|
||||
output_symbol="BTN_LEFT",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = AbsToRelHandler(
|
||||
EventCombination((EV_ABS, ABS_X, 0)),
|
||||
Mapping(
|
||||
event_combination=f"{EV_ABS},{ABS_X},0",
|
||||
target_uinput="mouse",
|
||||
output_type=EV_REL,
|
||||
output_code=REL_X,
|
||||
),
|
||||
)
|
||||
|
||||
async def test_reset(self):
|
||||
self.handler.notify(
|
||||
InputEvent(0, 0, EV_ABS, ABS_X, MAX_ABS),
|
||||
source=InputDevice("/dev/input/event15"),
|
||||
forward=evdev.UInput(),
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
self.handler.reset()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
count = global_uinputs.get_uinput("mouse").write_count
|
||||
self.assertGreater(count, 6) # count should be 60*0.2 = 12
|
||||
await asyncio.sleep(0.2)
|
||||
self.assertEqual(count, global_uinputs.get_uinput("mouse").write_count)
|
||||
|
||||
|
||||
class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = AxisSwitchHandler(
|
||||
EventCombination.from_string("2,0,10+1,3,1"),
|
||||
Mapping(
|
||||
event_combination="2,0,10+1,3,1",
|
||||
target_uinput="mouse",
|
||||
output_symbol="BTN_LEFT",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.mock1 = MagicMock()
|
||||
self.mock2 = MagicMock()
|
||||
self.mock3 = MagicMock()
|
||||
self.handler = HierarchyHandler(
|
||||
[self.mock1, self.mock2, self.mock3],
|
||||
InputEvent.from_tuple((EV_KEY, KEY_A, 1)),
|
||||
)
|
||||
|
||||
def test_reset(self):
|
||||
self.handler.reset()
|
||||
self.mock1.reset.assert_called()
|
||||
self.mock2.reset.assert_called()
|
||||
self.mock3.reset.assert_called()
|
||||
|
||||
|
||||
class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = KeyHandler(
|
||||
EventCombination.from_string("2,0,10+1,3,1"),
|
||||
Mapping(
|
||||
event_combination="2,0,10+1,3,1",
|
||||
target_uinput="mouse",
|
||||
output_symbol="BTN_LEFT",
|
||||
),
|
||||
)
|
||||
|
||||
def test_reset(self):
|
||||
self.handler.notify(
|
||||
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
|
||||
source=InputDevice("/dev/input/event11"),
|
||||
forward=evdev.UInput(),
|
||||
)
|
||||
history = convert_to_internal_events(
|
||||
global_uinputs.get_uinput("mouse").write_history
|
||||
)
|
||||
self.assertEqual(history[0], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)))
|
||||
self.assertEqual(len(history), 1)
|
||||
|
||||
self.handler.reset()
|
||||
history = convert_to_internal_events(
|
||||
global_uinputs.get_uinput("mouse").write_history
|
||||
)
|
||||
self.assertEqual(history[1], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)))
|
||||
self.assertEqual(len(history), 2)
|
||||
|
||||
|
||||
class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.context_mock = MagicMock()
|
||||
self.handler = MacroHandler(
|
||||
EventCombination.from_string("2,0,10+1,3,1"),
|
||||
Mapping(
|
||||
event_combination="2,0,10+1,3,1",
|
||||
target_uinput="mouse",
|
||||
output_symbol="hold_keys(BTN_LEFT, BTN_RIGHT)",
|
||||
),
|
||||
context=self.context_mock,
|
||||
)
|
||||
|
||||
async def test_reset(self):
|
||||
self.handler.notify(
|
||||
InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)),
|
||||
source=InputDevice("/dev/input/event11"),
|
||||
forward=evdev.UInput(),
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
history = convert_to_internal_events(
|
||||
global_uinputs.get_uinput("mouse").write_history
|
||||
)
|
||||
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)), history)
|
||||
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 1)), history)
|
||||
self.assertEqual(len(history), 2)
|
||||
|
||||
self.handler.reset()
|
||||
await asyncio.sleep(0.1)
|
||||
history = convert_to_internal_events(
|
||||
global_uinputs.get_uinput("mouse").write_history
|
||||
)
|
||||
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)), history[-2:])
|
||||
self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 0)), history[-2:])
|
||||
self.assertEqual(len(history), 4)
|
||||
|
||||
|
||||
class TestRelToBtnHanlder(BaseTests, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.handler = AxisSwitchHandler(
|
||||
EventCombination.from_string("2,0,10+1,3,1"),
|
||||
Mapping(
|
||||
event_combination="2,0,10+1,3,1",
|
||||
target_uinput="mouse",
|
||||
output_symbol="BTN_LEFT",
|
||||
),
|
||||
)
|
@ -0,0 +1,79 @@
|
||||
import unittest
|
||||
from dataclasses import dataclass
|
||||
|
||||
from inputremapper.gui.message_broker import MessageBroker, MessageType
|
||||
|
||||
|
||||
class Listener:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, data):
|
||||
self.calls.append(data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
message_type: MessageType
|
||||
msg: str
|
||||
|
||||
|
||||
class TestMessageBroker(unittest.TestCase):
|
||||
def test_calls_listeners(self):
|
||||
"""The correct Listeners get called"""
|
||||
message_broker = MessageBroker()
|
||||
listener = Listener()
|
||||
message_broker.subscribe(MessageType.test1, listener)
|
||||
message_broker.send(Message(MessageType.test1, "foo"))
|
||||
message_broker.send(Message(MessageType.test2, "bar"))
|
||||
self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo"))
|
||||
|
||||
def test_unsubscribe(self):
|
||||
message_broker = MessageBroker()
|
||||
listener = Listener()
|
||||
message_broker.subscribe(MessageType.test1, listener)
|
||||
message_broker.send(Message(MessageType.test1, "a"))
|
||||
message_broker.unsubscribe(listener)
|
||||
message_broker.send(Message(MessageType.test1, "b"))
|
||||
self.assertEqual(len(listener.calls), 1)
|
||||
self.assertEqual(listener.calls[0], Message(MessageType.test1, "a"))
|
||||
|
||||
def test_unsubscribe_unknown_listener(self):
|
||||
"""nothing happens if we unsubscribe an unknown listener"""
|
||||
message_broker = MessageBroker()
|
||||
listener1 = Listener()
|
||||
listener2 = Listener()
|
||||
message_broker.subscribe(MessageType.test1, listener1)
|
||||
message_broker.unsubscribe(listener2)
|
||||
message_broker.send(Message(MessageType.test1, "a"))
|
||||
self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a"))
|
||||
|
||||
def test_preserves_order(self):
|
||||
message_broker = MessageBroker()
|
||||
calls = []
|
||||
|
||||
def listener1(_):
|
||||
message_broker.send(Message(MessageType.test2, "f"))
|
||||
calls.append(1)
|
||||
|
||||
def listener2(_):
|
||||
message_broker.send(Message(MessageType.test2, "f"))
|
||||
calls.append(2)
|
||||
|
||||
def listener3(_):
|
||||
message_broker.send(Message(MessageType.test2, "f"))
|
||||
calls.append(3)
|
||||
|
||||
def listener4(_):
|
||||
calls.append(4)
|
||||
|
||||
message_broker.subscribe(MessageType.test1, listener1)
|
||||
message_broker.subscribe(MessageType.test1, listener2)
|
||||
message_broker.subscribe(MessageType.test1, listener3)
|
||||
message_broker.subscribe(MessageType.test2, listener4)
|
||||
message_broker.send(Message(MessageType.test1, ""))
|
||||
|
||||
first = calls[:3]
|
||||
first.sort()
|
||||
self.assertEqual([1, 2, 3], first)
|
||||
self.assertEqual([4, 4, 4], calls[3:])
|
@ -1,218 +0,0 @@
|
||||
#!/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 tests.test import tmp
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from inputremapper.configs.preset import (
|
||||
find_newest_preset,
|
||||
rename_preset,
|
||||
get_any_preset,
|
||||
delete_preset,
|
||||
get_available_preset_name,
|
||||
get_presets,
|
||||
)
|
||||
from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, touch
|
||||
from inputremapper.gui.active_preset import active_preset
|
||||
|
||||
|
||||
def create_preset(group_name, name="new preset"):
|
||||
name = get_available_preset_name(group_name, name)
|
||||
active_preset.clear()
|
||||
active_preset.path = get_preset_path(group_name, name)
|
||||
active_preset.save()
|
||||
|
||||
|
||||
PRESETS = os.path.join(CONFIG_PATH, "presets")
|
||||
|
||||
|
||||
class TestPresets(unittest.TestCase):
|
||||
def test_get_available_preset_name(self):
|
||||
# no filename conflict
|
||||
self.assertEqual(get_available_preset_name("_", "qux 2"), "qux 2")
|
||||
|
||||
touch(get_preset_path("_", "qux 5"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux 5"), "qux 6")
|
||||
touch(get_preset_path("_", "qux"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux"), "qux 2")
|
||||
touch(get_preset_path("_", "qux1"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux1"), "qux1 2")
|
||||
touch(get_preset_path("_", "qux 2 3"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux 2 3"), "qux 2 4")
|
||||
|
||||
touch(get_preset_path("_", "qux 5"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy")
|
||||
touch(get_preset_path("_", "qux 5 copy"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 2")
|
||||
touch(get_preset_path("_", "qux 5 copy 2"))
|
||||
self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 3")
|
||||
|
||||
touch(get_preset_path("_", "qux 5copy"))
|
||||
self.assertEqual(
|
||||
get_available_preset_name("_", "qux 5copy", True),
|
||||
"qux 5copy copy",
|
||||
)
|
||||
touch(get_preset_path("_", "qux 5copy 2"))
|
||||
self.assertEqual(
|
||||
get_available_preset_name("_", "qux 5copy 2", True),
|
||||
"qux 5copy 2 copy",
|
||||
)
|
||||
touch(get_preset_path("_", "qux 5copy 2 copy"))
|
||||
self.assertEqual(
|
||||
get_available_preset_name("_", "qux 5copy 2 copy", True),
|
||||
"qux 5copy 2 copy 2",
|
||||
)
|
||||
|
||||
|
||||
class TestCreatePreset(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(tmp):
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
def test_create_preset_1(self):
|
||||
self.assertEqual(get_any_preset(), ("Foo Device", None))
|
||||
create_preset("Foo Device")
|
||||
self.assertEqual(get_any_preset(), ("Foo Device", "new preset"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
|
||||
|
||||
def test_create_preset_2(self):
|
||||
create_preset("Foo Device")
|
||||
create_preset("Foo Device")
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json"))
|
||||
|
||||
def test_create_preset_3(self):
|
||||
create_preset("Foo Device", "pre set")
|
||||
create_preset("Foo Device", "pre set")
|
||||
create_preset("Foo Device", "pre set")
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 2.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 3.json"))
|
||||
|
||||
|
||||
class TestDeletePreset(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(tmp):
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
def test_delete_preset(self):
|
||||
create_preset("Foo Device")
|
||||
create_preset("Foo Device")
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
|
||||
delete_preset("Foo Device", "new preset")
|
||||
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device"))
|
||||
delete_preset("Foo Device", "new preset 2")
|
||||
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json"))
|
||||
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json"))
|
||||
# if no preset in the directory, remove the directory
|
||||
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device"))
|
||||
|
||||
|
||||
class TestRenamePreset(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(tmp):
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
def test_rename_preset(self):
|
||||
create_preset("Foo Device", "preset 1")
|
||||
create_preset("Foo Device", "preset 2")
|
||||
create_preset("Foo Device", "foobar")
|
||||
rename_preset("Foo Device", "preset 1", "foobar")
|
||||
rename_preset("Foo Device", "preset 2", "foobar")
|
||||
self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/preset 1.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 2.json"))
|
||||
self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 3.json"))
|
||||
|
||||
|
||||
class TestFindPresets(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
if os.path.exists(tmp):
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
def test_get_presets(self):
|
||||
os.makedirs(os.path.join(PRESETS, "1234"))
|
||||
|
||||
os.mknod(os.path.join(PRESETS, "1234", "picture.png"))
|
||||
self.assertEqual(len(get_presets("1234")), 0)
|
||||
|
||||
os.mknod(os.path.join(PRESETS, "1234", "foo bar 1.json"))
|
||||
time.sleep(0.01)
|
||||
os.mknod(os.path.join(PRESETS, "1234", "foo bar 2.json"))
|
||||
# the newest to the front
|
||||
self.assertListEqual(get_presets("1234"), ["foo bar 2", "foo bar 1"])
|
||||
|
||||
def test_find_newest_preset_1(self):
|
||||
create_preset("Foo Device", "preset 1")
|
||||
time.sleep(0.01)
|
||||
create_preset("Bar Device", "preset 2")
|
||||
|
||||
# not a preset, ignore
|
||||
time.sleep(0.01)
|
||||
path = os.path.join(PRESETS, "Bar Device", "picture.png")
|
||||
os.mknod(path)
|
||||
|
||||
self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2"))
|
||||
|
||||
def test_find_newest_preset_2(self):
|
||||
os.makedirs(f"{PRESETS}/Foo Device")
|
||||
time.sleep(0.01)
|
||||
os.makedirs(f"{PRESETS}/device_2")
|
||||
# takes the first one that the test-fake returns
|
||||
self.assertEqual(find_newest_preset(), ("Foo Device", None))
|
||||
|
||||
def test_find_newest_preset_3(self):
|
||||
os.makedirs(f"{PRESETS}/Foo Device")
|
||||
self.assertEqual(find_newest_preset(), ("Foo Device", None))
|
||||
|
||||
def test_find_newest_preset_4(self):
|
||||
create_preset("Foo Device", "preset 1")
|
||||
self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1"))
|
||||
|
||||
def test_find_newest_preset_5(self):
|
||||
create_preset("Foo Device", "preset 1")
|
||||
time.sleep(0.01)
|
||||
create_preset("unknown device 3", "preset 3")
|
||||
self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1"))
|
||||
|
||||
def test_find_newest_preset_6(self):
|
||||
# takes the first one that the test-fake returns
|
||||
self.assertEqual(find_newest_preset(), ("Foo Device", None))
|
||||
|
||||
def test_find_newest_preset_7(self):
|
||||
self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", None))
|
||||
|
||||
def test_find_newest_preset_8(self):
|
||||
create_preset("Foo Device", "preset 1")
|
||||
time.sleep(0.01)
|
||||
create_preset("Foo Device", "preset 3")
|
||||
time.sleep(0.01)
|
||||
create_preset("Bar Device", "preset 2")
|
||||
self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", "preset 3"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|