"""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_cursor_pos_callback(self, callback: Callable[[float, float], None] | None) -> None:
self._cursor_pos_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()