"""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)}"