sdl3/src/Ui/Component.php
2025-11-09 20:24:05 +01:00

533 lines
15 KiB
PHP

<?php
namespace PHPNative\Ui;
use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Style\Margin;
use PHPNative\Tailwind\Style\MediaQueryEnum;
use PHPNative\Tailwind\Style\Padding;
use PHPNative\Tailwind\Style\StateEnum;
use PHPNative\Tailwind\StyleParser;
abstract class Component
{
protected int $id;
protected $children = [];
protected bool $visible = true;
protected bool $overlay = false;
protected bool $layoutDirty = true;
protected bool $renderDirty = true;
protected int $zIndex = 0;
protected StateEnum $currentState = StateEnum::normal;
protected null|Component $parent = null; // Reference to parent component
protected $cachedTexture = null; // SDL texture cache for this component
protected bool $useTextureCache = false; // Disabled by default, enable per component if needed
protected Viewport $viewport;
protected array $computedStyles = [];
protected Viewport $contentViewport;
protected null|Window $attachedWindow = null;
public function __construct(
protected string $style = '',
) {
// Initialize viewports with default values
// These will be properly set during layout()
$this->id = rand();
$this->viewport = new Viewport(
x: 0,
y: 0,
width: 0,
height: 0,
windowWidth: 800,
windowHeight: 600,
);
$this->contentViewport = clone $this->viewport;
}
/**
* Destructor - clean up resources
*/
public function __destruct()
{
$this->invalidateTextureCache();
}
/**
* Clean up component resources
*/
public function cleanup(): void
{
// Free texture cache
$this->invalidateTextureCache();
// Recursively cleanup children
foreach ($this->children as $child) {
if (method_exists($child, 'cleanup')) {
$child->cleanup();
}
}
}
public function setViewport(Viewport $viewport): void
{
$this->viewport = $viewport;
}
public function getViewport(): Viewport
{
return $this->viewport;
}
public function getContentViewport(): Viewport
{
return $this->contentViewport;
}
public function setContentViewport(Viewport $viewport): void
{
$this->contentViewport = $viewport;
}
/**
* Apply offset to viewport and all child viewports recursively
*/
public function applyScrollOffset(int $offsetX, int $offsetY): void
{
// Offset this component's viewports
$this->viewport->x = (int) ($this->viewport->x - $offsetX);
$this->viewport->y = (int) ($this->viewport->y - $offsetY);
$this->contentViewport->x = (int) ($this->contentViewport->x - $offsetX);
$this->contentViewport->y = (int) ($this->contentViewport->y - $offsetY);
// Recursively apply to all children
foreach ($this->children as $child) {
$child->applyScrollOffset($offsetX, $offsetY);
}
}
public function setZIndex(int $zIndex): void
{
$this->zIndex = $zIndex;
}
public function getZIndex(): int
{
return $this->zIndex;
}
public function isVisible(): bool
{
return $this->visible;
}
public function setVisible(bool $visible): void
{
if ($this->visible !== $visible) {
$this->visible = $visible;
$this->markDirty(true);
}
}
public function setOverlay(bool $overlay): void
{
if ($this->overlay !== $overlay) {
$this->overlay = $overlay;
$this->markDirty(false);
}
}
public function isOverlay(): bool
{
return $this->overlay;
}
public function invalidateTextureCache(): void
{
if ($this->cachedTexture !== null) {
sdl_destroy_texture($this->cachedTexture);
$this->cachedTexture = null;
}
$this->renderDirty = true;
}
public function getCachedTexture()
{
return $this->cachedTexture;
}
public function setCachedTexture($texture): void
{
// Free old texture if exists
if ($this->cachedTexture !== null) {
sdl_destroy_texture($this->cachedTexture);
}
$this->cachedTexture = $texture;
}
public function useTextureCache(): bool
{
return $this->useTextureCache;
}
public function setUseTextureCache(bool $use): void
{
$this->useTextureCache = $use;
if (!$use) {
$this->invalidateTextureCache();
}
}
public function markClean(): void
{
$this->layoutDirty = false;
$this->renderDirty = false;
foreach ($this->children as $child) {
$child->markClean();
}
}
public function setParent(null|Component $parent): void
{
$this->parent = $parent;
}
public function getParent(): null|Component
{
return $this->parent;
}
public function update(): void
{
foreach ($this->children as $child) {
$child->update();
}
}
public function layout(null|TextRenderer $textRenderer = null): void
{
$this->computedStyles = StyleParser::parse($this->style)->getValidStyles(
MediaQueryEnum::normal,
$this->currentState,
);
if (isset($this->computedStyles[Margin::class]) && ($m = $this->computedStyles[Margin::class])) {
$this->viewport->x = (int) ($this->viewport->x + $m->left);
$this->viewport->width = max(0, ($this->viewport->width - $m->right) - $m->left);
$this->viewport->y = (int) ($this->viewport->y + $m->top);
$this->viewport->height = max(0, ($this->viewport->height - $m->bottom) - $m->top);
}
$this->contentViewport = clone $this->viewport;
if (isset($this->computedStyles[Padding::class]) && ($p = $this->computedStyles[Padding::class])) {
$this->contentViewport->x = (int) ($this->contentViewport->x + $p->left);
$this->contentViewport->width = max(0, ($this->contentViewport->width - $p->right) - $p->left);
$this->contentViewport->y = (int) ($this->contentViewport->y + $p->top);
$this->contentViewport->height = max(0, ($this->contentViewport->height - $p->bottom) - $p->top);
}
if ($this->useTextureCache) {
$this->invalidateTextureCache();
}
$this->layoutDirty = false;
$this->renderDirty = true;
}
public function render(&$renderer, null|TextRenderer $textRenderer = null): void
{
if (!$this->visible) {
return;
}
if (
isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) &&
($bg = $this->computedStyles[\PHPNative\Tailwind\Style\Background::class])
) {
if ($this->currentState == StateEnum::hover) {
sdl_set_render_draw_color($renderer, $bg->color->red, $bg->color->green, $bg->color->blue, 10);
} else {
sdl_set_render_draw_color(
$renderer,
$bg->color->red,
$bg->color->green,
$bg->color->blue,
$bg->color->alpha,
);
}
if (
isset($this->computedStyles[\PHPNative\Tailwind\Style\Border::class]) &&
($border = $this->computedStyles[\PHPNative\Tailwind\Style\Border::class])
) {
// SDL3: sdl_rounded_box_ex uses (x1, y1, x2, y2) instead of (x, y, w, h)
$x2 = $this->viewport->x + $this->viewport->width;
$y2 = $this->viewport->y + $this->viewport->height;
sdl_rounded_box_ex(
$renderer,
$this->viewport->x,
$this->viewport->y,
$x2,
$y2,
$border->roundTopLeft ?? 0,
$border->roundTopRight ?? 0,
$border->roundBottomRight ?? 0,
$border->roundBottomLeft ?? 0,
$bg->color->red,
$bg->color->green,
$bg->color->blue,
$bg->color->alpha,
);
} else {
sdl_render_fill_rect($renderer, [
'x' => $this->viewport->x,
'y' => $this->viewport->y,
'w' => $this->viewport->width,
'h' => $this->viewport->height,
]);
}
}
if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) {
sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10);
sdl_render_rect($renderer, [
'x' => $this->viewport->x,
'y' => $this->viewport->y,
'w' => $this->viewport->width,
'h' => $this->viewport->height,
]);
}
}
public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void
{
if (!$this->visible) {
return;
}
// Render children
foreach ($this->children as $child) {
$child->render($renderer, $textRenderer);
$child->renderContent($renderer, $textRenderer);
}
}
public function addComponent(Component $component): void
{
$this->children[] = $component;
$component->setParent($this);
if ($this->attachedWindow !== null) {
$component->attachToWindow($this->attachedWindow);
}
$this->markDirty(true); // Adding a child means we need to re-layout and re-render
}
/**
* Collect all overlays recursively from this component and all children
* @return array Array of overlay components
*/
public function collectOverlays(): array
{
$overlays = [];
foreach ($this->children as $child) {
if ($child->isOverlay()) {
$overlays[] = $child;
}
// Recursively collect from children
$overlays = array_merge($overlays, $child->collectOverlays());
}
return $overlays;
}
/**
* Handle mouse click event
* @return bool True if event was handled
*/
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
// Don't handle events if component is not visible
if (!$this->visible) {
return false;
}
// Default implementation: propagate to children
foreach ($this->children as $child) {
if ($child->handleMouseClick($mouseX, $mouseY, $button)) {
return true;
}
}
return false;
}
/**
* Handle mouse move event
*/
public function handleMouseMove(float $mouseX, float $mouseY): void
{
// Check if mouse is over this component
$isMouseOver =
$mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
$mouseY >= $this->viewport->y &&
$mouseY <= ($this->viewport->y + $this->viewport->height);
// Update state based on mouse position
$previousState = $this->currentState;
$this->currentState = $isMouseOver ? StateEnum::hover : StateEnum::normal;
// Recompute styles if state changed
if ($previousState !== $this->currentState) {
$this->computedStyles = StyleParser::parse($this->style)->getValidStyles(
MediaQueryEnum::normal,
$this->currentState,
);
// Mark as dirty since visual state changed
$this->markDirty(false, false);
}
foreach ($this->children as $child) {
$child->handleMouseMove($mouseX, $mouseY);
}
}
/**
* Handle mouse button release event
*/
public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void
{
// Default implementation: propagate to children
foreach ($this->children as $child) {
$child->handleMouseRelease($mouseX, $mouseY, $button);
}
}
/**
* Handle mouse wheel event
* @return bool True if event was handled
*/
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool
{
// Don't handle events if component is not visible
if (!$this->visible) {
return false;
}
// Default implementation: propagate to children
foreach ($this->children as $child) {
if ($child->handleMouseWheel($mouseX, $mouseY, $deltaY)) {
return true;
}
}
return false;
}
/**
* Handle text input event
* @param string $text The input text
*/
public function handleTextInput(string $text): void
{
// Default implementation: propagate to children
foreach ($this->children as $child) {
$child->handleTextInput($text);
}
}
/**
* Handle key down event
* @param int $keycode SDL keycode
* @return bool True if event was handled
*/
public function handleKeyDown(int $keycode, int $mod = 0): bool
{
// Default implementation: propagate to children
foreach ($this->children as $child) {
if ($child->handleKeyDown($keycode, $mod)) {
return true;
}
}
return false;
}
public function needsLayout(): bool
{
return $this->layoutDirty;
}
public function isDirty(): bool
{
return $this->renderDirty;
}
public function markDirty(bool $requiresLayout = false, bool $bubble = true): void
{
if ($requiresLayout) {
$this->layoutDirty = true;
$this->renderDirty = true;
$this->invalidateTextureCache();
if ($this->attachedWindow !== null) {
$this->attachedWindow->setShouldBeReLayouted(true);
}
} else {
$this->renderDirty = true;
}
if ($bubble && $this->parent !== null) {
$this->parent->markDirty($requiresLayout);
return;
}
if (!$bubble && !$requiresLayout) {
$ancestor = $this->parent;
while ($ancestor !== null && $ancestor->useTextureCache()) {
$ancestor->renderDirty = true;
$ancestor->invalidateTextureCache();
$ancestor = $ancestor->getParent();
}
}
}
public function attachToWindow(Window $window): void
{
$this->attachedWindow = $window;
foreach ($this->children as $child) {
$child->attachToWindow($window);
}
}
public function detachFromWindow(): void
{
$this->attachedWindow = null;
foreach ($this->children as $child) {
$child->detachFromWindow();
}
}
/**
* @return array<Component>
*/
public function getChildren(): array
{
return $this->children;
}
}