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; // HiDPI‑Scaling 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); } } } }