diff --git a/data/input-remapper.glade b/data/input-remapper.glade index 00a6daee..12dd7d21 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -419,6 +419,7 @@ Shortcut: ctrl + del True True + Activate this to load the preset next time the device connects, or when the user logs in start center @@ -850,13 +851,13 @@ Shortcut: ctrl + del True False + Available output axes are affected by the Target setting. 12 100 True False - Available output axes are affected by the Target setting. 0.5 Output axis 1 diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py index 5fb06338..afd9556b 100644 --- a/inputremapper/configs/mapping.py +++ b/inputremapper/configs/mapping.py @@ -20,9 +20,9 @@ from __future__ import annotations import enum -from typing import Optional, Callable, Tuple, TypeVar, Literal, Union, Any, Dict +from collections import namedtuple +from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict -import evdev import pkg_resources from evdev.ecodes import ( EV_KEY, @@ -48,9 +48,21 @@ from pydantic import ( from inputremapper.configs.input_config import InputCombination from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME -from inputremapper.exceptions import MacroParsingError +from inputremapper.configs.validation_errors import ( + OutputSymbolUnknownError, + SymbolNotAvailableInTargetError, + OnlyOneAnalogInputError, + TriggerPointInRangeError, + OutputSymbolVariantError, + MacroButTypeOrCodeSetError, + SymbolAndCodeMismatchError, + MissingMacroOrKeyError, + MissingOutputAxisError, + MacroParsingError, +) from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_types import MessageType +from inputremapper.injection.global_uinputs import can_default_uinput_emit from inputremapper.injection.macros.parse import is_this_a_macro, parse from inputremapper.utils import get_evdev_constant_name @@ -337,28 +349,39 @@ class Mapping(UIMapping): """If the mapping is valid.""" return True - @validator("output_symbol", pre=True) - def validate_symbol(cls, symbol): + @root_validator(pre=True) + def validate_symbol(cls, values): """Parse a macro to check for syntax errors.""" - if not symbol: - return None + symbol = values.get("output_symbol") + + if symbol == "": + values["output_symbol"] = None + return values + + if symbol is None: + return values symbol = symbol.strip() + values["output_symbol"] = symbol + + if symbol == DISABLE_NAME: + return values if is_this_a_macro(symbol): - try: - parse(symbol, verbose=False) # raises MacroParsingError - return symbol - except MacroParsingError as exception: - # pydantic only catches ValueError, TypeError, and AssertionError - raise ValueError(exception) from exception - - if system_mapping.get(symbol) is not None: - return symbol - - raise ValueError( - f'The output_symbol "{symbol}" is not a macro and not a valid keycode-name' - ) + mapping_mock = namedtuple("Mapping", values.keys())(**values) + # raises MacroParsingError + parse(symbol, mapping=mapping_mock, verbose=False) + return values + + code = system_mapping.get(symbol) + if code is None: + raise OutputSymbolUnknownError(symbol) + + target = values.get("target_uinput") + if target is not None and not can_default_uinput_emit(target, EV_KEY, code): + raise SymbolNotAvailableInTargetError(symbol, target) + + return values @validator("input_combination") def only_one_analog_input(cls, combination) -> InputCombination: @@ -367,10 +390,7 @@ class Mapping(UIMapping): """ analog_events = [event for event in combination if event.defines_analog_input] if len(analog_events) > 1: - raise ValueError( - f"Cannot map a combination of multiple analog inputs: {analog_events}" - "add trigger points (event.value != 0) to map as a button" - ) + raise OnlyOneAnalogInputError(analog_events) return combination @@ -383,10 +403,7 @@ class Mapping(UIMapping): and input_config.analog_threshold and abs(input_config.analog_threshold) >= 100 ): - raise ValueError( - f"{input_config = } maps an absolute axis to a button, but the trigger " - "point (event.analog_threshold) is not between -100[%] and 100[%]" - ) + raise TriggerPointInRangeError(input_config) return combination @root_validator @@ -396,10 +413,7 @@ class Mapping(UIMapping): o_type = values.get("output_type") o_code = values.get("output_code") if o_symbol is None and (o_type is None or o_code is None): - raise ValueError( - "Missing Argument: Mapping must either contain " - "`output_symbol` or `output_type` and `output_code`" - ) + raise OutputSymbolVariantError() return values @root_validator @@ -409,24 +423,21 @@ class Mapping(UIMapping): type_ = values.get("output_type") code = values.get("output_code") if symbol is None: - return values # type and code can be anything + # If symbol is "", then validate_symbol changes it to None + # type and code can be anything + return values if type_ is None and code is None: - return values # we have a symbol: no type and code is fine + # we have a symbol: no type and code is fine + return values - if is_this_a_macro(symbol): # disallow output type and code for macros + if is_this_a_macro(symbol): + # disallow output type and code for macros if type_ is not None or code is not None: - raise ValueError( - "output_symbol is a macro: output_type " - "and output_code must be None" - ) + raise MacroButTypeOrCodeSetError() if code is not None and code != system_mapping.get(symbol) or type_ != EV_KEY: - raise ValueError( - "output_symbol and output_code mismatch: " - f"output macro is {symbol} --> {system_mapping.get(symbol)} " - f"but output_code is {code} --> {system_mapping.get_name(code)} " - ) + raise SymbolAndCodeMismatchError(symbol, code) return values @root_validator @@ -435,28 +446,21 @@ class Mapping(UIMapping): And vice versa.""" assert isinstance(values.get("input_combination"), InputCombination) combination: InputCombination = values["input_combination"] - use_as_analog = True in [event.defines_analog_input for event in combination] + analog_input_config = combination.find_analog_input_config() + use_as_analog = analog_input_config is not None output_type = values.get("output_type") output_symbol = values.get("output_symbol") if not use_as_analog and not output_symbol and output_type != EV_KEY: - raise ValueError( - "missing macro or key: " - f'"{str(combination)}" is not used as analog input, ' - f"but no output macro or key is programmed" - ) + raise MissingMacroOrKeyError() if ( use_as_analog and output_type not in (EV_ABS, EV_REL) and output_symbol != DISABLE_NAME ): - raise ValueError( - "Missing output axis: " - f'"{str(combination)}" is used as analog input, ' - f"but the {output_type = } is not an axis " - ) + raise MissingOutputAxisError(analog_input_config, output_type) return values diff --git a/inputremapper/configs/validation_errors.py b/inputremapper/configs/validation_errors.py new file mode 100644 index 00000000..a891b0d3 --- /dev/null +++ b/inputremapper/configs/validation_errors.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# 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 . + +"""Exceptions that are thrown when configurations are incorrect.""" + +# can't merge this with exceptions.py, because I want error constructors here to +# be intelligent to avoid redundant code, and they need imports, which would cause +# circular imports. + +# pydantic only catches ValueError, TypeError, and AssertionError + +from __future__ import annotations + +from typing import Optional + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.system_mapping import system_mapping +from inputremapper.injection.global_uinputs import find_fitting_default_uinputs + + +class OutputSymbolVariantError(ValueError): + def __init__(self): + super().__init__( + "Missing Argument: Mapping must either contain " + "`output_symbol` or `output_type` and `output_code`" + ) + + +class TriggerPointInRangeError(ValueError): + def __init__(self, input_config): + super().__init__( + f"{input_config = } maps an absolute axis to a button, but the " + "trigger point (event.analog_threshold) is not between -100[%] " + "and 100[%]" + ) + + +class OnlyOneAnalogInputError(ValueError): + def __init__(self, analog_events): + super().__init__( + f"Cannot map a combination of multiple analog inputs: {analog_events}" + "add trigger points (event.value != 0) to map as a button" + ) + + +class SymbolNotAvailableInTargetError(ValueError): + def __init__(self, symbol, target): + code = system_mapping.get(symbol) + + fitting_targets = find_fitting_default_uinputs(EV_KEY, code) + fitting_targets_string = '", "'.join(fitting_targets) + + super().__init__( + f'The output_symbol "{symbol}" is not available for the "{target}" ' + + f'target. Try "{fitting_targets_string}".' + ) + + +class OutputSymbolUnknownError(ValueError): + def __init__(self, symbol: str): + super().__init__( + f'The output_symbol "{symbol}" is not a macro and not a valid ' + + "keycode-name" + ) + + +class MacroButTypeOrCodeSetError(ValueError): + def __init__(self): + super().__init__( + "output_symbol is a macro: output_type " "and output_code must be None" + ) + + +class SymbolAndCodeMismatchError(ValueError): + def __init__(self, symbol, code): + super().__init__( + "output_symbol and output_code mismatch: " + f"output macro is {symbol} -> {system_mapping.get(symbol)} " + f"but output_code is {code} -> {system_mapping.get_name(code)} " + ) + + +class MissingMacroOrKeyError(ValueError): + def __init__(self): + super().__init__("missing macro or key") + + +class MissingOutputAxisError(ValueError): + def __init__(self, analog_input_config, output_type): + super().__init__( + "Missing output axis: " + f'"{analog_input_config}" is used as analog input, ' + f"but the {output_type = } is not an axis " + ) + + +class MacroParsingError(ValueError): + """Macro syntax errors.""" + + def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"): + self.symbol = symbol + super().__init__(msg) + + +def pydantify(error: type): + """Generate a string as it would appear IN pydantic error types. + + This does not include the base class name, which is transformed to snake case in + pydantic. Example pydantic error type: "value_error.foobar" for FooBarError. + """ + # See https://github.com/pydantic/pydantic/discussions/5112 + lower_classname = error.__name__.lower() + if lower_classname.endswith("error"): + return lower_classname[: -len("error")] + return lower_classname diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py index be3060e9..487ae07c 100644 --- a/inputremapper/exceptions.py +++ b/inputremapper/exceptions.py @@ -20,8 +20,6 @@ """Exceptions specific to inputremapper.""" -from typing import Optional - class Error(Exception): """Base class for exceptions in inputremapper. @@ -44,14 +42,6 @@ class EventNotHandled(Error): super().__init__(f"Event {event} can not be handled by the configured target") -class MacroParsingError(Error): - """Macro syntax errors.""" - - def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"): - self.symbol = symbol - super().__init__(msg) - - class MappingParsingError(Error): """Anything that goes wrong during the creation of handlers from the mapping.""" diff --git a/inputremapper/gui/components/main.py b/inputremapper/gui/components/main.py index 9fdca274..510d1cae 100644 --- a/inputremapper/gui/components/main.py +++ b/inputremapper/gui/components/main.py @@ -24,9 +24,7 @@ from __future__ import annotations import gi - - -from gi.repository import Gtk +from gi.repository import Gtk, Pango from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( @@ -79,18 +77,17 @@ class StatusBar: self._error_icon = error_icon self._warning_icon = warning_icon + label = self._gui.get_message_area().get_children()[0] + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_selectable(True) + self._message_broker.subscribe(MessageType.status_msg, self._on_status_update) - self._message_broker.subscribe(MessageType.init, self._on_init) # keep track if there is an error or warning in the stack of statusbar # unfortunately this is not exposed over the api self._error = False self._warning = False - def _on_init(self, _): - self._error_icon.hide() - self._warning_icon.hide() - def _on_status_update(self, data: StatusData): """Show a status message and set its tooltip. @@ -118,24 +115,21 @@ class StatusBar: self._error_icon.show() status_bar.set_tooltip_text("") - else: - if tooltip is None: - tooltip = message + return - self._error_icon.hide() - self._warning_icon.hide() + if tooltip is None: + tooltip = message - if context_id in (CTX_ERROR, CTX_MAPPING): - self._error_icon.show() - self._error = True + self._error_icon.hide() + self._warning_icon.hide() - if context_id == CTX_WARNING: - self._warning_icon.show() - self._warning = True + if context_id in (CTX_ERROR, CTX_MAPPING): + self._error_icon.show() + self._error = True - max_length = 135 - if len(message) > max_length: - message = message[: max_length - 3] + "..." + if context_id == CTX_WARNING: + self._warning_icon.show() + self._warning = True - status_bar.push(context_id, message) - status_bar.set_tooltip_text(tooltip) + status_bar.push(context_id, message) + status_bar.set_tooltip_text(tooltip) diff --git a/inputremapper/gui/controller.py b/inputremapper/gui/controller.py index f53fccf4..947eaaa6 100644 --- a/inputremapper/gui/controller.py +++ b/inputremapper/gui/controller.py @@ -38,9 +38,18 @@ from evdev.ecodes import EV_KEY, EV_REL, EV_ABS from gi.repository import Gtk -from inputremapper.configs.mapping import MappingData, UIMapping +from inputremapper.configs.mapping import ( + MappingData, + UIMapping, + MacroButTypeOrCodeSetError, + SymbolAndCodeMismatchError, + MissingOutputAxisError, + MissingMacroOrKeyError, + OutputSymbolVariantError, +) from inputremapper.configs.paths import sanitize_path_component from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.validation_errors import pydantify from inputremapper.exceptions import DataManagementError from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME from inputremapper.gui.gettext import _ @@ -145,6 +154,7 @@ class Controller: """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.publish(StatusData(CTX_MAPPING)) return @@ -154,18 +164,36 @@ class Controller: continue position = mapping.format_name() - msg = _("Mapping error at %s, hover for info") % position - self.show_status(CTX_MAPPING, msg, self._get_ui_error_string(mapping)) + error_strings = self._get_ui_error_strings(mapping) + tooltip = "" + if len(error_strings) == 0: + # shouldn't be possible to get to this point + logger.error("Expected an error") + return + elif 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] - @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()) + self.show_status( + CTX_MAPPING, + msg.replace("\n", " "), + tooltip, + ) - # check all the different error messages which are not useful for the user + @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 ( - "output_symbol is a macro:" in error_string - or "output_symbol and output_code mismatch:" in error_string + 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 " @@ -173,15 +201,15 @@ class Controller: ) if ( - "output_symbol is a macro:" in error_string - or "output_symbol and output_code mismatch:" in error_string + 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 "missing output axis:" in error_string: - message = _( + 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: @@ -190,27 +218,64 @@ class Controller: for event in mapping.input_combination if event.defines_analog_input ][0] - message += _( + 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 message + return error_message - if "missing macro or key:" in error_string and mapping.output_symbol is None: - message = _( + if ( + pydantify(MissingMacroOrKeyError) in error_type + and mapping.output_symbol is None + ): + error_message = _( "The input specifies a key or macro input, but no macro or key is " "programmed." ) if mapping.output_type in (EV_ABS, EV_REL): - message += _( + 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 message + return error_message + + 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 error_string + return formatted_errors def get_a_preset(self) -> str: """Attempts to get the newest preset in the current group @@ -611,9 +676,9 @@ class Controller: self.show_status, CTX_ERROR, "The device was not grabbed", - "Either another application is already grabbing it or " + "Either another application is already grabbing it, " "your preset doesn't contain anything that is sent by the " - "device.", + "device or your preset contains errors", ), InjectorState.UPGRADE_EVDEV: partial( self.show_status, diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index a6d8dc4c..7022acd2 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from typing import Dict, Union, Tuple, Optional +from typing import Dict, Union, Tuple, Optional, List import evdev @@ -59,6 +59,21 @@ DEFAULT_UINPUTS["keyboard + mouse"] = { } +def can_default_uinput_emit(target: str, type_: int, code: int) -> bool: + """Check if the uinput with the target name is capable of the event.""" + capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_) + return capabilities is not None and code in capabilities + + +def find_fitting_default_uinputs(type_: int, code: int) -> List[str]: + """Find the names of default uinputs that are able to emit this event.""" + return [ + uinput + for uinput in DEFAULT_UINPUTS + if code in DEFAULT_UINPUTS[uinput].get(type_, []) + ] + + class UInput(evdev.UInput): def __init__(self, *args, **kwargs): name = kwargs["name"] diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index 093b27bc..5e8ad378 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -55,7 +55,11 @@ from evdev.ecodes import ( ) from inputremapper.configs.system_mapping import system_mapping -from inputremapper.exceptions import MacroParsingError +from inputremapper.configs.validation_errors import ( + SymbolNotAvailableInTargetError, + MacroParsingError, +) +from inputremapper.injection.global_uinputs import can_default_uinput_emit from inputremapper.ipc.shared_dict import SharedDict from inputremapper.logger import logger @@ -125,21 +129,6 @@ def _type_check(value: Any, allowed_types, display_name=None, position=None) -> ) -def _type_check_symbol(keyname: Union[str, Variable]) -> int: - """Same as _type_check, but checks if the key-name is valid.""" - if isinstance(keyname, Variable): - # it is a variable and will be read at runtime - return keyname - - symbol = str(keyname) - code = system_mapping.get(symbol) - - if code is None: - raise MacroParsingError(msg=f'Unknown key "{symbol}"') - - return code - - def _type_check_variablename(name: str): """Check if this is a legit variable name. @@ -216,6 +205,8 @@ class Macro: self.context = context self.mapping = mapping + # TODO check if mapping is ever none by throwing an error + # List of coroutines that will be called sequentially. # This is the compiled code self.tasks: List[MacroTask] = [] @@ -317,12 +308,12 @@ class Macro: """Write the symbol.""" # This is done to figure out if the macro is broken at compile time, because # if KEY_A was unknown we can show this in the gui before the injection starts. - _type_check_symbol(symbol) + self._type_check_symbol(symbol) async def task(handler: Callable): # if the code is $foo, figure out the correct code now. resolved_symbol = _resolve(symbol, [str]) - code = _type_check_symbol(resolved_symbol) + code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) @@ -334,11 +325,11 @@ class Macro: def add_key_down(self, symbol: str): """Press the symbol.""" - _type_check_symbol(symbol) + self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) - code = _type_check_symbol(resolved_symbol) + code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) @@ -347,11 +338,11 @@ class Macro: def add_key_up(self, symbol: str): """Release the symbol.""" - _type_check_symbol(symbol) + self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) - code = _type_check_symbol(resolved_symbol) + code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 0) @@ -370,11 +361,11 @@ class Macro: # if macro is a key name, hold down the key while the # keyboard key is physically held down symbol = macro - _type_check_symbol(symbol) + self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) - code = _type_check_symbol(resolved_symbol) + code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) @@ -405,14 +396,14 @@ class Macro: macro """ _type_check(macro, [Macro], "modify", 2) - _type_check_symbol(modifier) + self._type_check_symbol(modifier) self.child_macros.append(macro) async def task(handler: Callable): # TODO test var resolved_modifier = _resolve(modifier, [str]) - code = _type_check_symbol(resolved_modifier) + code = self._type_check_symbol(resolved_modifier) handler(EV_KEY, code, 1) await self._keycode_pause() @@ -425,11 +416,11 @@ class Macro: def add_hold_keys(self, *symbols): """Hold down multiple keys, equivalent to `a + b + c + ...`.""" for symbol in symbols: - _type_check_symbol(symbol) + self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbols = [_resolve(symbol, [str]) for symbol in symbols] - codes = [_type_check_symbol(symbol) for symbol in resolved_symbols] + codes = [self._type_check_symbol(symbol) for symbol in resolved_symbols] for code in codes: handler(EV_KEY, code, 1) @@ -698,3 +689,22 @@ class Macro: await else_.run(handler) self.tasks.append(task) + + def _type_check_symbol(self, keyname: Union[str, Variable]) -> Union[Variable, int]: + """Same as _type_check, but checks if the key-name is valid.""" + if isinstance(keyname, Variable): + # it is a variable and will be read at runtime + return keyname + + symbol = str(keyname) + code = system_mapping.get(symbol) + + if code is None: + raise MacroParsingError(msg=f'Unknown key "{symbol}"') + + if self.mapping is not None: + target = self.mapping.target_uinput + if target is not None and not can_default_uinput_emit(target, EV_KEY, code): + raise SymbolNotAvailableInTargetError(symbol, target) + + return code diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index 48642756..92ad79ce 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -25,7 +25,7 @@ import inspect import re from typing import Optional, Any -from inputremapper.exceptions import MacroParsingError +from inputremapper.configs.validation_errors import MacroParsingError from inputremapper.injection.macros.macro import Macro, Variable from inputremapper.logger import logger @@ -452,6 +452,7 @@ def parse(macro: str, context=None, mapping=None, verbose: bool = True): verbose log the parsing True by default """ + # TODO pass mapping in frontend and do the target check for keys? logger.debug("parsing macro %s", macro.replace("\n", "")) macro = clean(macro) macro = handle_plus_syntax(macro) diff --git a/readme/coverage.svg b/readme/coverage.svg index 2132964c..705b8c65 100644 --- a/readme/coverage.svg +++ b/readme/coverage.svg @@ -17,7 +17,7 @@ coverage - 92% - 92% + 93% + 93% diff --git a/readme/development.md b/readme/development.md index 2c5f29c8..4a195490 100644 --- a/readme/development.md +++ b/readme/development.md @@ -87,6 +87,9 @@ sudo pip install . New badges, if needed, will be created in `readme/` and they just need to be commited. +Beware that coverage can suffer if old files reside in your python path. Remove the build folder +and reinstall it. + ## Translations To regenerate the `po/input-remapper.pot` file, run diff --git a/readme/pylint.svg b/readme/pylint.svg index 26889e45..ecb710ca 100644 --- a/readme/pylint.svg +++ b/readme/pylint.svg @@ -17,7 +17,7 @@ pylint - 8.88 - 8.88 + 8.87 + 8.87 diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index 05384773..b176cf57 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -905,8 +905,8 @@ class TestGui(GuiTestBase): InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), ) - # 4. update target to mouse - self.target_selection.set_active_id("mouse") + # 4. update target + self.target_selection.set_active_id("keyboard + mouse") gtk_iteration() self.assertEqual( self.data_manager.active_mapping, @@ -915,20 +915,14 @@ class TestGui(GuiTestBase): [InputConfig(type=1, code=30, origin_hash=origin)] ), output_symbol="Shift_L", - target_uinput="mouse", + target_uinput="keyboard + mouse", ), ) def test_show_status(self): - self.message_broker.publish(StatusData(0, "a" * 500)) - gtk_iteration() - text = self.get_status_text() - self.assertIn("...", text) - - self.message_broker.publish(StatusData(0, "b")) - gtk_iteration() + self.message_broker.publish(StatusData(0, "a")) text = self.get_status_text() - self.assertNotIn("...", text) + self.assertEqual("a", text) def test_hat_switch(self): # load a device with more capabilities diff --git a/tests/unit/test_macros.py b/tests/unit/test_macros.py index cb43f25a..fd88df42 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -42,7 +42,10 @@ from evdev.ecodes import ( from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping -from inputremapper.exceptions import MacroParsingError +from inputremapper.configs.validation_errors import ( + MacroParsingError, + SymbolNotAvailableInTargetError, +) from inputremapper.injection.context import Context from inputremapper.injection.macros.macro import ( Macro, @@ -117,6 +120,7 @@ class MacroTestBase(unittest.IsolatedAsyncioTestCase): class DummyMapping: macro_key_sleep_ms = 10 rel_rate = 60 + target_uinput = "keyboard + mouse" class TestMacros(MacroTestBase): @@ -520,7 +524,7 @@ class TestMacros(MacroTestBase): self.assertRaises(MacroParsingError, parse, "wait(a)", self.context) parse("ifeq(a, 2, k(a),)", self.context) # no error parse("ifeq(a, 2, , k(a))", self.context) # no error - parse("ifeq(a, 2, None, k(a))", self.context, True) # no error + parse("ifeq(a, 2, None, k(a))", self.context) # no error self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, 1,)", self.context) self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, , 2)", self.context) parse("if_eq(2, $a, k(a),)", self.context) # no error @@ -547,6 +551,15 @@ class TestMacros(MacroTestBase): parse("add(a, 1)", self.context) # no error self.assertRaises(MacroParsingError, parse, "add(a, b)", self.context) + # wrong target for BTN_A + self.assertRaises( + SymbolNotAvailableInTargetError, + parse, + "key(BTN_A)", + self.context, + DummyMapping, + ) + async def test_key(self): code_a = system_mapping.get("a") code_b = system_mapping.get("b") diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index cdd674b9..aa2adbbf 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -28,6 +28,7 @@ from evdev.ecodes import ( REL_Y, REL_WHEEL, REL_WHEEL_HI_RES, + KEY_1, ) from pydantic import ValidationError @@ -366,63 +367,103 @@ class TestMapping(unittest.IsolatedAsyncioTestCase): m = Mapping(**cfg) self.assertTrue(m.is_valid()) + def test_wrong_target(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="a", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "BTN_A", + ) + + def test_wrong_target_for_macro(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="key(a)", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "key(BTN_A)", + ) + class TestUIMapping(unittest.IsolatedAsyncioTestCase): def test_init(self): - """should be able to initialize without an error""" + """Should be able to initialize without throwing errors.""" UIMapping() def test_is_valid(self): - """should be invalid at first - and become valid once all data is provided""" - m = UIMapping() - self.assertFalse(m.is_valid()) + """Should be invalid at first and become valid once all data is provided.""" + mapping = UIMapping() + self.assertFalse(mapping.is_valid()) - m.input_combination = [{"type": 1, "code": 2}] - m.output_symbol = "a" - self.assertFalse(m.is_valid()) - m.target_uinput = "keyboard" - self.assertTrue(m.is_valid()) + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" + self.assertFalse(mapping.is_valid()) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) def test_updates_validation_error(self): - m = UIMapping() - self.assertGreaterEqual(len(m.get_error().errors()), 2) - m.input_combination = [{"type": 1, "code": 2}] - m.output_symbol = "a" + mapping = UIMapping() + self.assertGreaterEqual(len(mapping.get_error().errors()), 2) + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" self.assertIn( - "1 validation error for Mapping\ntarget_uinput", str(m.get_error()) + "1 validation error for Mapping\ntarget_uinput", + str(mapping.get_error()), ) - m.target_uinput = "keyboard" - self.assertTrue(m.is_valid()) - self.assertIsNone(m.get_error()) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) + self.assertIsNone(mapping.get_error()) def test_copy_returns_ui_mapping(self): - """copy should also be a UIMapping with all the invalid data""" - m = UIMapping() - m2 = m.copy() - self.assertIsInstance(m2, UIMapping) - self.assertEqual(m2.input_combination, InputCombination.empty_combination()) - self.assertIsNone(m2.output_symbol) + """Copy should also be a UIMapping with all the invalid data.""" + mapping = UIMapping() + mapping_2 = mapping.copy() + self.assertIsInstance(mapping_2, UIMapping) + self.assertEqual( + mapping_2.input_combination, InputCombination.empty_combination() + ) + self.assertIsNone(mapping_2.output_symbol) def test_get_bus_massage(self): - m = UIMapping() - m2 = m.get_bus_message() - self.assertEqual(m2.message_type, MessageType.mapping) + mapping = UIMapping() + mapping_2 = mapping.get_bus_message() + self.assertEqual(mapping_2.message_type, MessageType.mapping) with self.assertRaises(TypeError): # the massage should be immutable - m2.output_symbol = "a" - self.assertIsNone(m2.output_symbol) + mapping_2.output_symbol = "a" + self.assertIsNone(mapping_2.output_symbol) # the original should be not immutable - m.output_symbol = "a" - self.assertEqual(m.output_symbol, "a") + mapping.output_symbol = "a" + self.assertEqual(mapping.output_symbol, "a") def test_has_input_defined(self): - m = UIMapping() - self.assertFalse(m.has_input_defined()) - m.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)]) - self.assertTrue(m.has_input_defined()) + mapping = UIMapping() + self.assertFalse(mapping.has_input_defined()) + mapping.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)]) + self.assertTrue(mapping.has_input_defined()) if __name__ == "__main__": diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py index 2ddf0f80..ad351391 100644 --- a/tests/unit/test_preset.py +++ b/tests/unit/test_preset.py @@ -426,7 +426,7 @@ class TestPreset(unittest.TestCase): mapping.output_symbol = "BTN_Left" self.assertFalse(self.preset.dangerously_mapped_btn_left()) - mapping.target_uinput = "keyboard" + mapping.target_uinput = "keyboard + mouse" mapping.output_symbol = "3" self.assertTrue(self.preset.dangerously_mapped_btn_left())