Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires. Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
921 lines
34 KiB
Python
921 lines
34 KiB
Python
# -*- coding: utf-8 -*-
|
||
# input-remapper - GUI for device specific keyboard mappings
|
||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||
#
|
||
# 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,
|
||
List,
|
||
Any,
|
||
Tuple,
|
||
)
|
||
|
||
from evdev.ecodes import EV_KEY, EV_REL, EV_ABS
|
||
from gi.repository import Gtk, GLib
|
||
|
||
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||
from inputremapper.configs.mapping import (
|
||
MappingData,
|
||
UIMapping,
|
||
MappingType,
|
||
)
|
||
from inputremapper.configs.paths import PathUtils
|
||
from inputremapper.configs.validation_errors import (
|
||
pydantify,
|
||
MissingMacroOrKeyError,
|
||
MacroButTypeOrCodeSetError,
|
||
SymbolAndCodeMismatchError,
|
||
MissingOutputAxisError,
|
||
WrongMappingTypeForKeyError,
|
||
OutputSymbolVariantError,
|
||
)
|
||
from inputremapper.configs.app_binding import AppBinding
|
||
from inputremapper.exceptions import DataManagementError
|
||
from inputremapper.gui.components.output_type_names import OutputTypeNames
|
||
from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME
|
||
from inputremapper.gui.focus_service_client import ensure_focus_service_running
|
||
from inputremapper.gui.gettext import _
|
||
from inputremapper.gui.messages.message_broker import (
|
||
MessageBroker,
|
||
MessageType,
|
||
)
|
||
from inputremapper.gui.messages.message_data import (
|
||
PresetData,
|
||
StatusData,
|
||
CombinationRecorded,
|
||
UserConfirmRequest,
|
||
DoStackSwitch,
|
||
)
|
||
from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING
|
||
from inputremapper.injection.injector import (
|
||
InjectorState,
|
||
InjectorStateMessage,
|
||
)
|
||
from inputremapper.logging.logger import logger
|
||
|
||
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,
|
||
) -> None:
|
||
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):
|
||
"""Let the Controller know about the user interface singleton.."""
|
||
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._publish_mapping_errors_as_status_msg
|
||
)
|
||
self.message_broker.subscribe(
|
||
MessageType.mapping, self._publish_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 reader-service takes longer to provide the
|
||
# initial groups
|
||
self.data_manager.publish_groups()
|
||
self.data_manager.publish_uinputs()
|
||
self.data_manager.publish_app_bindings()
|
||
|
||
def _on_groups_changed(self, _):
|
||
"""Load the newest group as soon as everyone got notified
|
||
about the updated groups."""
|
||
|
||
if self.data_manager.active_group is not None:
|
||
# don't jump to a different group and preset suddenly, if the user
|
||
# is already looking at one
|
||
logger.debug("A group is already active")
|
||
return
|
||
|
||
group_key = self.get_a_group()
|
||
if group_key is None:
|
||
logger.debug("Could not find a group")
|
||
return
|
||
|
||
self.load_group(group_key)
|
||
|
||
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 mapping: (
|
||
mapping.format_name() or mapping.input_combination.beautify()
|
||
)
|
||
)
|
||
combination = mappings[0].input_combination
|
||
self.load_mapping(combination)
|
||
self.load_input_config(combination[0])
|
||
else:
|
||
# send an empty mapping to make sure the ui is reset to default values
|
||
self.message_broker.publish(MappingData(**MAPPING_DEFAULTS))
|
||
|
||
def _on_combination_recorded(self, data: CombinationRecorded):
|
||
combination = self._auto_use_as_analog(data.combination)
|
||
self.update_combination(combination)
|
||
|
||
def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]:
|
||
if not self.data_manager.active_preset:
|
||
return None
|
||
|
||
if self.data_manager.active_preset.is_valid():
|
||
self.message_broker.publish(StatusData(CTX_MAPPING))
|
||
return None
|
||
|
||
mappings = list(self.data_manager.active_preset)
|
||
|
||
# Move the selected (active) mapping to the front, so that it is checked first.
|
||
active_mapping = self.data_manager.active_mapping
|
||
if active_mapping is not None:
|
||
mappings.remove(active_mapping)
|
||
mappings.insert(0, active_mapping)
|
||
|
||
for mapping in mappings:
|
||
if not mapping.has_input_defined():
|
||
# Empty mapping, nothing recorded yet so nothing can be configured,
|
||
# therefore there isn't anything to validate.
|
||
continue
|
||
|
||
position = mapping.format_name()
|
||
error_strings = self._get_ui_error_strings(mapping)
|
||
|
||
if len(error_strings) == 0:
|
||
continue
|
||
|
||
if len(error_strings) > 1:
|
||
msg = _('%d Mapping errors at "%s", hover for info') % (
|
||
len(error_strings),
|
||
position,
|
||
)
|
||
tooltip = "– " + "\n– ".join(error_strings)
|
||
else:
|
||
msg = f'"{position}": {error_strings[0]}'
|
||
tooltip = error_strings[0]
|
||
|
||
return msg.replace("\n", " "), tooltip
|
||
|
||
return None
|
||
|
||
def _publish_mapping_errors_as_status_msg(self, *__) -> None:
|
||
"""Send mapping ValidationErrors to the MessageBroker."""
|
||
validation_result = self._format_status_bar_validation_errors()
|
||
|
||
if validation_result is None:
|
||
return
|
||
|
||
self.show_status(
|
||
CTX_MAPPING,
|
||
validation_result[0],
|
||
validation_result[1],
|
||
)
|
||
|
||
@staticmethod
|
||
def format_error_message(mapping, error_type, error_message: str) -> str:
|
||
"""Check all the different error messages which are not useful for the user."""
|
||
# There is no more elegant way of comparing error_type with the base class.
|
||
# https://github.com/pydantic/pydantic/discussions/5112
|
||
if (
|
||
pydantify(MacroButTypeOrCodeSetError) in error_type
|
||
or pydantify(SymbolAndCodeMismatchError) in error_type
|
||
) and mapping.input_combination.defines_analog_input:
|
||
return _(
|
||
"Remove the macro or key from the macro input field "
|
||
"when specifying an analog output"
|
||
)
|
||
|
||
if (
|
||
pydantify(MacroButTypeOrCodeSetError) in error_type
|
||
or pydantify(SymbolAndCodeMismatchError) in error_type
|
||
) and not mapping.input_combination.defines_analog_input:
|
||
return _(
|
||
"Remove the Analog Output Axis when specifying a macro or key output"
|
||
)
|
||
|
||
if pydantify(MissingOutputAxisError) in error_type:
|
||
error_message = _(
|
||
"The input specifies an analog axis, but no output axis is selected."
|
||
)
|
||
if mapping.output_symbol is not None:
|
||
event = [
|
||
event
|
||
for event in mapping.input_combination
|
||
if event.defines_analog_input
|
||
][0]
|
||
error_message += _(
|
||
"\nIf you mean to create a key or macro mapping "
|
||
"go to the advanced input configuration"
|
||
' and set a "Trigger Threshold" for '
|
||
f'"{event.description()}"'
|
||
)
|
||
return error_message
|
||
|
||
if pydantify(WrongMappingTypeForKeyError) in error_type:
|
||
error_message = _(
|
||
"The input specifies a key, but the output type is not "
|
||
f'"{OutputTypeNames.key_or_macro}".'
|
||
)
|
||
|
||
if mapping.output_type in (EV_ABS, EV_REL):
|
||
error_message += _(
|
||
"\nIf you mean to create an analog axis mapping go to the "
|
||
'advanced input configuration and set an input to "Use as Analog".'
|
||
)
|
||
|
||
return error_message
|
||
|
||
if pydantify(MissingMacroOrKeyError) in error_type:
|
||
return _("Missing macro or key")
|
||
|
||
return error_message
|
||
|
||
@staticmethod
|
||
def _get_ui_error_strings(mapping: UIMapping) -> List[str]:
|
||
"""Get a human readable error message from a mapping error."""
|
||
validation_error = mapping.get_error()
|
||
|
||
if validation_error is None:
|
||
return []
|
||
|
||
formatted_errors = []
|
||
|
||
for error in validation_error.errors():
|
||
if pydantify(OutputSymbolVariantError) in error["type"]:
|
||
# this is rather internal, when this error appears in the gui, there is
|
||
# also always another more readable error at the same time that explains
|
||
# this problem.
|
||
continue
|
||
|
||
error_string = f'"{mapping.format_name()}": '
|
||
error_message = error["msg"]
|
||
error_location = error["loc"][0]
|
||
if error_location != "__root__":
|
||
error_string += f"{error_location}: "
|
||
|
||
# check all the different error messages which are not useful for the user
|
||
formatted_errors.append(
|
||
Controller.format_error_message(
|
||
mapping,
|
||
error["type"],
|
||
error_message,
|
||
)
|
||
)
|
||
|
||
return formatted_errors
|
||
|
||
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(r" copy *\d*$", name)
|
||
if match:
|
||
name = name[: match.start()]
|
||
|
||
self.data_manager.copy_preset(
|
||
self.data_manager.get_available_preset_name(f"{name} copy")
|
||
)
|
||
self.message_broker.publish(DoStackSwitch(1))
|
||
|
||
def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination:
|
||
"""If output is analog, set the first fitting input to analog."""
|
||
if self.data_manager.active_mapping is None:
|
||
return combination
|
||
|
||
if not self.data_manager.active_mapping.is_analog_output():
|
||
return combination
|
||
|
||
if combination.find_analog_input_config():
|
||
# something is already set to do that
|
||
return combination
|
||
|
||
for i, input_config in enumerate(combination):
|
||
# find the first analog input and set it to "use as analog"
|
||
if input_config.type in (EV_ABS, EV_REL):
|
||
logger.info("Using %s as analog input", input_config)
|
||
|
||
# combinations and input_configs are immutable, a new combination
|
||
# is created to fit the needs instead
|
||
combination_list = list(combination)
|
||
combination_list[i] = input_config.modify(analog_threshold=0)
|
||
new_combination = InputCombination(combination_list)
|
||
return new_combination
|
||
|
||
return combination
|
||
|
||
def update_combination(self, combination: InputCombination):
|
||
"""Update the input_combination of the active mapping."""
|
||
combination = self._auto_use_as_analog(combination)
|
||
|
||
try:
|
||
self.data_manager.update_mapping(input_combination=combination)
|
||
self.save()
|
||
except KeyError:
|
||
self.show_status(
|
||
CTX_MAPPING,
|
||
f'"{combination.beautify()}" already mapped to something else',
|
||
)
|
||
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. Play around with the "
|
||
+ 'advanced "Release Input" toggle.'
|
||
),
|
||
)
|
||
|
||
def move_input_config_in_combination(
|
||
self,
|
||
input_config: InputConfig,
|
||
direction: Union[Literal["up"], Literal["down"]],
|
||
):
|
||
"""Move the active_input_config up or down in the input_combination of the
|
||
active_mapping."""
|
||
if (
|
||
not self.data_manager.active_mapping
|
||
or len(self.data_manager.active_mapping.input_combination) == 1
|
||
):
|
||
return
|
||
combination: Sequence[InputConfig] = (
|
||
self.data_manager.active_mapping.input_combination
|
||
)
|
||
|
||
i = combination.index(input_config)
|
||
if (
|
||
i + 1 == len(combination)
|
||
and direction == "down"
|
||
or i == 0
|
||
and direction == "up"
|
||
):
|
||
return
|
||
|
||
if direction == "up":
|
||
combination = (
|
||
list(combination[: i - 1])
|
||
+ [input_config]
|
||
+ [combination[i - 1]]
|
||
+ list(combination[i + 1 :])
|
||
)
|
||
elif direction == "down":
|
||
combination = (
|
||
list(combination[:i])
|
||
+ [combination[i + 1]]
|
||
+ [input_config]
|
||
+ list(combination[i + 2 :])
|
||
)
|
||
else:
|
||
raise ValueError(f"unknown direction: {direction}")
|
||
self.update_combination(InputCombination(combination))
|
||
self.load_input_config(input_config)
|
||
|
||
def load_input_config(self, input_config: InputConfig):
|
||
"""Load an InputConfig form the active mapping input combination."""
|
||
self.data_manager.load_input_config(input_config)
|
||
|
||
def update_input_config(self, new_input_config: InputConfig):
|
||
"""Modify the active input configuration."""
|
||
try:
|
||
self.data_manager.update_input_config(new_input_config)
|
||
except KeyError:
|
||
# we need to synchronize the gui
|
||
self.data_manager.publish_mapping()
|
||
self.data_manager.publish_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_input_config
|
||
):
|
||
return
|
||
|
||
combination = list(self.data_manager.active_mapping.input_combination)
|
||
combination.remove(self.data_manager.active_input_config)
|
||
try:
|
||
self.data_manager.update_mapping(
|
||
input_combination=InputCombination(combination)
|
||
)
|
||
self.load_input_config(combination[0])
|
||
self.save()
|
||
except (KeyError, ValueError):
|
||
# we need to synchronize the gui
|
||
self.data_manager.publish_mapping()
|
||
self.data_manager.publish_event()
|
||
|
||
def set_event_as_analog(self, analog: bool):
|
||
"""Use the active event as an analog input."""
|
||
assert self.data_manager.active_input_config is not None
|
||
event = self.data_manager.active_input_config
|
||
|
||
if event.type != EV_KEY:
|
||
if analog:
|
||
try:
|
||
self.data_manager.update_input_config(
|
||
event.modify(analog_threshold=0)
|
||
)
|
||
self.save()
|
||
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_input_config(
|
||
event.modify(analog_threshold=value)
|
||
)
|
||
self.save()
|
||
return
|
||
except KeyError:
|
||
pass
|
||
|
||
# didn't update successfully
|
||
# we need to synchronize the gui
|
||
self.data_manager.publish_mapping()
|
||
self.data_manager.publish_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
|
||
|
||
new_name = PathUtils.sanitize_path_component(new_name)
|
||
new_name = self.data_manager.get_available_preset_name(new_name)
|
||
self.data_manager.rename_preset(new_name)
|
||
|
||
def add_preset(self, name: str = DEFAULT_PRESET_NAME):
|
||
"""Create a new preset called `new preset n`, add it to the active_group."""
|
||
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())
|
||
self.message_broker.publish(DoStackSwitch(1))
|
||
|
||
if not self.data_manager.active_preset:
|
||
return
|
||
msg = (
|
||
_('Are you sure you want to delete the preset "%s"?')
|
||
% self.data_manager.active_preset.name
|
||
)
|
||
self.message_broker.publish(UserConfirmRequest(msg, f))
|
||
|
||
def load_mapping(self, input_combination: InputCombination):
|
||
"""Load the mapping with the given input_combination form the active_preset."""
|
||
self.data_manager.load_mapping(input_combination)
|
||
self.load_input_config(input_combination[0])
|
||
|
||
def update_mapping(self, **changes):
|
||
"""Update the active_mapping with the given keywords and values."""
|
||
if "mapping_type" in changes.keys():
|
||
if not (changes := self._change_mapping_type(changes)):
|
||
# we need to synchronize the gui
|
||
self.data_manager.publish_mapping()
|
||
self.data_manager.publish_event()
|
||
return
|
||
|
||
self.data_manager.update_mapping(**changes)
|
||
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=InputCombination.empty_combination())
|
||
self.data_manager.update_mapping(**MAPPING_DEFAULTS)
|
||
|
||
def delete_mapping(self):
|
||
"""Remove the active_mapping form the active_preset."""
|
||
|
||
def get_answer(answer: bool):
|
||
if answer:
|
||
self.data_manager.delete_mapping()
|
||
self.save()
|
||
|
||
if not self.data_manager.active_mapping:
|
||
return
|
||
self.message_broker.publish(
|
||
UserConfirmRequest(
|
||
_("Are you sure you want to delete this mapping?"),
|
||
get_answer,
|
||
)
|
||
)
|
||
|
||
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 load_app_bindings(self):
|
||
"""(Re)publish the current application bindings and service state."""
|
||
self.data_manager.publish_app_bindings()
|
||
|
||
def set_app_binding_enabled(self, enabled: bool):
|
||
"""Enable or disable focus-driven preset binding."""
|
||
if enabled:
|
||
# make sure the user-level focus-service is running, mirroring how
|
||
# the GUI launches the reader-service.
|
||
ensure_focus_service_running()
|
||
|
||
self.data_manager.set_app_binding_enabled(enabled)
|
||
|
||
if enabled:
|
||
# the service might need a moment to come up and detect its backend;
|
||
# refresh the status once it had time to settle.
|
||
GLib.timeout_add(1500, self._refresh_app_bindings_once)
|
||
|
||
def _refresh_app_bindings_once(self) -> bool:
|
||
self.data_manager.publish_app_bindings()
|
||
return False # GLib: do not repeat
|
||
|
||
def update_app_bindings(self, bindings: List[AppBinding]):
|
||
"""Persist the given application bindings."""
|
||
try:
|
||
self.data_manager.set_app_bindings(bindings)
|
||
except PermissionError as e:
|
||
self.show_status(CTX_ERROR, _("Permission denied!"), str(e))
|
||
|
||
def get_presets_for_group(self, group_key: str) -> Tuple[str, ...]:
|
||
"""List the presets available for an arbitrary device group."""
|
||
return self.data_manager.get_presets_for_group(group_key)
|
||
|
||
def start_app_detection(self):
|
||
"""Start listening for focus changes to auto-fill an app_id."""
|
||
self.data_manager.start_app_detection()
|
||
|
||
def stop_app_detection(self):
|
||
"""Stop listening for focus changes."""
|
||
self.data_manager.stop_app_detection()
|
||
|
||
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):
|
||
"""Record the input of the active_group
|
||
|
||
Updates the active_mapping.input_combination with the recorded events.
|
||
"""
|
||
state = self.data_manager.get_state()
|
||
if state == InjectorState.RUNNING or state == InjectorState.STARTING:
|
||
self.data_manager.stop_combination_recording()
|
||
self.message_broker.signal(MessageType.recording_finished)
|
||
self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing'))
|
||
return
|
||
|
||
logger.debug("Recording Keys")
|
||
|
||
def on_recording_finished(_):
|
||
self.message_broker.unsubscribe(on_recording_finished)
|
||
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, on_recording_finished
|
||
)
|
||
self.data_manager.start_combination_recording()
|
||
|
||
def stop_key_recording(self):
|
||
"""Stop recording the input."""
|
||
logger.debug("Stopping Recording Keys")
|
||
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 mappings 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: InjectorStateMessage) -> None:
|
||
"""Show if the injection was successfully started."""
|
||
self.message_broker.unsubscribe(self.show_injector_result)
|
||
state = msg.state
|
||
|
||
def running() -> None:
|
||
assert self.data_manager.active_preset is not None
|
||
msg = _('Applied preset "%s"') % self.data_manager.active_preset.name
|
||
if self.data_manager.active_preset.dangerously_mapped_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
|
||
)
|
||
|
||
def no_grab() -> None:
|
||
assert self.data_manager.active_preset is not None
|
||
msg = (
|
||
_('Failed to apply preset "%s"') % self.data_manager.active_preset.name
|
||
)
|
||
tooltip = (
|
||
"Maybe your preset doesn't contain anything that is sent by the "
|
||
"device or another device is already grabbing it"
|
||
)
|
||
|
||
# InjectorState.NO_GRAB also happens when all mappings have validation
|
||
# errors. In that case, we can show something more useful.
|
||
validation_result = self._format_status_bar_validation_errors()
|
||
if validation_result is not None:
|
||
msg = f"{msg}. {validation_result[0]}"
|
||
tooltip = validation_result[1]
|
||
|
||
self.show_status(CTX_ERROR, msg, tooltip)
|
||
|
||
assert self.data_manager.active_preset # make mypy happy
|
||
state_calls: Dict[InjectorState, Callable] = {
|
||
InjectorState.RUNNING: running,
|
||
InjectorState.ERROR: partial(
|
||
self.show_status,
|
||
CTX_ERROR,
|
||
_('Error applying preset "%s"') % self.data_manager.active_preset.name,
|
||
),
|
||
InjectorState.NO_GRAB: no_grab,
|
||
InjectorState.UPGRADE_EVDEV: partial(
|
||
self.show_status,
|
||
CTX_ERROR,
|
||
"Upgrade python-evdev",
|
||
"Your python-evdev version is too old.",
|
||
),
|
||
}
|
||
|
||
if state in state_calls:
|
||
state_calls[state]()
|
||
|
||
def stop_injecting(self):
|
||
"""Stop injecting any preset for the active_group."""
|
||
|
||
def show_result(msg: InjectorStateMessage):
|
||
self.message_broker.unsubscribe(show_result)
|
||
|
||
if not msg.inactive():
|
||
# some speculation: there might be unexpected additional status messages
|
||
# with a different state, or the status is wrong because something in
|
||
# the long pipeline of status messages is broken.
|
||
logger.error(
|
||
"Expected the injection to eventually stop, but got state %s",
|
||
msg.state,
|
||
)
|
||
return
|
||
|
||
self.show_status(CTX_APPLY, _("Stopped the injection"))
|
||
|
||
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.publish(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)
|
||
|
||
def _change_mapping_type(self, changes: Dict[str, Any]):
|
||
"""Query the user to update the mapping in order to change the mapping type."""
|
||
mapping = self.data_manager.active_mapping
|
||
|
||
if mapping is None:
|
||
return changes
|
||
|
||
if changes["mapping_type"] == mapping.mapping_type:
|
||
return changes
|
||
|
||
if changes["mapping_type"] == MappingType.ANALOG.value:
|
||
msg = _("You are about to change the mapping to analog.")
|
||
if mapping.output_symbol:
|
||
msg += _('\nThis will remove "{}" ' "from the text input!").format(
|
||
mapping.output_symbol
|
||
)
|
||
|
||
if not [
|
||
input_config
|
||
for input_config in mapping.input_combination
|
||
if input_config.defines_analog_input
|
||
]:
|
||
# there is no analog input configured, let's try to autoconfigure it
|
||
inputs: List[InputConfig] = list(mapping.input_combination)
|
||
for i, input_config in enumerate(inputs):
|
||
if input_config.type in [EV_ABS, EV_REL]:
|
||
inputs[i] = input_config.modify(analog_threshold=0)
|
||
changes["input_combination"] = InputCombination(inputs)
|
||
msg += _(
|
||
'\nThe input "{}" will be used as analog input.'
|
||
).format(input_config.description())
|
||
break
|
||
else:
|
||
# not possible to autoconfigure inform the user
|
||
msg += _("\nYou need to record an analog input.")
|
||
|
||
elif not mapping.output_symbol:
|
||
return changes
|
||
|
||
answer = None
|
||
|
||
def get_answer(answer_: bool):
|
||
nonlocal answer
|
||
answer = answer_
|
||
|
||
self.message_broker.publish(UserConfirmRequest(msg, get_answer))
|
||
if answer:
|
||
changes["output_symbol"] = None
|
||
return changes
|
||
else:
|
||
return None
|
||
|
||
if changes["mapping_type"] == MappingType.KEY_MACRO.value:
|
||
try:
|
||
analog_input = tuple(
|
||
filter(lambda i: i.defines_analog_input, mapping.input_combination)
|
||
)[0]
|
||
except IndexError:
|
||
changes["output_type"] = None
|
||
changes["output_code"] = None
|
||
return changes
|
||
|
||
answer = None
|
||
|
||
def get_answer(answer_: bool):
|
||
nonlocal answer
|
||
answer = answer_
|
||
|
||
self.message_broker.publish(
|
||
UserConfirmRequest(
|
||
f"You are about to change the mapping to a Key or Macro mapping!\n"
|
||
f"Go to the advanced input configuration and set a "
|
||
f'"Trigger Threshold" for "{analog_input.description()}".',
|
||
get_answer,
|
||
)
|
||
)
|
||
if answer:
|
||
changes["output_type"] = None
|
||
changes["output_code"] = None
|
||
return changes
|
||
else:
|
||
return None
|
||
|
||
return changes
|