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