550 lines
17 KiB
PHP
550 lines
17 KiB
PHP
<?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;
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
}
|