Source code for simvx.core.input.spelling

"""The written form of an input source, shared by ``simvx.toml`` and the rebind file.

One spelling for everything an action's source list can hold, so a project's
settings and a player's saved rebinds read and write the same thing:

.. code-block:: toml

    jump   = ["space", "controller:a"]                          # a key's name, or any control's spelling
    save   = ["ctrl+s", {key = "s", held = ["meta"]}]           # a combo, or the table it stands for
    find   = [{key = "f", held = ["ctrl"], exact = false}]
    super  = [{control = "controller:a", held = ["controller:left_bumper"]}]
    steer  = ["controller:left_x", {low = "a", high = "d"}]     # a whole axis, or two sides of one
    move   = ["controller:left_stick", {up = "w", down = "s", left = "a", right = "d"}]
    pause  = [{name = "start"}, "escape"]                       # another entry, by name

A string is always a control (a key's name, a combo, or a control's
``type:name`` spelling) and never another entry's name, which is written
``{name = "..."}``, so a string is never ambiguous. A whole axis reads as its
two halves and a stick as its four, as they do in code. Every value is plain
data (strings, numbers, booleans, lists and tables), so it fits TOML and JSON
alike.

A binding is written in the shortest form that reads back as exactly it: a bare
key as its name, anything else as a table.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import replace
from typing import Any

from .actions import Axis1D, Axis2D, Source, to_source
from .controls import (
    Controller,
    DeviceAxis,
    DeviceButton,
    DeviceControl,
    DeviceStick,
    DeviceType,
    Key,
    Touch,
    key_code,
    key_to_name,
    name_to_keys,
)
from .events import InputBinding, key_combo_to_binding, key_named, key_part

__all__ = ["source_from_data", "source_to_data"]

_AXIS_1D_FIELDS = ("low", "high")
_AXIS_2D_FIELDS = ("up", "down", "left", "right")
_BINDING_FIELDS = ("key", "control", "held", "exact", "deadzone", "scale", "per_second")
_BINDING_DEFAULTS = InputBinding()
_AXIS_DEADZONE = Axis1D(low=Key.A, high=Key.D).deadzone
_STICKS = (Controller.LEFT_STICK, Controller.RIGHT_STICK, Touch.PAN)


# -- Reading ------------------------------------------------------------------


[docs] def source_from_data(data: Any, *, device_id: int | None = None) -> Source: """One source read back from its written form; a name reference reads as the name. *device_id* pins every control read to that unit, which is how a section of the rebind file filed under one controller comes back. Raises ``ValueError`` naming what is wrong, with the spelling that would be right. """ if isinstance(data, str): return _pin(_from_string(data), device_id) if not isinstance(data, Mapping): raise ValueError(f"a binding is a string or a table, got {type(data).__name__} {data!r}") fields = set(data) if "name" in fields: if fields != {"name"} or not isinstance(data["name"], str) or not data["name"]: raise ValueError('a reference to another entry is {name = "..."} alone') return data["name"] if fields & set(_AXIS_1D_FIELDS) or fields & set(_AXIS_2D_FIELDS): return _composite(data, device_id) return _pin(_binding_from_table(data), device_id)
def _from_string(text: str) -> Source: """A written control: a key's name, a combo, or a ``type:name`` spelling, all lower case.""" if text != text.lower(): raise ValueError(f"{text!r} is not how a control is written: write {text.lower()!r}") keys = name_to_keys(text) if keys: return InputBinding(keys[0]) found = getattr(Key, text.upper(), None) if isinstance(found, DeviceButton): return InputBinding(found) combo = key_combo_to_binding(text) if combo is not None: return combo if ":" not in text: raise ValueError( f"{text!r} names no key or control; a key is written by its name ('space', 'ctrl+s') " "and any other control as type:name ('controller:a'); another entry is {name = \"...\"}" ) control = DeviceControl.parse(text) if isinstance(control, DeviceStick | DeviceButton | DeviceAxis): return to_source(control) raise ValueError(f"{text!r} is not a button, an axis or a stick") def _binding_from_table(data: Mapping[str, Any]) -> InputBinding | Axis1D | Axis2D: """``{key = ...}`` or ``{control = ...}``, with what it holds, exactness and deadzone.""" unknown = sorted(set(data) - set(_BINDING_FIELDS)) if unknown: flags = [name for name in unknown if name in ("ctrl", "shift", "alt", "meta")] hint = f'; a modifier is held, held = ["{flags[0]}"]' if flags else "" raise ValueError(f"unknown binding fields {unknown}; a binding has {list(_BINDING_FIELDS)}{hint}") key, control = data.get("key"), data.get("control") if (key is None) == (control is None): raise ValueError("each binding names exactly one of key or control") if key is not None: try: read = _from_string(str(key)) if ":" not in str(key) else None except ValueError: read = None if not isinstance(read, InputBinding) or not read.is_key: raise ValueError(f"unknown key {key!r}; a key is written by its name, 'space', or as a combo, 'ctrl+s'") source: InputBinding | Axis1D | Axis2D = read else: spelled = _from_string(str(control)) if ":" in str(control) else _missing_type(str(control)) if isinstance(spelled, str): raise ValueError(f"control {control!r} names no control") source = spelled deadzone = data.get("deadzone") if deadzone is not None: if not isinstance(deadzone, int | float) or not 0.0 <= float(deadzone) <= 1.0: raise ValueError(f"deadzone must be a number between 0.0 and 1.0, got {deadzone!r}") scale, rate = _units(data) if isinstance(source, Axis1D | Axis2D): if set(data) - {"control", "deadzone", "scale", "per_second"}: raise ValueError(f"{control!r} is a whole axis or a stick, which holds nothing and has no exactness") if deadzone is not None: source = type(source)(**{**_parts(source), "deadzone": float(deadzone)}) if rate is not None: source = source.per_second(rate) return source * scale if scale != 1.0 else source held = _held(data.get("held", [])) exact = data.get("exact") if exact is not None and not isinstance(exact, bool): raise ValueError(f"exact must be true or false, got {exact!r}") binding = source.holding(*held) binding.exact = exact binding.scale = scale binding.rate = rate if deadzone is not None: binding.deadzone = float(deadzone) return binding def _units(data: Mapping[str, Any]) -> tuple[float, float | None]: """A table's ``scale`` and ``per_second``, checked: numbers, the scale not zero.""" scale = data.get("scale", 1.0) rate = data.get("per_second") if not isinstance(scale, int | float) or isinstance(scale, bool) or scale == 0: raise ValueError(f"scale must be a number other than 0, got {scale!r}") if rate is not None and (not isinstance(rate, int | float) or isinstance(rate, bool)): raise ValueError(f"per_second must be a number, got {rate!r}") return float(scale), None if rate is None else float(rate) def _missing_type(text: str) -> Source: raise ValueError(f"control {text!r} names no device type; write it as type:name, 'controller:a'") def _held(written: Any) -> tuple[DeviceButton, ...]: """``held = ["ctrl", "controller:left_bumper"]``: a key as a combo spells it, anything else by spelling.""" if not isinstance(written, list) or not all(isinstance(part, str) for part in written): raise ValueError('held must be a list of controls, such as ["ctrl"]') held: list[DeviceButton] = [] for part in written: key = key_named(part) if key is not None: held.append(key) continue control = DeviceControl.parse(part) if not isinstance(control, DeviceButton): raise ValueError(f"held {part!r} is not a button") held.append(control) return tuple(held) def _composite(data: Mapping[str, Any], device_id: int | None) -> Axis1D | Axis2D: """``{low = ..., high = ...}`` or ``{up = ..., down = ..., left = ..., right = ...}``.""" fields = set(data) - {"deadzone"} shape: tuple[str, ...] cls: type[Axis1D] | type[Axis2D] if fields == set(_AXIS_1D_FIELDS): shape, cls = _AXIS_1D_FIELDS, Axis1D elif fields == set(_AXIS_2D_FIELDS): shape, cls = _AXIS_2D_FIELDS, Axis2D else: raise ValueError(f"a composite has either low and high, or up, down, left and right, got {sorted(fields)}") parts: dict[str, Any] = {} for field in shape: part = source_from_data(data[field], device_id=device_id) if isinstance(part, Axis1D): # A composite's part is one control: a whole axis there is that # axis read as it is, not the signed pair a whole axis is alone. scale, rate, bare = _common_units(part) whole = _whole(bare) if whole is not None: axis = whole[0] assert isinstance(axis, DeviceAxis) part = InputBinding(axis, scale=scale, rate=rate) if device_id is not None: part = _pin(part, device_id) if not isinstance(part, InputBinding | str): raise ValueError(f"{field} is one control or one entry's name, got {data[field]!r}") parts[field] = part deadzone = data.get("deadzone") if deadzone is not None: if not isinstance(deadzone, int | float) or not 0.0 <= float(deadzone) <= 1.0: raise ValueError(f"deadzone must be a number between 0.0 and 1.0, got {deadzone!r}") parts["deadzone"] = float(deadzone) return cls(**parts) def _pin(source: Source, device_id: int | None) -> Source: """*source* with every control on the unit *device_id*, and what it holds on that unit where it is its type.""" if device_id is None or isinstance(source, str): return source if isinstance(source, InputBinding): control = source.control if control is None: return source pinned = InputBinding( control.on(device_id), deadzone=source.deadzone, held=tuple(h.on(device_id) if h.device is control.device else h for h in source.held), exact=source.exact, ) return pinned parts: dict[str, Any] = {field: _pin(part, device_id) for field, part in _parts(source).items()} return type(source)(**parts, deadzone=source.deadzone) def _parts(source: Axis1D | Axis2D) -> dict[str, Any]: fields = _AXIS_1D_FIELDS if isinstance(source, Axis1D) else _AXIS_2D_FIELDS return {field: getattr(source, field) for field in fields} # -- Writing ------------------------------------------------------------------
[docs] def source_to_data(source: Source) -> Any: """*source* as plain data: the shortest form that reads back as exactly it. A control's unit is not written: the rebind file files a pinned binding under its device's guid, and reading that section pins it again. """ if isinstance(source, str): return {"name": source} if isinstance(source, InputBinding): return _binding_to_data(source) scale, rate, bare = _common_units(source) whole = _whole(bare) if whole is not None: control, default_deadzone = whole if bare.deadzone == default_deadzone and scale == 1.0 and rate is None: return str(control) written: dict[str, Any] = {"control": str(control)} if bare.deadzone != default_deadzone: written["deadzone"] = bare.deadzone _write_units(written, scale, rate) return written data: dict[str, Any] = {field: source_to_data(part) for field, part in _parts(source).items()} if source.deadzone != _AXIS_DEADZONE: data["deadzone"] = source.deadzone return data
def _common_units(source: Axis1D | Axis2D) -> tuple[float, float | None, Axis1D | Axis2D]: """The scale and rate every part of *source* shares, and *source* without them; 1.0 and None when they differ.""" parts = list(_parts(source).values()) bindings = [p for p in parts if isinstance(p, InputBinding)] if len(bindings) != len(parts) or not bindings: return 1.0, None, source scale, rate = bindings[0].scale, bindings[0].rate if any(b.scale != scale or b.rate != rate for b in bindings) or (scale == 1.0 and rate is None): return 1.0, None, source bare = {name: replace(part, scale=1.0, rate=None) for name, part in _parts(source).items()} return scale, rate, type(source)(**bare, deadzone=source.deadzone) def _whole(source: Axis1D | Axis2D) -> tuple[DeviceControl, float] | None: """The whole axis or stick *source* was spelt from, and the deadzone that spelling reads with.""" if isinstance(source, Axis2D): for stick in _STICKS: unit = _unit(source) candidate = Axis2D.for_stick(stick.on(unit) if unit is not None else stick, deadzone=source.deadzone) if candidate == source: default = to_source(stick) assert isinstance(default, Axis2D) return stick, default.deadzone return None low = source.low if isinstance(low, InputBinding) and isinstance(low.control, DeviceAxis) and low.control.half < 0: axis = low.control.whole default = to_source(axis.on(None)) assert isinstance(default, Axis1D) if Axis1D.for_device_axis(axis, deadzone=source.deadzone) == source: return axis.on(None), default.deadzone return None def _unit(source: Axis1D | Axis2D) -> int | None: first = next(iter(_parts(source).values())) return first.control.unit if isinstance(first, InputBinding) and first.control is not None else None def _binding_to_data(binding: InputBinding) -> Any: control = binding.control if control is None: raise ValueError("a binding with no control has no written form") plain = ( not binding.held and binding.exact is None and binding.deadzone == _BINDING_DEFAULTS.deadzone and binding.scale == 1.0 and binding.rate is None ) if isinstance(control, DeviceButton) and binding.is_key and control.name is not None: if plain: return _key_name(control) data: dict[str, Any] = {"key": _key_name(control)} else: if plain: return str(control.on(None)) data = {"control": str(control.on(None))} if binding.held: data["held"] = [key_part(h) if h.device is DeviceType.KEYBOARD else str(h.on(None)) for h in binding.held] if binding.exact is not None: data["exact"] = binding.exact if binding.deadzone != _BINDING_DEFAULTS.deadzone: data["deadzone"] = binding.deadzone _write_units(data, binding.scale, binding.rate) return data def _write_units(data: dict[str, Any], scale: float, rate: float | None) -> None: if scale != 1.0: data["scale"] = scale if rate is not None: data["per_second"] = rate def _key_name(key: DeviceButton) -> str: """The name that reads back as exactly *key*. The display names share one spelling between the left and right modifiers ("shift" is both), and reading a shared name back picks the left one. Where that would change the binding, the key's own constant name is written instead, which reads back unambiguously. """ name = key_to_name(key) matches = name_to_keys(name) if matches and matches[0] == key: return name return key.name if key.name is not None else f"key_{key_code(key)}"