sdl3/src/Ui/Window.php
2025-11-14 21:12:46 +01:00

550 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace PHPNative\Ui;
use PHPNative\Framework\Profiler;
use PHPNative\Framework\TextRenderer;
class Window
{
private mixed $window = null;
private mixed $renderer = null;
private int $windowId = 0;
private null|Component $rootComponent = null;
private null|TextRenderer $textRenderer;
private float $mouseX = 0;
private float $mouseY = 0;
private Viewport $viewport;
private bool $shouldBeReLayouted = true;
private float $pixelRatio = 1.0;
private float $uiScale = 1.0;
private bool $shouldClose = false;
private $onResize = null;
private $onFpsChange = null;
private float $lastFpsUpdate = 0.0;
private int $frameCounter = 0;
private float $currentFps = 0.0;
public function __construct(
private string $title,
private int $width = 800,
private int $height = 600,
private int $x = 100,
private int $y = 100,
) {
// Initialize SDL if not already done
static $sdlInitialized = false;
if (!$sdlInitialized) {
if (!sdl_init(SDL_INIT_VIDEO)) {
throw new \Exception('Failed to initialize SDL: ' . sdl_get_error());
}
// Initialize TTF
if (!ttf_init()) {
throw new \Exception('Failed to initialize TTF: ' . sdl_get_error());
}
$sdlInitialized = true;
}
// Create window with resizable flag for normal window decorations
$flags = SDL_WINDOW_RESIZABLE;
$this->window = sdl_create_window($title, $width, $height, $flags);
if (!$this->window) {
throw new \Exception('Failed to create window: ' . sdl_get_error());
}
// Get window ID for event routing
$this->windowId = sdl_get_window_id($this->window);
// Enable text input for this window
sdl_start_text_input($this->window);
// Create renderer
$this->renderer = sdl_create_renderer($this->window);
if (!$this->renderer) {
throw new \Exception('Failed to create renderer: ' . sdl_get_error());
}
// Initialize text renderer
$this->textRenderer = new TextRenderer($this->renderer);
if (!$this->textRenderer->init()) {
error_log('Warning: Failed to initialize text renderer. Text rendering will not be available.');
}
// Get actual window size
$size = sdl_get_window_size($this->window);
$this->width = $size[0];
$this->height = $size[1];
$this->viewport = new Viewport(
windowWidth: $this->width,
windowHeight: $this->height,
width: $this->width,
height: $this->height,
);
$this->updatePixelRatio();
$this->lastFpsUpdate = microtime(true);
}
private function updatePixelRatio(): void
{
$this->pixelRatio = 1.0;
// HiDPIScaling ist optional und wird nur aktiviert,
// wenn die Umgebungsvariable PHPNATIVE_ENABLE_HIDPI=1 gesetzt ist.
$enableHiDpi = getenv('PHPNATIVE_ENABLE_HIDPI');
if ($enableHiDpi !== '1') {
if ($this->textRenderer) {
$this->textRenderer->setPixelRatio($this->pixelRatio);
}
return;
}
if (!function_exists('sdl_get_window_size') || !$this->window) {
return;
}
$windowSize = sdl_get_window_size($this->window);
$pixelSize = null;
if (function_exists('sdl_get_window_size_in_pixels')) {
$pixelSize = sdl_get_window_size_in_pixels($this->window);
}
if ((!is_array($pixelSize) || count($pixelSize) < 2) && function_exists('sdl_get_renderer_output_size')) {
$pixelSize = sdl_get_renderer_output_size($this->renderer);
}
if (is_array($windowSize) && is_array($pixelSize)) {
$logicalWidth = max(1, (int) ($windowSize[0] ?? 1));
$logicalHeight = max(1, (int) ($windowSize[1] ?? 1));
$pixelWidth = max(1, (int) ($pixelSize[0] ?? 1));
$pixelHeight = max(1, (int) ($pixelSize[1] ?? 1));
$ratioX = $pixelWidth / $logicalWidth;
$ratioY = $pixelHeight / $logicalHeight;
$computed = max($ratioX, $ratioY);
if ($computed > 0) {
$this->pixelRatio = max(1.0, $computed);
}
}
if ($this->textRenderer) {
$this->textRenderer->setPixelRatio($this->pixelRatio);
}
}
public function getPixelRatio(): float
{
return $this->pixelRatio;
}
public function getUiScale(): float
{
return $this->uiScale;
}
public function setRoot(Component $component): self
{
if ($this->rootComponent !== null) {
$this->rootComponent->detachFromWindow();
}
$this->rootComponent = $component;
$this->rootComponent->attachToWindow($this);
$this->shouldBeReLayouted = true;
// Layout immediately to prevent black screen on first render
// This is especially important for windows created during event handling
$this->layout();
return $this;
}
public function getRoot(): null|Component
{
return $this->rootComponent;
}
public function getTitle(): string
{
return $this->title;
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
public function shouldClose(): bool
{
return $this->shouldClose;
}
public function close(): void
{
$this->shouldClose = true;
}
public function getWindowResource(): mixed
{
return $this->window;
}
public function getRendererResource(): mixed
{
return $this->renderer;
}
public function getWindowId(): int
{
return $this->windowId;
}
/**
* Process a single event for this window
* Called by Application with pre-filtered events
*/
public function processEvent(array $event): void
{
// Debug output - can be removed later
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) {
$eventTypes = [
SDL_EVENT_QUIT => 'QUIT',
SDL_EVENT_WINDOW_CLOSE_REQUESTED => 'WINDOW_CLOSE_REQUESTED',
SDL_EVENT_KEY_DOWN => 'KEY_DOWN',
SDL_EVENT_MOUSE_BUTTON_DOWN => 'MOUSE_BUTTON_DOWN',
SDL_EVENT_MOUSE_BUTTON_UP => 'MOUSE_BUTTON_UP',
SDL_EVENT_MOUSE_MOTION => 'MOUSE_MOTION',
SDL_EVENT_WINDOW_RESIZED => 'WINDOW_RESIZED',
];
$typeName = $eventTypes[$event['type']] ?? ('UNKNOWN(' . $event['type'] . ')');
error_log("[{$this->title}] (ID:{$this->windowId}) Event: {$typeName}");
}
switch ($event['type']) {
case SDL_EVENT_QUIT:
case SDL_EVENT_WINDOW_CLOSE_REQUESTED:
$this->shouldClose = true;
break;
case SDL_EVENT_KEY_DOWN:
$keycode = $event['keycode'] ?? 0;
$mod = $event['mod'] ?? 0;
// Propagate key event to root component
if ($this->rootComponent) {
$this->rootComponent->handleKeyDown($keycode, $mod);
}
break;
case SDL_EVENT_TEXT_INPUT:
$text = $event['text'] ?? '';
// Propagate text input to root component
if ($this->rootComponent && !empty($text)) {
$this->rootComponent->handleTextInput($text);
}
break;
case SDL_EVENT_WINDOW_RESIZED:
// Update window dimensions (from event data)
$newWidth = $event['data1'] ?? $this->width;
$newHeight = $event['data2'] ?? $this->height;
$this->width = $newWidth;
$this->height = $newHeight;
// Update text renderer framebuffer / DPI
if ($this->textRenderer && $this->textRenderer->isInitialized()) {
$this->textRenderer->updateFramebuffer($newWidth, $newHeight);
}
$this->updatePixelRatio();
$this->viewport->x = 0;
$this->viewport->y = 0;
$this->viewport->windowWidth = $newWidth;
$this->viewport->width = $newWidth;
$this->viewport->height = $newHeight;
$this->viewport->windowHeight = $newHeight;
$this->shouldBeReLayouted = true;
if ($this->onResize) {
($this->onResize)($this);
}
break;
case SDL_EVENT_MOUSE_MOTION:
$newMouseX = (float) ($event['x'] ?? 0);
$newMouseY = (float) ($event['y'] ?? 0);
$this->mouseX = $newMouseX;
$this->mouseY = $newMouseY;
// Check overlays first (in reverse z-index order - highest first)
if ($this->rootComponent) {
$overlays = $this->rootComponent->collectOverlays();
usort($overlays, fn($a, $b) => $b->getZIndex() <=> $a->getZIndex());
$handled = false;
foreach ($overlays as $overlay) {
if ($overlay->isVisible()) {
$overlay->handleMouseMove($this->mouseX, $this->mouseY);
$handled = true;
break;
}
}
// If overlay is visible, send fake event to background to clear hover states
if ($handled) {
$this->rootComponent->handleMouseMove(-1000, -1000);
} else {
// If no overlay handled it, propagate to normal components
$this->rootComponent->handleMouseMove($this->mouseX, $this->mouseY);
}
}
break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
$button = $event['button'] ?? 0;
// Check overlays first (in reverse z-index order - highest first)
if ($this->rootComponent) {
$overlays = $this->rootComponent->collectOverlays();
usort($overlays, fn($a, $b) => $b->getZIndex() <=> $a->getZIndex());
$handled = false;
foreach ($overlays as $overlay) {
if (
$overlay->isVisible() &&
$overlay->handleMouseClick($this->mouseX, $this->mouseY, $button)
) {
$handled = true;
break;
}
}
// If no overlay handled it, propagate to normal components
if (!$handled) {
$this->rootComponent->handleMouseClick($this->mouseX, $this->mouseY, $button);
}
}
break;
case SDL_EVENT_MOUSE_BUTTON_UP:
$button = $event['button'] ?? 0;
// Propagate release to root component
if ($this->rootComponent) {
$this->rootComponent->handleMouseRelease($this->mouseX, $this->mouseY, $button);
}
break;
case SDL_EVENT_MOUSE_WHEEL:
$deltaY = $event['y'] ?? 0;
// Check overlays first (in reverse z-index order - highest first)
if ($this->rootComponent) {
$overlays = $this->rootComponent->collectOverlays();
usort($overlays, fn($a, $b) => $b->getZIndex() <=> $a->getZIndex());
$handled = false;
foreach ($overlays as $overlay) {
if (
$overlay->isVisible() &&
$overlay->handleMouseWheel($this->mouseX, $this->mouseY, $deltaY)
) {
$handled = true;
break;
}
}
// If no overlay handled it, propagate to normal components
if (!$handled) {
$this->rootComponent->handleMouseWheel($this->mouseX, $this->mouseY, $deltaY);
}
}
break;
}
}
/**
* Update mouse position and hover states
* This is called every frame to ensure hover works even without focus
*/
public function updateMousePosition(): void
{
// With SDL3, mouse position is tracked through SDL_EVENT_MOUSE_MOTION events
// This method is kept for compatibility but does nothing
// Hover states are updated in handleEvents() via SDL_EVENT_MOUSE_MOTION
}
/**
* Update window state
*/
public function update(): void
{
if ($this->rootComponent) {
$this->rootComponent->update();
}
}
/**
* Layout components
*/
public function layout(): bool
{
if ($this->rootComponent && $this->shouldBeReLayouted) {
// Always layout for now - we can optimize this later by tracking what changed
// The shouldBeReLayouted flag was preventing proper layout updates
$this->rootComponent->setViewport($this->viewport);
$this->rootComponent->layout($this->textRenderer);
$this->shouldBeReLayouted = false;
return true;
}
return false;
}
/**
* Render the window
*
* Note: With SDL3, we must clear and redraw everything each frame.
* The optimization comes from dirty tracking which prevents unnecessary
* state updates and layout recalculations, not from skipping rendering.
*/
public function render(): void
{
if ($this->rootComponent === null) {
return;
}
Profiler::start('window_render');
if ($this->shouldBeReLayouted) {
Profiler::start('layout');
$this->layout();
Profiler::end('layout');
}
sdl_set_render_draw_color($this->renderer, 255, 255, 255, 255);
sdl_render_clear($this->renderer);
// Build texture cache for components that need it (after layout)
Profiler::start('buildTextureCacheRecursive');
$this->rootComponent->buildTextureCacheRecursive($this->renderer, $this->textRenderer);
Profiler::end('buildTextureCacheRecursive');
Profiler::start('render_tree');
$this->rootComponent->render($this->renderer, $this->textRenderer);
$this->rootComponent->renderContent($this->renderer, $this->textRenderer);
Profiler::end('render_tree');
$overlays = $this->rootComponent->collectOverlays();
if (!empty($overlays)) {
usort($overlays, static fn($a, $b) => $a->getZIndex() <=> $b->getZIndex());
foreach ($overlays as $overlay) {
if (!$overlay->isVisible()) {
continue;
}
$overlay->render($this->renderer, $this->textRenderer);
$overlay->renderContent($this->renderer, $this->textRenderer);
}
}
$this->rootComponent->markClean();
$this->updateFps();
sdl_render_present($this->renderer);
Profiler::end('window_render');
// Report profiling data periodically
Profiler::report();
}
/**
* Clean up resources
*/
public function cleanup(): void
{
if ($this->textRenderer) {
$this->textRenderer->free();
$this->textRenderer = null; // Prevent destructor from running again
}
}
/**
* Destroy the window and all resources
* This explicitly closes the SDL window
*/
public function destroy(): void
{
// First clean up text renderer
$this->cleanup();
// Explicitly destroy SDL renderer and window
if ($this->renderer) {
sdl_destroy_renderer($this->renderer);
$this->renderer = null;
}
if ($this->window) {
sdl_destroy_window($this->window);
$this->window = null;
}
}
public function setShouldBeReLayouted(bool $shouldBeReLayouted): void
{
$this->shouldBeReLayouted = $shouldBeReLayouted;
}
public function getViewport(): Viewport
{
return $this->viewport;
}
public function setOnResize(callable $callback): void
{
$this->onResize = $callback;
}
public function setOnFpsChange(null|callable $callback): void
{
$this->onFpsChange = $callback;
}
public function getCurrentFps(): float
{
return $this->currentFps;
}
private function updateFps(): void
{
$this->frameCounter++;
$now = microtime(true);
$elapsed = $now - $this->lastFpsUpdate;
if ($elapsed >= 1.0) {
$this->currentFps = $this->frameCounter / $elapsed;
$this->frameCounter = 0;
$this->lastFpsUpdate = $now;
if ($this->onFpsChange !== null) {
($this->onFpsChange)($this->currentFps);
}
}
}
}