Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
877 lines
32 KiB
Python
877 lines
32 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
|
||
|
||
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.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.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()
|
||
|
||
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 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
|