#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 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 . from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING from evdev._ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import ( MacroError, SymbolNotAvailableInTargetError, ) from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.variable import Variable if TYPE_CHECKING: from inputremapper.injection.macros.raw_value import RawValue from inputremapper.configs.mapping import Mapping class ArgumentFlags(Enum): # No default value is set, and the user has to provide one when using the macro required = "required" # If used, acts like foo(*bar) spread = "spread" @dataclass class ArgumentConfig: """Definition what kind of arguments a task may take.""" position: Union[int, Literal[ArgumentFlags.spread]] name: str types: List[Optional[Type]] is_symbol: bool = False default: Any = ArgumentFlags.required # If True, then the value (which should be a string), is the name of a non-constant # variable. Tasks that overwrite their value need this, like `set`. The specified # types are those that the current value of that variable may have. For `set` this # doesn't matter, but something like `add` requires them to be numbers. is_variable_name: bool = False def is_required(self) -> bool: return self.default == ArgumentFlags.required def is_spread(self): """Does this Argument store all remaining Variables of a Task as a list?""" return self.position == ArgumentFlags.spread class Argument(ArgumentConfig): """Validation of variables and access to their value for Tasks during runtime.""" _variable: Optional[Variable] = None # If the position is set to ArgumentFlags.spread, then _variables will be filled # with all remaining positional arguments that were passed to a task. _variables: List[Variable] _mapping: Optional[Mapping] = None def __init__( self, argument_config: ArgumentConfig, mapping: Mapping, ) -> None: # If a default of None is specified, but None is not an allowed type, then # input-remapper has a bug here. Add "None" to your ArgumentConfig.types assert not ( argument_config.default is None and None not in argument_config.types ) self.position = argument_config.position self.name = argument_config.name self.types = argument_config.types self.is_symbol = argument_config.is_symbol self.default = argument_config.default self.is_variable_name = argument_config.is_variable_name self._mapping = mapping self._variables = [] def initialize_variables(self, raw_values: List[RawValue]) -> None: """If the macro is supposed to contain multiple variables, set them. Should be done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert self.is_spread() for raw_value in raw_values: variable = self._parse_raw_value(raw_value) self._variables.append(variable) def initialize_variable(self, raw_value: RawValue) -> None: """Set the Arguments Variable. Done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert not self.is_spread() variable = self._parse_raw_value(raw_value) self._variable = variable def initialize_default(self) -> None: """Set the Arguments to its default value. Done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert not self.is_spread() variable = Variable(value=self.default, const=True) self._variable = variable def get_value(self) -> Any: """To ask for the current value of the variable during runtime.""" assert not self.is_spread(), f"Use .{self.get_values.__name__}()" # If a user passed None as value, it should be a Variable(None, const=True) here. # If not, a test or input-remapper is broken. assert self._variable is not None value = self._variable.get_value() if not self._variable.const: # Dynamic value. Hasn't been validated yet value = self._validate_dynamic_value(self._variable) return value def get_values(self) -> List[Any]: """To ask for the current values of the variables during runtime.""" assert self.is_spread(), f"Use .{self.get_value.__name__}()" values = [] for variable in self._variables: if not variable.const: values.append(self._validate_dynamic_value(variable)) else: values.append(variable.get_value()) return values def get_variable_name(self) -> str: """If the variable is not const, return its name.""" assert self._variable is not None return self._variable.get_name() def contains_macro(self) -> bool: """Does the underlying Variable contain another child-macro?""" assert self._variable is not None return isinstance(self._variable.get_value(), Macro) def set_value(self, value: Any) -> Any: """To set the value of the underlying Variable during runtime. Fails for constants.""" assert self._variable is not None if self._variable.const: raise Exception("Can't set value of a constant") self._variable.set_value(value) def assert_is_symbol(self, symbol: str) -> None: """Checks if the key/symbol-name is valid. Like "KEY_A" or "escape". Using `is_symbol` on the ArgumentConfig is prefered, which causes it to automatically do this for you. But some macros may be a bit more flexible, and there we want to assert this ourselves only in certain cases.""" symbol = str(symbol) code = keyboard_layout.get(symbol) if code is None: raise MacroError(msg=f'Unknown key "{symbol}"') if self._mapping is not None: target = self._mapping.target_uinput if target is not None and not GlobalUInputs.can_default_uinput_emit( target, EV_KEY, code ): raise SymbolNotAvailableInTargetError(symbol, target) def _parse_raw_value(self, raw_value: RawValue) -> Variable: """Validate and parse.""" value = raw_value.value # The order of steps below matters. if isinstance(value, Macro): return Variable(value=value, const=True) if self.is_variable_name: # Treat this as a non-constant variable, # even without a `$` in front of its name if value.startswith('"'): # Remove quotes from the string value = value[1:-1] return Variable(value=value, const=False) if value.startswith("$"): # Will be resolved during the macros runtime return Variable(value=value[1:], const=False) if self.is_symbol: if value.startswith('"'): value = value[1:-1] self.assert_is_symbol(value) return Variable(value=value, const=True) if (value == "" or value == "None") and None in self.types: # I think "" is the deprecated alternative to "None" return Variable(value=None, const=True) if value.startswith('"') and str in self.types: # Something with explicit quotes should never be parsed as a number. # Treat it as a string no matter the content. value = value[1:-1] return Variable(value=value, const=True) if float in self.types and "." in value: try: return Variable(value=float(value), const=True) except (ValueError, TypeError): pass if int in self.types: try: return Variable(value=int(value), const=True) except (ValueError, TypeError): pass if not value.startswith('"') and ("(" in value or ")" in value): # Looks like something that should have been a macro. It is not explicitly # wrapped in quotes. Most likely an error. If it was a valid macro, the # parser would have parsed it as such. raise MacroError( msg=f"A broken macro was passed as parameter to {self.name}" ) if str in self.types: # Treat as a string. Something like KEY_A in key(KEY_A) return Variable(value=value, const=True) raise self._type_error_factory(value) def _validate_dynamic_value(self, variable: Variable) -> Any: """To make sure the value of a non-const variable, asked for at runtime, is fitting for the given ArgumentConfig.""" # Most of the stuff has already been taken care of when, for example, # the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the # first time. In the first case we get a number 1, and in the second a string # `bar` without quotes assert not variable.const value = variable.get_value() if self.is_symbol: # value might be int `1`, which is a valid symbol for `key(1)` value = str(value) self.assert_is_symbol(value) return value if None in self.types and value is None: return value if type(value) in self.types: return value if type(value) not in self.types and str in self.types: # `set` cannot make predictions where the variable will be used. Make sure # the type is compatible, and turn numbers back into strings if need be. return str(value) # If the value is "1", we don't attempt to parse it as a number. This being a # string means that something like `set(foo, "1")` was used, which enforces a # string datatype. Otherwise, `set` would have already turned it into an int. raise self._type_error_factory(value) def _is_numeric_string(self, value: str) -> bool: """Check if the value can be turned into a number.""" try: float(value) return True except ValueError: return False def _type_error_factory(self, value: Any) -> MacroError: formatted_types: List[str] = [] for type_ in self.types: if type_ is None: formatted_types.append("None") else: formatted_types.append(type_.__name__) return MacroError( msg=( f'Expected "{self.name}" to be one of {formatted_types}, but got ' f'{type(value).__name__} "{value}"' ) )