Source code for simvx.graphics.input_adapter

"""Adapter to bridge platform input events to simvx.core.Input singleton.

Backend-agnostic — works with GLFW, SDL3, or any backend that delivers the
same integer key codes and action constants.
"""


from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from simvx.core import Input

log = logging.getLogger(__name__)

# Note: Cursor position callbacks deliver coordinates in logical (screen)
# pixels, not physical (framebuffer) pixels.  On HiDPI displays the framebuffer
# may be larger, but mouse coordinates and tree.screen_size both use logical
# units — no DPI scaling is needed in the input layer.

# Action constants (shared by GLFW and SDL3 backends)
_PRESS = 1
_RELEASE = 0
_REPEAT = 2

# Key code → string name mapping.  Integer values match both GLFW and SDL3
# backends (same mapping as web_app.py _KEY_MAP for the Pyodide path).
_KEY_MAP: dict[int, str] = {
    # Letters
    65: "a", 66: "b", 67: "c", 68: "d", 69: "e", 70: "f", 71: "g", 72: "h",
    73: "i", 74: "j", 75: "k", 76: "l", 77: "m", 78: "n", 79: "o", 80: "p",
    81: "q", 82: "r", 83: "s", 84: "t", 85: "u", 86: "v", 87: "w", 88: "x",
    89: "y", 90: "z",
    # Numbers
    48: "0", 49: "1", 50: "2", 51: "3", 52: "4", 53: "5", 54: "6", 55: "7", 56: "8", 57: "9",
    # Special keys
    32: "space", 257: "enter", 256: "escape", 258: "tab", 259: "backspace",
    261: "delete", 260: "insert", 268: "home", 269: "end", 266: "pageup", 267: "pagedown",
    # Arrow keys
    265: "up", 264: "down", 263: "left", 262: "right",
    # Modifiers
    340: "shift", 344: "shift", 341: "ctrl", 345: "ctrl", 342: "alt", 346: "alt",
    # Punctuation / symbols
    47: "/", 92: "\\", 45: "-", 61: "=", 91: "[", 93: "]",
    59: ";", 39: "'", 44: ",", 46: ".", 96: "`",
    # Function keys
    290: "f1", 291: "f2", 292: "f3", 293: "f4", 294: "f5", 295: "f6",
    296: "f7", 297: "f8", 298: "f9", 299: "f10", 300: "f11", 301: "f12",
}


[docs] def key_callback(key: int, action: int, mods: int) -> None: """Process key event and update Input state. Args: key: Platform key code (integer, shared by GLFW/SDL3). action: _PRESS (1), _RELEASE (0), or _REPEAT (2). mods: Modifier bits (shift, ctrl, alt, etc.) """ key_name = _KEY_MAP.get(key) if not key_name: return if action == _PRESS: if not Input._keys.get(key_name): Input._keys_just_pressed[key_name] = True Input._keys[key_name] = True Input._on_key(key, True) elif action == _RELEASE: Input._keys[key_name] = False Input._keys_just_released[key_name] = True Input._on_key(key, False)
[docs] def mouse_button_callback(button: int, action: int, mods: int) -> None: """Process mouse button event. Args: button: Mouse button index (0=left, 1=right, 2=middle). action: _PRESS (1) or _RELEASE (0). mods: Modifier bits. """ from simvx.core import MouseButton as MB btn = f"mouse_{button + 1}" if action == _PRESS: if not Input._keys.get(btn): Input._keys_just_pressed[btn] = True Input._keys[btn] = True mb_map = {0: MB.LEFT, 1: MB.RIGHT, 2: MB.MIDDLE} if button in mb_map: Input._on_mouse_button(int(mb_map[button]), True) elif action == _RELEASE: Input._keys[btn] = False Input._keys_just_released[btn] = True mb_map = {0: MB.LEFT, 1: MB.RIGHT, 2: MB.MIDDLE} if button in mb_map: Input._on_mouse_button(int(mb_map[button]), False)
@dataclass class _UICallbacks: char: Callable | None = None key: Callable | None = None mouse: Callable | None = None motion: Callable | None = None scroll: Callable | None = None touch: Callable | None = None _ui = _UICallbacks() # Track which finger is the "primary" pointer (first finger to touch down) _primary_finger: int | None = None
[docs] def set_ui_callbacks(*, char=None, key=None, mouse=None, motion=None, scroll=None, touch=None): """Install UI routing callbacks (called by App._run_with_tree).""" _ui.char = char _ui.key = key _ui.mouse = mouse _ui.motion = motion _ui.scroll = scroll _ui.touch = touch
[docs] def clear_ui_callbacks(): """Reset all UI routing callbacks to None.""" global _primary_finger _primary_finger = None set_ui_callbacks()
[docs] def reset_primary_finger(): """Reset primary finger tracking (call on scene change).""" global _primary_finger _primary_finger = None
[docs] def cursor_pos_callback(x: float, y: float) -> None: """Process cursor position event and update Input state.""" old = Input._mouse_pos Input._mouse_pos = (x, y) Input._mouse_delta = (x - old[0], y - old[1]) # Route motion to UI immediately (critical for drag responsiveness) if _ui.motion: _ui.motion((x, y))
[docs] def scroll_callback(x_offset: float, y_offset: float) -> None: """Process scroll event and update Input state.""" Input._scroll_delta = ( Input._scroll_delta[0] + x_offset, Input._scroll_delta[1] + y_offset, ) # Route to UI as key events (scroll_up / scroll_down) if _ui.scroll: if y_offset > 0: _ui.scroll("scroll_up", True) elif y_offset < 0: _ui.scroll("scroll_down", True)
[docs] def char_callback(codepoint: int) -> None: """Process character input event (typed characters).""" if _ui.char: _ui.char(chr(codepoint))
[docs] def key_callback_with_ui(key: int, action: int, mods: int) -> None: """Key callback that also routes key events to UI.""" key_callback(key, action, mods) if _ui.key: key_name = _KEY_MAP.get(key) if key_name: pressed = action in (_PRESS, _REPEAT) _ui.key(key_name, pressed)
[docs] def mouse_button_callback_with_ui(button: int, action: int, mods: int) -> None: """Mouse callback that also routes clicks to UI.""" mouse_button_callback(button, action, mods) if _ui.mouse: pressed = action == _PRESS _ui.mouse(button + 1, pressed)
[docs] def touch_callback(finger_id: int, action: int, x: float, y: float, pressure: float) -> None: """Process touch event — updates Input state AND routes primary finger through UI. The primary finger (first finger to touch down) is emulated as mouse input so all existing UI widgets work with touch out of the box. Multi-touch data still flows through Input._touches for game code and GestureRecognizer. Args: finger_id: Unique finger identifier. action: 0=down, 1=up, 2=move. x, y: Position in window pixel coordinates. pressure: Touch pressure 0.0-1.0. """ global _primary_finger # Always store raw touch for game code / GestureRecognizer Input._update_touch(finger_id, action, x, y, pressure) # Route multi-touch to UI (for controls with touch_mode="multi") if _ui.touch: _ui.touch(finger_id, action, x, y) # Primary finger emulates mouse for standard UI interaction if action == 0: # down if _primary_finger is None: _primary_finger = finger_id Input._mouse_pos = (x, y) Input._mouse_delta = (0.0, 0.0) if _ui.motion: _ui.motion((x, y)) if _ui.mouse: _ui.mouse(1, True) # button=1 (left), pressed=True elif action == 1: # up if finger_id == _primary_finger: _primary_finger = None Input._mouse_pos = (x, y) if _ui.motion: _ui.motion((x, y)) if _ui.mouse: _ui.mouse(1, False) # left-button release elif action == 2: # move if finger_id == _primary_finger: old = Input._mouse_pos Input._mouse_pos = (x, y) Input._mouse_delta = (x - old[0], y - old[1]) if _ui.motion: _ui.motion((x, y))
[docs] def poll_gamepads(window_backend: Any) -> None: """Poll gamepads via the window backend and update Input state.""" if not hasattr(window_backend, "poll_gamepads"): return for pad_id, buttons, axes in window_backend.poll_gamepads(): Input._update_gamepad(pad_id, buttons, axes)
__all__ = [ "key_callback", "mouse_button_callback", "cursor_pos_callback", "scroll_callback", "char_callback", "poll_gamepads", "key_callback_with_ui", "mouse_button_callback_with_ui", "set_ui_callbacks", "clear_ui_callbacks", "reset_primary_finger", ]