Source code for simvx.core.input.state

"""Input — singleton for querying current input state."""

from __future__ import annotations

from typing import ClassVar

from ..math.types import Vec2
from .enums import JoyAxis, JoyButton, Key, MouseButton, MouseCaptureMode
from .events import InputBinding
from .map import InputMap


[docs] class Input: """Static input manager. Tracks keyboard, mouse, and gamepad state. Actions are registered via InputMap; query with is_action_pressed() etc. Direct key/button queries use is_key_pressed(Key.X) / is_mouse_button_pressed(MouseButton.LEFT). """ # --- Touch emulation (mouse <-> touch) --- _emulate_touch_from_mouse: ClassVar[bool] = False # --- String-based state (written by platform adapters, read by UI system) --- _keys: ClassVar[dict[str, bool]] = {} _keys_just_pressed: ClassVar[dict[str, bool]] = {} _keys_just_released: ClassVar[dict[str, bool]] = {} _mouse_pos: ClassVar[tuple[float, float]] = (0.0, 0.0) _mouse_delta: ClassVar[tuple[float, float]] = (0.0, 0.0) _scroll_delta: ClassVar[tuple[float, float]] = (0.0, 0.0) _gamepad_buttons: ClassVar[dict[int, dict[str, bool]]] = {} _gamepad_axes: ClassVar[dict[int, dict[str, float]]] = {} # --- Typed state --- _keys_pressed: ClassVar[set[int]] = set() _keys_just_pressed_typed: ClassVar[set[int]] = set() _keys_just_released_typed: ClassVar[set[int]] = set() _mouse_buttons_pressed: ClassVar[set[int]] = set() _mouse_buttons_just_pressed: ClassVar[set[int]] = set() _mouse_buttons_just_released: ClassVar[set[int]] = set() _joy_axes: ClassVar[dict[int, float]] = {} _joy_buttons_pressed: ClassVar[set[int]] = set() _joy_buttons_just_pressed: ClassVar[set[int]] = set() _joy_buttons_just_released: ClassVar[set[int]] = set() _capture_mode: ClassVar[MouseCaptureMode] = MouseCaptureMode.VISIBLE _capture_mode_callback: ClassVar[object] = None # Platform sets this to apply capture # ---------------------------------------------------------------- # Action queries (via InputMap only) # ----------------------------------------------------------------
[docs] @classmethod def is_action_pressed(cls, action: str) -> bool: """Check if any input mapped to the action is currently held.""" for b in InputMap.get_bindings(action): if cls._binding_pressed(b): return True return False
[docs] @classmethod def is_action_just_pressed(cls, action: str) -> bool: """Check if any input mapped to the action was pressed this frame.""" for b in InputMap.get_bindings(action): if cls._binding_just_pressed(b): return True return False
[docs] @classmethod def is_action_just_released(cls, action: str) -> bool: """Check if any input mapped to the action was released this frame.""" for b in InputMap.get_bindings(action): if cls._binding_just_released(b): return True return False
[docs] @classmethod def get_action_strength(cls, action: str) -> float: """Return action strength: 1.0 for digital press, analog value for axes.""" strength = 0.0 for b in InputMap.get_bindings(action): strength = max(strength, cls._binding_strength(b)) return strength
# Alias get_strength = get_action_strength
[docs] @classmethod def get_axis(cls, negative_action: str, positive_action: str) -> float: """Return axis value from two opposing actions. Range [-1, 1].""" return cls.get_action_strength(positive_action) - cls.get_action_strength(negative_action)
[docs] @classmethod def get_vector(cls, neg_x: str, pos_x: str, neg_y: str, pos_y: str) -> Vec2: """Return a normalized 2D direction vector from four input actions. Handles diagonal normalization so magnitude never exceeds 1.0. """ x = cls.get_action_strength(pos_x) - cls.get_action_strength(neg_x) y = cls.get_action_strength(pos_y) - cls.get_action_strength(neg_y) v = Vec2(x, y) ln = v.length() return v / ln if ln > 1.0 else v
# ---------------------------------------------------------------- # Typed key query API # ----------------------------------------------------------------
[docs] @classmethod def is_key_pressed(cls, key: Key) -> bool: """Check if a specific key is currently held down.""" return int(key) in cls._keys_pressed
[docs] @classmethod def is_key_just_pressed(cls, key: Key) -> bool: """Check if a specific key was pressed this frame (not held from previous).""" return int(key) in cls._keys_just_pressed_typed
[docs] @classmethod def is_key_just_released(cls, key: Key) -> bool: """Check if a specific key was released this frame.""" return int(key) in cls._keys_just_released_typed
# ---------------------------------------------------------------- # Mouse # ----------------------------------------------------------------
[docs] @classmethod def is_mouse_button_pressed(cls, button: MouseButton) -> bool: """Check if a mouse button is currently held.""" return int(button) in cls._mouse_buttons_pressed
[docs] @classmethod def is_mouse_button_just_pressed(cls, button: MouseButton) -> bool: """Check if a mouse button was pressed this frame.""" return int(button) in cls._mouse_buttons_just_pressed
[docs] @classmethod def is_mouse_button_just_released(cls, button: MouseButton) -> bool: """Check if a mouse button was released this frame.""" return int(button) in cls._mouse_buttons_just_released
[docs] @classmethod def get_mouse_position(cls) -> Vec2: """Get current mouse position in screen coordinates.""" return Vec2(cls._mouse_pos[0], cls._mouse_pos[1])
[docs] @classmethod def get_mouse_delta(cls) -> Vec2: """Get mouse movement delta this frame.""" return Vec2(cls._mouse_delta[0], cls._mouse_delta[1])
[docs] @classmethod def get_scroll_delta(cls) -> tuple[float, float]: """Get scroll wheel delta this frame (x, y).""" return cls._scroll_delta
[docs] @classmethod def set_touch_emulation(cls, enabled: bool = True): """Enable mouse-to-touch emulation (useful for testing touch on desktop). When enabled, left mouse button presses/releases and mouse moves also generate touch events with finger_id=0. This lets GestureRecognizer and other touch-consuming code work with a mouse. """ cls._emulate_touch_from_mouse = enabled
[docs] @classmethod def set_mouse_capture_mode(cls, mode: MouseCaptureMode): """Set mouse cursor capture mode. Platform adapter applies the change.""" cls._capture_mode = mode if cls._capture_mode_callback: cls._capture_mode_callback(mode)
[docs] @classmethod def get_mouse_capture_mode(cls) -> MouseCaptureMode: """Get the current mouse capture mode.""" return cls._capture_mode
# ---------------------------------------------------------------- # Gamepad # ----------------------------------------------------------------
[docs] @classmethod def get_gamepad_axis(cls, pad_id: int = 0, axis: str | JoyAxis = "left_x") -> float: """Get gamepad axis value [-1, 1]. Accepts either a string name (legacy) or JoyAxis enum. """ if isinstance(axis, JoyAxis): return cls._joy_axes.get(int(axis), 0.0) return cls._gamepad_axes.get(pad_id, {}).get(axis, 0.0)
[docs] @classmethod def is_gamepad_pressed(cls, pad_id: int = 0, button: str | JoyButton = "a") -> bool: """Check if gamepad button is pressed. Accepts either a string name (legacy) or JoyButton enum. """ if isinstance(button, JoyButton): return int(button) in cls._joy_buttons_pressed return cls._gamepad_buttons.get(pad_id, {}).get(button, False)
[docs] @classmethod def get_gamepad_vector(cls, pad_id: int = 0, stick: str = "left") -> Vec2: """Get stick as Vec2 with deadzone applied.""" x = cls.get_gamepad_axis(pad_id, f"{stick}_x") y = cls.get_gamepad_axis(pad_id, f"{stick}_y") v = Vec2(x, y) if v.length() < 0.15: return Vec2(0, 0) return v
# ---------------------------------------------------------------- # Public injection (for testing / virtual controls) # ----------------------------------------------------------------
[docs] @classmethod def inject_key(cls, key: int | Key, pressed: bool) -> None: """Inject a synthetic key event. Same path as platform adapters.""" cls._on_key(int(key), pressed)
[docs] @classmethod def inject_mouse_button(cls, button: int | MouseButton, pressed: bool) -> None: """Inject a synthetic mouse button event. Same path as platform adapters.""" cls._on_mouse_button(int(button), pressed)
# ---------------------------------------------------------------- # Internal: called by platform adapters # ---------------------------------------------------------------- @classmethod def _on_key(cls, key: int, pressed: bool): """Called by platform adapter for typed key events.""" if pressed: if key not in cls._keys_pressed: cls._keys_just_pressed_typed.add(key) cls._keys_pressed.add(key) else: cls._keys_pressed.discard(key) cls._keys_just_released_typed.add(key) @classmethod def _on_mouse_button(cls, button: int, pressed: bool): """Called by platform adapter for typed mouse button events.""" if pressed: if button not in cls._mouse_buttons_pressed: cls._mouse_buttons_just_pressed.add(button) cls._mouse_buttons_pressed.add(button) else: cls._mouse_buttons_pressed.discard(button) cls._mouse_buttons_just_released.add(button) # Mouse->touch emulation: left button (0) maps to finger 0 if cls._emulate_touch_from_mouse and button == 0: x, y = cls._mouse_pos cls._update_touch(0, 0 if pressed else 1, x, y, 1.0 if pressed else 0.0) @classmethod def _on_mouse_move(cls, x: float, y: float): """Called by platform adapter for mouse movement.""" old = cls._mouse_pos cls._mouse_pos = (x, y) cls._mouse_delta = (x - old[0], y - old[1]) # Mouse->touch emulation: emit move only when finger 0 is "down" (left button held) if cls._emulate_touch_from_mouse and 0 in cls._touches: cls._update_touch(0, 2, x, y, 1.0) @classmethod def _on_joy_button(cls, button: int, pressed: bool): """Called by platform adapter for gamepad button events.""" if pressed: if button not in cls._joy_buttons_pressed: cls._joy_buttons_just_pressed.add(button) cls._joy_buttons_pressed.add(button) else: cls._joy_buttons_pressed.discard(button) cls._joy_buttons_just_released.add(button) @classmethod def _on_joy_axis(cls, axis: int, value: float): """Called by platform adapter for gamepad axis changes.""" cls._joy_axes[axis] = value @classmethod def _update_gamepad(cls, pad_id: int, buttons: dict[str, bool], axes: dict[str, float]): """Called by platform adapter to update gamepad state (legacy string API).""" cls._gamepad_buttons[pad_id] = buttons cls._gamepad_axes[pad_id] = axes # ---------------------------------------------------------------- # Touch input # ---------------------------------------------------------------- # Active touches: finger_id -> (x, y, pressure) _touches: ClassVar[dict[int, tuple[float, float, float]]] = {} _touches_just_pressed: ClassVar[dict[int, tuple[float, float, float]]] = {} _touches_just_released: ClassVar[set[int]] = set() @classmethod def _update_touch(cls, finger_id: int, action: int, x: float, y: float, pressure: float): """Called by platform adapter for touch events. action: 0=down, 1=up, 2=move.""" if action == 0: # down cls._touches[finger_id] = (x, y, pressure) cls._touches_just_pressed[finger_id] = (x, y, pressure) elif action == 1: # up cls._touches.pop(finger_id, None) cls._touches_just_released.add(finger_id) elif action == 2: # move cls._touches[finger_id] = (x, y, pressure)
[docs] @classmethod def get_touches(cls) -> dict[int, tuple[float, float, float]]: """Return dict of active touches: {finger_id: (x, y, pressure)}.""" return dict(cls._touches)
[docs] @classmethod def get_touches_just_pressed(cls) -> dict[int, tuple[float, float, float]]: """Return touches that started this frame.""" return dict(cls._touches_just_pressed)
[docs] @classmethod def get_touches_just_released(cls) -> set[int]: """Return finger IDs that were lifted this frame.""" return set(cls._touches_just_released)
[docs] @staticmethod def get_touch_positions() -> list[tuple[int, float, float, float]]: """Active touches: list of (finger_id, x, y, pressure).""" return [(fid, x, y, p) for fid, (x, y, p) in Input._touches.items()]
[docs] @staticmethod def is_touch_pressed(finger_id: int = 0) -> bool: """Whether finger_id is currently touching.""" return finger_id in Input._touches
[docs] @staticmethod def get_touch_count() -> int: """Number of active touch points.""" return len(Input._touches)
@classmethod def _new_frame(cls): """Called by engine at frame start. Clears per-frame state.""" cls._keys_just_pressed_typed.clear() cls._keys_just_released_typed.clear() cls._mouse_buttons_just_pressed.clear() cls._mouse_buttons_just_released.clear() cls._joy_buttons_just_pressed.clear() cls._joy_buttons_just_released.clear() @classmethod def _end_frame(cls): """Called by engine at frame end. Clears all per-frame state.""" cls._keys_just_pressed.clear() cls._keys_just_released.clear() cls._keys_just_pressed_typed.clear() cls._keys_just_released_typed.clear() cls._mouse_buttons_just_pressed.clear() cls._mouse_buttons_just_released.clear() cls._joy_buttons_just_pressed.clear() cls._joy_buttons_just_released.clear() cls._mouse_delta = (0.0, 0.0) cls._scroll_delta = (0.0, 0.0) cls._touches_just_pressed.clear() cls._touches_just_released.clear() @classmethod def _reset(cls): """Reset all input state. Useful for testing.""" cls._keys.clear() cls._keys_just_pressed.clear() cls._keys_just_released.clear() cls._mouse_pos = (0.0, 0.0) cls._mouse_delta = (0.0, 0.0) cls._scroll_delta = (0.0, 0.0) cls._gamepad_buttons.clear() cls._gamepad_axes.clear() cls._keys_pressed.clear() cls._keys_just_pressed_typed.clear() cls._keys_just_released_typed.clear() cls._mouse_buttons_pressed.clear() cls._mouse_buttons_just_pressed.clear() cls._mouse_buttons_just_released.clear() cls._joy_axes.clear() cls._joy_buttons_pressed.clear() cls._joy_buttons_just_pressed.clear() cls._joy_buttons_just_released.clear() cls._capture_mode = MouseCaptureMode.VISIBLE cls._emulate_touch_from_mouse = False cls._touches.clear() cls._touches_just_pressed.clear() cls._touches_just_released.clear() InputMap.clear() # ---------------------------------------------------------------- # Internal: binding resolution helpers # ---------------------------------------------------------------- @classmethod def _binding_pressed(cls, b: InputBinding) -> bool: """Check if a typed binding is currently pressed.""" if b.key is not None: return int(b.key) in cls._keys_pressed if b.mouse_button is not None: return int(b.mouse_button) in cls._mouse_buttons_pressed if b.joy_button is not None: return int(b.joy_button) in cls._joy_buttons_pressed if b.joy_axis is not None: val = cls._joy_axes.get(int(b.joy_axis), 0.0) if b.joy_axis_positive: return val > b.deadzone return val < -b.deadzone return False @classmethod def _binding_just_pressed(cls, b: InputBinding) -> bool: """Check if a typed binding was just pressed this frame.""" if b.key is not None: return int(b.key) in cls._keys_just_pressed_typed if b.mouse_button is not None: return int(b.mouse_button) in cls._mouse_buttons_just_pressed if b.joy_button is not None: return int(b.joy_button) in cls._joy_buttons_just_pressed # Axis just-pressed would need previous-frame state; not supported for axes return False @classmethod def _binding_just_released(cls, b: InputBinding) -> bool: """Check if a typed binding was just released this frame.""" if b.key is not None: return int(b.key) in cls._keys_just_released_typed if b.mouse_button is not None: return int(b.mouse_button) in cls._mouse_buttons_just_released if b.joy_button is not None: return int(b.joy_button) in cls._joy_buttons_just_released return False @classmethod def _binding_strength(cls, b: InputBinding) -> float: """Return analog strength [0, 1] for a binding.""" if b.key is not None: return 1.0 if int(b.key) in cls._keys_pressed else 0.0 if b.mouse_button is not None: return 1.0 if int(b.mouse_button) in cls._mouse_buttons_pressed else 0.0 if b.joy_button is not None: return 1.0 if int(b.joy_button) in cls._joy_buttons_pressed else 0.0 if b.joy_axis is not None: val = cls._joy_axes.get(int(b.joy_axis), 0.0) if b.joy_axis_positive: return max(0.0, val) if val > b.deadzone else 0.0 return max(0.0, -val) if val < -b.deadzone else 0.0 return 0.0