"""PySide6 windowing backend."""
from __future__ import annotations
import logging
import platform
from collections.abc import Callable
from typing import Any
import vulkan as vk
log = logging.getLogger(__name__)
__all__ = ["QtBackend"]
[docs]
class QtBackend:
"""WindowBackend implementation using PySide6."""
def __init__(self) -> None:
from PySide6.QtCore import Qt
from PySide6.QtGui import QSurfaceFormat, QWindow
from PySide6.QtWidgets import QApplication
self._window: QWindow | None = None
self._closed: bool = False
self._mouse_button_callback: Callable[[int, int, int], None] | None = None
self._QWindow = QWindow
self._QApplication = QApplication
self._QSurfaceFormat = QSurfaceFormat
self._Qt = Qt
# Ensure Qt application exists
if not QApplication.instance():
QApplication([])
[docs]
def create_window(self, width: int, height: int, title: str) -> None:
"""Create a QWindow with Vulkan surface."""
fmt = self._QSurfaceFormat()
# Note: setRenderingAPI may not be available in all PySide6 versions
if hasattr(fmt, "setRenderingAPI"):
fmt.setRenderingAPI(self._QSurfaceFormat.RenderingAPI.OpenGL)
self._QSurfaceFormat.setDefaultFormat(fmt)
self._window = self._QWindow()
self._window.setTitle(title)
self._window.setGeometry(100, 100, width, height)
# Set surface type for Vulkan
if hasattr(self._QWindow, "VulkanSurface"):
self._window.setSurfaceType(self._QWindow.VulkanSurface)
self._window.show()
# Connect close event
if hasattr(self._window, "destroyed"):
self._window.destroyed.connect(self._on_window_closed)
def _on_window_closed(self) -> None:
"""Handle window close event."""
self._closed = True
[docs]
def create_graphics_surface(self, instance: Any) -> Any:
"""Create a Vulkan surface from the QWindow."""
if not self._window:
raise RuntimeError("Window not created")
native_handle = self._window.winId()
system = platform.system()
if system == "Linux":
# Linux: use XCB or Xlib
try:
# Try XCB first
import xcb # noqa: F401
conn = xcb.xcb.connect()
surface_info = vk.VkXcbSurfaceCreateInfoKHR(
connection=conn.connection,
window=native_handle,
)
fn = vk.vkGetInstanceProcAddr(instance, "vkCreateXcbSurfaceKHR")
if fn:
surface = vk.ffi.new("VkSurfaceKHR*")
vk.vkCreateXcbSurfaceKHR(instance, surface_info, None, surface)
return surface[0]
except (ImportError, Exception) as e:
log.debug("XCB surface creation failed: %s", e)
# Xlib fallback
try:
surface_info = vk.VkXlibSurfaceCreateInfoKHR(
dpy=vk.ffi.NULL,
window=native_handle,
)
fn = vk.vkGetInstanceProcAddr(instance, "vkCreateXlibSurfaceKHR")
if fn:
surface = vk.ffi.new("VkSurfaceKHR*")
vk.vkCreateXlibSurfaceKHR(instance, surface_info, None, surface)
return surface[0]
except Exception as e:
log.debug("Xlib surface creation failed: %s", e)
elif system == "Windows":
# Windows: use Win32
try:
surface_info = vk.VkWin32SurfaceCreateInfoKHR(
hinstance=vk.ffi.NULL,
hwnd=native_handle,
)
surface = vk.ffi.new("VkSurfaceKHR*")
vk.vkCreateWin32SurfaceKHR(instance, surface_info, None, surface)
return surface[0]
except Exception as e:
log.debug("Win32 surface creation failed: %s", e)
elif system == "Darwin":
# macOS: use Metal
try:
surface_info = vk.VkMetalSurfaceCreateInfoEXT(
pLayer=vk.ffi.NULL,
)
surface = vk.ffi.new("VkSurfaceKHR*")
vk.vkCreateMetalSurfaceEXT(instance, surface_info, None, surface)
return surface[0]
except Exception as e:
log.debug("Metal surface creation failed: %s", e)
raise RuntimeError(f"Unsupported platform for Vulkan surface creation: {system}")
[docs]
def get_required_instance_extensions(self) -> list[str]:
"""Return platform-specific Vulkan extensions needed."""
system = platform.system()
extensions = [
"VK_KHR_surface",
]
if system == "Linux":
extensions.append("VK_KHR_xcb_surface")
extensions.append("VK_KHR_xlib_surface")
elif system == "Windows":
extensions.append("VK_KHR_win32_surface")
elif system == "Darwin":
extensions.append("VK_EXT_metal_surface")
return extensions
[docs]
def poll_events(self) -> None:
"""Process Qt events."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app:
app.processEvents()
[docs]
def should_close(self) -> bool:
"""Check if window should close."""
return self._closed or self._window is None or not self._window.isVisible()
[docs]
def get_framebuffer_size(self) -> tuple[int, int]:
"""Get window framebuffer size."""
if not self._window:
return (0, 0)
return (self._window.width(), self._window.height())
def _make_mouse_press_handler(self) -> Any:
"""Create a mouse press event handler."""
def handler(event: Any) -> None:
if self._mouse_button_callback:
button = 0 # Left button
if hasattr(event, "button"):
from PySide6.QtCore import Qt
if event.button() == Qt.MouseButton.LeftButton:
button = 0
elif event.button() == Qt.MouseButton.RightButton:
button = 1
elif event.button() == Qt.MouseButton.MiddleButton:
button = 2
action = 1 # Press (Qt has different values but we simplify)
mods = 0
self._mouse_button_callback(button, action, mods)
return handler
[docs]
def get_cursor_pos(self) -> tuple[float, float]:
"""Get current cursor position."""
if not self._window:
return (0.0, 0.0)
from PySide6.QtGui import QCursor
pos = QCursor.pos()
if self._window:
local_pos = self._window.mapFromGlobal(pos)
return (float(local_pos.x()), float(local_pos.y()))
return (float(pos.x()), float(pos.y()))
[docs]
def set_window_size(self, width: int, height: int) -> None:
"""Resize the window."""
if self._window:
self._window.resize(width, height)
[docs]
def get_window_size(self) -> tuple[int, int]:
"""Get window size (may differ from framebuffer size on HiDPI)."""
if not self._window:
return (0, 0)
return (self._window.width(), self._window.height())
[docs]
def get_content_scale(self) -> tuple[float, float]:
if not self._window:
return (1.0, 1.0)
ratio = self._window.devicePixelRatio()
return (ratio, ratio)
[docs]
def set_title(self, title: str) -> None:
"""Update the window title bar."""
if self._window:
self._window.setWindowTitle(title)
[docs]
def set_key_callback(self, callback: Callable[[int, int, int], None] | None) -> None:
"""Register keyboard callback (key, action, mods)."""
self._key_callback = callback
[docs]
def set_cursor_pos_callback(self, callback: Callable[[float, float], None] | None) -> None:
"""Register cursor position callback."""
self._cursor_pos_callback = callback
[docs]
def set_char_callback(self, callback: Callable[[int], None] | None) -> None:
"""Register character input callback."""
self._char_callback = callback
[docs]
def set_cursor_shape(self, shape: int) -> None:
"""Set cursor shape. 0=arrow, 1=ibeam, 2=crosshair, 3=hand, 4=hresize, 5=vresize."""
if not self._window:
return
from PySide6.QtCore import Qt
from PySide6.QtGui import QCursor
shape_map = {
0: Qt.CursorShape.ArrowCursor,
1: Qt.CursorShape.IBeamCursor,
2: Qt.CursorShape.CrossCursor,
3: Qt.CursorShape.PointingHandCursor,
4: Qt.CursorShape.SizeHorCursor,
5: Qt.CursorShape.SizeVerCursor,
}
qt_shape = shape_map.get(shape, Qt.CursorShape.ArrowCursor)
self._window.setCursor(QCursor(qt_shape))
[docs]
def request_close(self) -> None:
"""Request the window to close."""
self._closed = True
if self._window:
self._window.close()
[docs]
def poll_gamepads(self) -> list[tuple[int, dict[str, bool], dict[str, float]]]:
"""Poll gamepads — not supported in Qt backend."""
return []
[docs]
def destroy(self) -> None:
"""Clean up Qt resources."""
if self._window:
self._window.close()
self._window = None