Source code for simvx.graphics.platform._sdl3

"""SDL3 windowing backend with multitouch support."""


from __future__ import annotations

import ctypes
import logging
from collections.abc import Callable
from typing import Any

__all__ = ["Sdl3Backend"]

log = logging.getLogger(__name__)


[docs] class Sdl3Backend: """WindowBackend implementation using SDL3. Provides keyboard, mouse, and multitouch input. Touch events are delivered with window-relative coordinates via the touch_callback. """ def __init__(self) -> None: import sdl3 self._sdl3 = sdl3 self._window: Any = None self._width = 0 self._height = 0 self._should_close = False self._paused = False self._lifecycle_callback: Callable[[str], None] | None = None self._key_callback: Callable[[int, int, int], None] | None = None self._mouse_button_callback: Callable[[int, int, int], None] | None = None self._cursor_pos_callback: Callable[[float, float], None] | None = None self._scroll_callback: Callable[[float, float], None] | None = None self._char_callback: Callable[[int], None] | None = None self._touch_callback: Callable[[int, int, float, float, float], None] | None = None self._cursor_x = 0.0 self._cursor_y = 0.0 # SDL scancode → GLFW key code mapping (subset used by SimVX) self._key_map = self._build_key_map() def _build_key_map(self) -> dict[int, int]: """Map SDL3 scancodes to GLFW key codes for compatibility.""" S = self._sdl3 m: dict[int, int] = {} # Letters A-Z: SDL scancode 4-29 → GLFW 65-90 for i in range(26): m[4 + i] = 65 + i # Digits 1-0: SDL 30-39 → GLFW 49-57, 48 for i in range(9): m[30 + i] = 49 + i m[39] = 48 # 0 # Function keys F1-F12: SDL 58-69 → GLFW 290-301 for i in range(12): m[58 + i] = 290 + i # Special keys specials = { S.SDL_SCANCODE_RETURN: 257, S.SDL_SCANCODE_ESCAPE: 256, S.SDL_SCANCODE_BACKSPACE: 259, S.SDL_SCANCODE_TAB: 258, S.SDL_SCANCODE_SPACE: 32, S.SDL_SCANCODE_MINUS: 45, S.SDL_SCANCODE_EQUALS: 61, S.SDL_SCANCODE_LEFTBRACKET: 91, S.SDL_SCANCODE_RIGHTBRACKET: 93, S.SDL_SCANCODE_BACKSLASH: 92, S.SDL_SCANCODE_SEMICOLON: 59, S.SDL_SCANCODE_APOSTROPHE: 39, S.SDL_SCANCODE_GRAVE: 96, S.SDL_SCANCODE_COMMA: 44, S.SDL_SCANCODE_PERIOD: 46, S.SDL_SCANCODE_SLASH: 47, S.SDL_SCANCODE_RIGHT: 262, S.SDL_SCANCODE_LEFT: 263, S.SDL_SCANCODE_DOWN: 264, S.SDL_SCANCODE_UP: 265, S.SDL_SCANCODE_PAGEUP: 266, S.SDL_SCANCODE_PAGEDOWN: 267, S.SDL_SCANCODE_HOME: 268, S.SDL_SCANCODE_END: 269, S.SDL_SCANCODE_INSERT: 260, S.SDL_SCANCODE_DELETE: 261, S.SDL_SCANCODE_LSHIFT: 340, S.SDL_SCANCODE_LCTRL: 341, S.SDL_SCANCODE_LALT: 342, S.SDL_SCANCODE_RSHIFT: 344, S.SDL_SCANCODE_RCTRL: 345, S.SDL_SCANCODE_RALT: 346, } m.update(specials) return m
[docs] def create_window(self, width: int, height: int, title: str, *, visible: bool = True) -> None: S = self._sdl3 if not S.SDL_Init(S.SDL_INIT_VIDEO | S.SDL_INIT_EVENTS): raise RuntimeError(f"SDL_Init failed: {S.SDL_GetError()}") flags = S.SDL_WINDOW_VULKAN | S.SDL_WINDOW_RESIZABLE if not visible: flags |= S.SDL_WINDOW_HIDDEN self._window = S.SDL_CreateWindow(title.encode(), width, height, flags) if not self._window: raise RuntimeError(f"SDL_CreateWindow failed: {S.SDL_GetError()}") self._width = width self._height = height
[docs] def create_graphics_surface(self, instance: Any) -> Any: S = self._sdl3 import vulkan as vk # Convert cffi VkInstance to SDL3's ctypes VkInstance VkInstance = S.SDL_vulkan.VkInstance VkSurfaceKHR = S.SDL_vulkan.VkSurfaceKHR instance_int = int(vk.ffi.cast("uintptr_t", instance)) sdl_instance = VkInstance(instance_int) surface_out = VkSurfaceKHR(0) ok = S.SDL_Vulkan_CreateSurface(self._window, sdl_instance, None, ctypes.byref(surface_out)) if not ok: raise RuntimeError(f"SDL_Vulkan_CreateSurface failed: {S.SDL_GetError()}") # Convert ctypes surface handle back to cffi VkSurfaceKHR return vk.ffi.cast("VkSurfaceKHR", surface_out.value)
[docs] def get_required_instance_extensions(self) -> list[str]: S = self._sdl3 count = ctypes.c_uint32(0) exts = S.SDL_Vulkan_GetInstanceExtensions(ctypes.byref(count)) if not exts: return [] return [exts[i].decode() for i in range(count.value)]
[docs] def poll_events(self) -> None: S = self._sdl3 event = S.SDL_Event() # GLFW-compatible action constants PRESS, RELEASE, REPEAT = 1, 0, 2 # Touch action constants (for touch_callback) TOUCH_DOWN, TOUCH_UP, TOUCH_MOVE = 0, 1, 2 while S.SDL_PollEvent(ctypes.byref(event)): etype = event.type if etype == S.SDL_EVENT_QUIT: self._should_close = True elif etype in (S.SDL_EVENT_KEY_DOWN, S.SDL_EVENT_KEY_UP): ke = event.key scancode = ke.scancode glfw_key = self._key_map.get(scancode) if glfw_key is not None and self._key_callback: action = PRESS if etype == S.SDL_EVENT_KEY_DOWN else RELEASE if etype == S.SDL_EVENT_KEY_DOWN and ke.repeat: action = REPEAT mods = 0 self._key_callback(glfw_key, action, mods) elif etype in (S.SDL_EVENT_MOUSE_BUTTON_DOWN, S.SDL_EVENT_MOUSE_BUTTON_UP): mb = event.button # SDL button 1=left,2=middle,3=right → GLFW 0=left,1=right,2=middle btn_map = {1: 0, 2: 2, 3: 1} glfw_btn = btn_map.get(mb.button, mb.button - 1) action = PRESS if etype == S.SDL_EVENT_MOUSE_BUTTON_DOWN else RELEASE if self._mouse_button_callback: self._mouse_button_callback(glfw_btn, action, 0) elif etype == S.SDL_EVENT_MOUSE_MOTION: mm = event.motion self._cursor_x = mm.x self._cursor_y = mm.y if self._cursor_pos_callback: self._cursor_pos_callback(float(mm.x), float(mm.y)) elif etype == S.SDL_EVENT_MOUSE_WHEEL: mw = event.wheel if self._scroll_callback: self._scroll_callback(float(mw.x), float(mw.y)) elif etype == S.SDL_EVENT_TEXT_INPUT: ti = event.text text = ti.text.decode("utf-8", errors="replace") if isinstance(ti.text, bytes) else str(ti.text) if self._char_callback: for ch in text: self._char_callback(ord(ch)) elif etype in (S.SDL_EVENT_FINGER_DOWN, S.SDL_EVENT_FINGER_UP, S.SDL_EVENT_FINGER_MOTION): tf = event.tfinger # SDL gives normalized 0-1 coords relative to window wx = tf.x * self._width wy = tf.y * self._height if etype == S.SDL_EVENT_FINGER_DOWN: action = TOUCH_DOWN elif etype == S.SDL_EVENT_FINGER_UP: action = TOUCH_UP else: action = TOUCH_MOVE if self._touch_callback: self._touch_callback(int(tf.fingerID), action, wx, wy, tf.pressure) # Android/mobile lifecycle events elif etype == getattr(S, "SDL_EVENT_DID_ENTER_BACKGROUND", 0): self._paused = True if self._lifecycle_callback: self._lifecycle_callback("paused") elif etype == getattr(S, "SDL_EVENT_DID_ENTER_FOREGROUND", 0): self._paused = False if self._lifecycle_callback: self._lifecycle_callback("resumed")
[docs] def should_close(self) -> bool: return self._should_close
[docs] def get_framebuffer_size(self) -> tuple[int, int]: if not self._window: return self._width, self._height S = self._sdl3 w, h = ctypes.c_int(), ctypes.c_int() S.SDL_GetWindowSizeInPixels(self._window, ctypes.byref(w), ctypes.byref(h)) return w.value, h.value
[docs] def get_window_size(self) -> tuple[int, int]: if not self._window: return self._width, self._height S = self._sdl3 w, h = ctypes.c_int(), ctypes.c_int() S.SDL_GetWindowSize(self._window, ctypes.byref(w), ctypes.byref(h)) self._width = w.value self._height = h.value return w.value, h.value
[docs] def get_content_scale(self) -> tuple[float, float]: if not self._window: return (1.0, 1.0) S = self._sdl3 scale = S.SDL_GetWindowDisplayScale(self._window) return (scale, scale)
[docs] def set_title(self, title: str) -> None: if not self._window: return S = self._sdl3 S.SDL_SetWindowTitle(self._window, title.encode("utf-8"))
[docs] def set_key_callback(self, callback: Callable[[int, int, int], None] | None) -> None: self._key_callback = callback
[docs] def set_mouse_button_callback(self, callback: Callable[[int, int, int], None] | None) -> None: self._mouse_button_callback = callback
[docs] def set_cursor_pos_callback(self, callback: Callable[[float, float], None] | None) -> None: self._cursor_pos_callback = callback
[docs] def set_scroll_callback(self, callback: Callable[[float, float], None] | None) -> None: self._scroll_callback = callback
[docs] def set_char_callback(self, callback: Callable[[int], None] | None) -> None: self._char_callback = callback
@property def paused(self) -> bool: """Whether the app is paused (backgrounded on mobile).""" return self._paused
[docs] def set_lifecycle_callback(self, callback: Callable[[str], None] | None) -> None: """Set lifecycle event callback. Receives 'paused' or 'resumed'.""" self._lifecycle_callback = callback
[docs] def set_touch_callback(self, callback: Callable[[int, int, float, float, float], None] | None) -> None: """Set touch event callback. Args: callback: (finger_id, action, x, y, pressure) where action is 0=down, 1=up, 2=move. x,y are in window pixel coordinates. """ self._touch_callback = callback
[docs] def get_cursor_pos(self) -> tuple[float, float]: return self._cursor_x, self._cursor_y
[docs] def set_cursor_shape(self, shape: int) -> None: """Set cursor shape. 0=arrow, 1=ibeam, 2=crosshair, 3=hand, 4=hresize, 5=vresize.""" S = self._sdl3 shape_map = { 0: S.SDL_SYSTEM_CURSOR_DEFAULT, 1: S.SDL_SYSTEM_CURSOR_TEXT, 2: S.SDL_SYSTEM_CURSOR_CROSSHAIR, 3: S.SDL_SYSTEM_CURSOR_POINTER, 4: S.SDL_SYSTEM_CURSOR_EW_RESIZE, 5: S.SDL_SYSTEM_CURSOR_NS_RESIZE, } sdl_shape = shape_map.get(shape, S.SDL_SYSTEM_CURSOR_DEFAULT) cursor = S.SDL_CreateSystemCursor(sdl_shape) if cursor: S.SDL_SetCursor(cursor)
[docs] def set_window_size(self, width: int, height: int) -> None: S = self._sdl3 if self._window: S.SDL_SetWindowSize(self._window, width, height) self._width = width self._height = height
[docs] def request_close(self) -> None: self._should_close = True
[docs] def poll_gamepads(self) -> list[tuple[int, dict[str, bool], dict[str, float]]]: """Poll all connected gamepads. Returns list of (id, buttons, axes).""" S = self._sdl3 results = [] count = ctypes.c_int(0) joysticks = S.SDL_GetGamepads(ctypes.byref(count)) if not joysticks or count.value == 0: return results for i in range(count.value): pad_id = joysticks[i] gamepad = S.SDL_OpenGamepad(pad_id) if not gamepad: continue buttons = { "a": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_SOUTH)), "b": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_EAST)), "x": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_WEST)), "y": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_NORTH)), "lb": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER)), "rb": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER)), "back": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_BACK)), "start": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_START)), "guide": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_GUIDE)), "l3": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_LEFT_STICK)), "r3": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_RIGHT_STICK)), "dpad_up": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_DPAD_UP)), "dpad_right": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_DPAD_RIGHT)), "dpad_down": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_DPAD_DOWN)), "dpad_left": bool(S.SDL_GetGamepadButton(gamepad, S.SDL_GAMEPAD_BUTTON_DPAD_LEFT)), } axes = { "left_x": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_LEFTX) / 32767.0, "left_y": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_LEFTY) / 32767.0, "right_x": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_RIGHTX) / 32767.0, "right_y": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_RIGHTY) / 32767.0, "lt": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_LEFT_TRIGGER) / 32767.0, "rt": S.SDL_GetGamepadAxis(gamepad, S.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767.0, } results.append((pad_id, buttons, axes)) S.SDL_CloseGamepad(gamepad) S.SDL_free(joysticks) return results
[docs] def destroy(self) -> None: S = self._sdl3 if self._window: S.SDL_DestroyWindow(self._window) self._window = None S.SDL_Quit()