Source code for simvx.core.input.layouts

"""The backend mapping: which raw index on a device each named control is.

A device reports its buttons and axes by index, which is the only thing the
engine truly knows about hardware it has never seen. A :class:`DeviceLayout`
is the backend's table for one kind of device (SDL's controller database, the
browser's standard mapping, GLFW's mouse numbering, the engine's own pen
table), and it is what lets an event carry ``Pen.BARREL_1`` rather than
"button 2".

It is internal. A game never writes to it: every change a game or a player
makes to what a control means is a rebind of an entry in the input map
(``InputMap.rebind(Controller.A, Controller.button(1))``), which is per tree and
survives with the player's other rebinds.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any

from .controls import (
    _KEY_CODES,
    ROLE_MEMBER,
    Controller,
    DeviceAxis,
    DeviceButton,
    DeviceControl,
    DeviceType,
    Mouse,
    Pen,
    Pointer,
    Touch,
    _check_name,
)

__all__ = ["DeviceLayout"]


def _names(device: DeviceType, table: Mapping[Any, int] | None, kind: type[DeviceControl]) -> dict[str, int]:
    """A table keyed by controls (or, from a file, by their names) as name to index."""
    out: dict[str, int] = {}
    for control, index in (table or {}).items():
        if isinstance(control, DeviceControl):
            if not isinstance(control, kind) or control.device is not device or control.name is None:
                raise ValueError(
                    f"a {device.value} layout names {kind.__name__}s of the {device.value}, got {control!r}"
                )
            name = control.name
        else:
            name = _check_name(str(control))
        if not isinstance(index, int) or isinstance(index, bool) or index < 0:
            raise ValueError(f"layout index for {device.value}:{name} must be an int of 0 or more, got {index!r}")
        out[name] = index
    return out


def _check_unique(device: DeviceType, table: Mapping[str, int], what: str) -> None:
    seen: dict[int, str] = {}
    for name, index in table.items():
        if index in seen:
            raise ValueError(
                f"a {device.value} layout puts {seen[index]!r} and {name!r} both at {what} {index}: "
                "a device reports one control per index"
            )
        seen[index] = name


[docs] @dataclass(frozen=True, slots=True, eq=False) class DeviceLayout: """The names one device gives its indices, and what it prints on each. Keyed by the controls themselves, so a misspelt name is an ``AttributeError`` where it is written:: DeviceLayout(DeviceType.PEN, buttons={Pen.TIP: 0, Pen.BARREL_1: 2, Pen.BARREL_2: 1}, labels={Pen.BARREL_1: "Lower barrel"}) Each index has at most one name. A label is keyed by name, so it moves with the name when an override moves it. """ device: DeviceType buttons: Mapping[Any, int] = field(default_factory=dict) axes: Mapping[Any, int] = field(default_factory=dict) labels: Mapping[Any, str] = field(default_factory=dict) _button_controls: dict[int, DeviceButton] = field(default_factory=dict, init=False, repr=False) _axis_controls: dict[int, DeviceAxis] = field(default_factory=dict, init=False, repr=False)
[docs] def __post_init__(self) -> None: device = DeviceType(self.device) object.__setattr__(self, "device", device) buttons = _names(device, self.buttons, DeviceButton) axes = _names(device, self.axes, DeviceAxis) _check_unique(device, buttons, "button") _check_unique(device, axes, "axis") labels = {(k.name if isinstance(k, DeviceControl) else str(k)): str(v) for k, v in self.labels.items()} object.__setattr__(self, "buttons", MappingProxyType(buttons)) object.__setattr__(self, "axes", MappingProxyType(axes)) object.__setattr__(self, "labels", MappingProxyType(labels))
[docs] def __eq__(self, other: object) -> bool: if not isinstance(other, DeviceLayout): return NotImplemented return (self.device, dict(self.buttons), dict(self.axes), dict(self.labels)) == ( other.device, dict(other.buttons), dict(other.axes), dict(other.labels), )
__hash__ = None # type: ignore[assignment]
[docs] def moved(self, buttons: Mapping[str, int] | None = None, axes: Mapping[str, int] | None = None) -> DeviceLayout: """This layout with the given names moved to the given indices. A name already at a target index that is not itself moved loses its index, because an index has one name. :meth:`DeviceLayouts.override` refuses that case with the fix; this is what applies a layer that was checked against a different base. """ def move(table: Mapping[str, int], changes: Mapping[str, int] | None) -> dict[str, int]: if not changes: return dict(table) taken = set(changes.values()) kept = {name: index for name, index in table.items() if name not in changes and index not in taken} return {**kept, **changes} return DeviceLayout(self.device, move(self.buttons, buttons), move(self.axes, axes), self.labels)
[docs] def button_control(self, index: int) -> DeviceButton: """The control a press of *index* is: its name where the layout has one, else ``X.button(index)``.""" found = self._button_controls.get(index) if found is None: name = next((n for n, at in self.buttons.items() if at == index), None) found = DeviceButton(self.device, name) if name is not None else DeviceButton(self.device, index=index) self._button_controls[index] = found return found
[docs] def axis_control(self, index: int) -> DeviceAxis: """The control motion of *index* is: its name where the layout has one, else ``X.axis(index)``.""" found = self._axis_controls.get(index) if found is None: name = next((n for n, at in self.axes.items() if at == index), None) found = DeviceAxis(self.device, name) if name is not None else DeviceAxis(self.device, index=index) self._axis_controls[index] = found return found
[docs] def button_index(self, button: DeviceButton) -> int | None: """The index *button* is on this layout, or None where it names nothing here. A role resolves to this device type's member of it: ``Pointer.PRIMARY`` is the mouse's left on a mouse layout. """ if button.device is DeviceType.POINTER: member = ROLE_MEMBER.get((button.on(None) if button.unit is not None else button, self.device)) return self.button_index(member) if member is not None else None if button.device is not self.device: return None if button.index is not None: return button.index return self.buttons.get(button.name or "")
[docs] def axis_index(self, axis: DeviceAxis) -> int | None: """The index *axis* is on this layout, whichever half or form it names, or None.""" if axis.device is not self.device: return None if axis.index is not None: return axis.index return self.axes.get(axis.name or "")
[docs] @property def primary(self) -> int | None: """The index of this device type's member of ``Pointer.PRIMARY``, which is what contact means.""" return self.button_index(Pointer.PRIMARY)
[docs] def button_label(self, index: int) -> str: """What to print for button *index*: its name's label, else its number.""" name = next((n for n, at in self.buttons.items() if at == index), None) if name is not None and name in self.labels: return self.labels[name] return f"Button {index + 1}"
[docs] def axis_label(self, index: int) -> str: """What to print for axis *index*: its name's label, else its number.""" name = next((n for n, at in self.axes.items() if at == index), None) if name is not None and name in self.labels: return self.labels[name] return f"Axis {index + 1}"
#: The keyboard's fixed layout: the engine's key table. KEYBOARD_LAYOUT = DeviceLayout(DeviceType.KEYBOARD, buttons=dict(_KEY_CODES)) #: The mouse in GLFW's numbering, which every desktop backend reports. MOUSE_LAYOUT = DeviceLayout( DeviceType.MOUSE, buttons={Mouse.LEFT: 0, Mouse.RIGHT: 1, Mouse.MIDDLE: 2, Mouse.BACK: 3, Mouse.FORWARD: 4}, axes={Mouse.WHEEL: 0}, labels={ Mouse.LEFT: "Left Click", Mouse.RIGHT: "Right Click", Mouse.MIDDLE: "Middle Click", Mouse.BACK: "Mouse Back", Mouse.FORWARD: "Mouse Forward", Mouse.WHEEL: "Mouse Wheel", }, ) #: The mouse in DOM order, which the browser reports: middle before right. DOM_MOUSE_LAYOUT = MOUSE_LAYOUT.moved(buttons={"middle": 1, "right": 2}) #: A finger's contact, and the gestures the engine recognises from fingers together. TOUCH_LAYOUT = DeviceLayout( DeviceType.TOUCH, buttons={Touch.CONTACT: 0, Touch.LONG_PRESS: 1, Touch.TWO_FINGER_TAP: 2}, axes={Touch.PINCH: 0, Touch.ROTATE: 1, Touch.PAN_X: 2, Touch.PAN_Y: 3}, labels={ Touch.CONTACT: "Tap", Touch.LONG_PRESS: "Long Press", Touch.TWO_FINGER_TAP: "Two-Finger Tap", Touch.PINCH: "Pinch", Touch.ROTATE: "Twist", Touch.PAN_X: "Two-Finger Pan X", Touch.PAN_Y: "Two-Finger Pan Y", }, ) #: A pen with its tip at 0 and its barrel buttons numbered from 1, as SDL3 and Qt report them. PEN_LAYOUT = DeviceLayout( DeviceType.PEN, buttons={Pen.TIP: 0, Pen.BARREL_1: 1, Pen.BARREL_2: 2, Pen.BARREL_3: 3}, labels={ Pen.TIP: "Pen Tip", Pen.BARREL_1: "Barrel Button 1", Pen.BARREL_2: "Barrel Button 2", Pen.BARREL_3: "Barrel Button 3", }, ) #: A pen in the W3C Pointer Events numbering: the barrel is 2. The #: specification assigns 1 to no pen button, and a second barrel reported #: there is named barrel 2 as the likeliest reading; a player whose pen #: disagrees overrides it. W3C_PEN_LAYOUT = PEN_LAYOUT.moved(buttons={"barrel_1": 2, "barrel_2": 1}) #: A tablet names nothing by default: its keys are bound by index, and a #: backend that knows the device names its rings and strips. TABLET_LAYOUT = DeviceLayout(DeviceType.TABLET) _G = Controller #: A pad the backend's mapping database laid out: the standard positions, in #: the order SDL, GLFW and the browser's standard mapping all report them. CONTROLLER_LAYOUT = DeviceLayout( DeviceType.CONTROLLER, buttons={ _G.A: 0, _G.B: 1, _G.X: 2, _G.Y: 3, _G.LEFT_BUMPER: 4, _G.RIGHT_BUMPER: 5, _G.BACK: 6, _G.START: 7, _G.GUIDE: 8, _G.LEFT_THUMB: 9, _G.RIGHT_THUMB: 10, _G.DPAD_UP: 11, _G.DPAD_RIGHT: 12, _G.DPAD_DOWN: 13, _G.DPAD_LEFT: 14, }, axes={_G.LEFT_X: 0, _G.LEFT_Y: 1, _G.RIGHT_X: 2, _G.RIGHT_Y: 3, _G.LEFT_TRIGGER: 4, _G.RIGHT_TRIGGER: 5}, labels={ _G.A: "A", _G.B: "B", _G.X: "X", _G.Y: "Y", _G.LEFT_BUMPER: "L1", _G.RIGHT_BUMPER: "R1", _G.BACK: "Select", _G.START: "Start", _G.GUIDE: "Guide", _G.LEFT_THUMB: "L3", _G.RIGHT_THUMB: "R3", _G.DPAD_UP: "D-Pad Up", _G.DPAD_RIGHT: "D-Pad Right", _G.DPAD_DOWN: "D-Pad Down", _G.DPAD_LEFT: "D-Pad Left", _G.LEFT_X: "Left Stick X", _G.LEFT_Y: "Left Stick Y", _G.RIGHT_X: "Right Stick X", _G.RIGHT_Y: "Right Stick Y", _G.LEFT_TRIGGER: "LT", _G.RIGHT_TRIGGER: "RT", }, ) #: A pad no mapping database knows: its indices are the driver's, and the #: backend names none of them. UNRECOGNISED_CONTROLLER_LAYOUT = DeviceLayout(DeviceType.CONTROLLER) #: The engine's default for each device type, which a backend's own replaces. DEFAULT_LAYOUTS: dict[DeviceType, DeviceLayout] = { DeviceType.KEYBOARD: KEYBOARD_LAYOUT, DeviceType.MOUSE: MOUSE_LAYOUT, DeviceType.TOUCH: TOUCH_LAYOUT, DeviceType.PEN: PEN_LAYOUT, DeviceType.TABLET: TABLET_LAYOUT, DeviceType.CONTROLLER: CONTROLLER_LAYOUT, }
[docs] def type_layout(device_type: DeviceType, backend: Mapping[DeviceType, DeviceLayout]) -> DeviceLayout: """The table for *device_type*: the backend's own where it gave one, else the engine's default.""" return backend.get(device_type) or DEFAULT_LAYOUTS.get(device_type) or DeviceLayout(device_type)
[docs] def default_raw(control: DeviceControl) -> DeviceControl | None: """The raw control the engine entry *control* resolves to by default, unit-free. A raw control (an index form, or a key, which is raw by nature) is its own. A named one resolves through the engine's own table for its device type, which is the numbering raw indices are given in on every backend. A role and a name the table does not place have none. """ bare = control.on(None) if bare.device is DeviceType.KEYBOARD or bare.index is not None: return bare layout = DEFAULT_LAYOUTS.get(bare.device) if layout is None: return None if isinstance(bare, DeviceButton): index = layout.button_index(bare) return DeviceButton(bare.device, index=index) if index is not None else None if isinstance(bare, DeviceAxis): whole = bare.whole index = layout.axis_index(whole) if index is None: return None raw = DeviceAxis(bare.device, index=index) if bare.half: raw = raw.positive if bare.half > 0 else raw.negative return raw.delta if bare.is_delta else raw return None
[docs] def raw_button(layout: DeviceLayout, index: int) -> DeviceButton: """The raw control a backend's button *index* is: the engine's own numbering for what it is. A backend that numbers a device its own way (the browser's mouse, the W3C pen) is translated here, so ``Mouse.button(1)`` is the right button under every backend. An index the backend's table does not name passes through. """ named = layout.button_control(index) if named.index is None: raw = default_raw(named) if isinstance(raw, DeviceButton): return raw return DeviceButton(layout.device, index=index)
[docs] def raw_axis(layout: DeviceLayout, index: int) -> DeviceAxis: """The raw control a backend's axis *index* is, as :func:`raw_button` is for a button.""" named = layout.axis_control(index) if named.index is None: raw = default_raw(named) if isinstance(raw, DeviceAxis): return raw return DeviceAxis(layout.device, index=index)