sdl3/src/Ui/Widget/Container.php
2025-11-05 18:33:10 +01:00

683 lines
26 KiB
PHP

<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Style\Basis;
use PHPNative\Tailwind\Style\DirectionEnum;
use PHPNative\Tailwind\Style\Flex;
use PHPNative\Tailwind\Style\FlexTypeEnum;
use PHPNative\Tailwind\Style\Height;
use PHPNative\Tailwind\Style\Unit;
use PHPNative\Tailwind\Style\Width;
use PHPNative\Ui\Component;
use PHPNative\Ui\Viewport;
class Container extends Component
{
// Scroll state
private float $scrollX = 0;
private float $scrollY = 0;
private float $contentWidth = 0;
private float $contentHeight = 0;
// Scrollbar state
private bool $isDraggingScrollbarX = false;
private bool $isDraggingScrollbarY = false;
private float $dragStartX = 0;
private float $dragStartY = 0;
private float $scrollStartX = 0;
private float $scrollStartY = 0;
// Scrollbar dimensions
private const SCROLLBAR_WIDTH = 12;
private const SCROLLBAR_MIN_SIZE = 20;
public function __construct(string $style = '')
{
parent::__construct($style);
}
/**
* Clear all child components
*/
public function clearChildren(): void
{
$this->children = [];
$this->contentWidth = 0;
$this->contentHeight = 0;
$this->scrollX = 0;
$this->scrollY = 0;
}
public function layout(null|TextRenderer $textRenderer = null): void
{
// Call parent to compute styles and setup viewports
parent::layout($textRenderer);
// Check if container has explicit width/height or is a flex child
$flexStyle = $this->computedStyles[Flex::class] ?? null;
$hasFlexGrow = $flexStyle && $flexStyle->type !== FlexTypeEnum::none;
// Check if overflow is set (if yes, container should not auto-expand)
$overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class] ?? null;
$hasOverflowX =
$overflow &&
in_array($overflow->x, [
\PHPNative\Tailwind\Style\OverflowEnum::scroll,
\PHPNative\Tailwind\Style\OverflowEnum::auto,
]);
$hasOverflowY =
$overflow &&
in_array($overflow->y, [
\PHPNative\Tailwind\Style\OverflowEnum::scroll,
\PHPNative\Tailwind\Style\OverflowEnum::auto,
]);
// A container is a flex container if it has flex/flex-row/flex-col in style
// (but not flex-1, flex-auto, flex-none which make it a flex child)
$isFlexContainer =
$flexStyle &&
(
preg_match('/\bflex-row\b/', $this->style) ||
preg_match('/\bflex-col\b/', $this->style) ||
preg_match('/(?<![a-z-])flex(?![a-z-])/', $this->style)
);
// If overflow is set, treat it as explicit size (don't auto-expand)
$hasExplicitWidth =
isset($this->computedStyles[Width::class]) ||
isset($this->computedStyles[Basis::class]) ||
$hasFlexGrow ||
$hasOverflowX;
$hasExplicitHeight =
isset($this->computedStyles[Height::class]) ||
isset($this->computedStyles[Basis::class]) ||
$hasFlexGrow ||
$hasOverflowY;
// Check if this container has flex layout
if ($isFlexContainer) {
$this->layoutChildren($flexStyle, $textRenderer);
} else {
// Non-flex layout: stack children vertically
$currentY = $this->contentViewport->y;
foreach ($this->children as $child) {
// Skip overlays in normal layout flow
if ($child->isOverlay()) {
continue;
}
// Create a viewport for the child with available width but let height be determined by child
$childViewport = new Viewport(
x: $this->contentViewport->x,
y: (int) $currentY,
width: $this->contentViewport->width,
height: $this->contentViewport->height,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$child->setViewport($childViewport);
$child->layout($textRenderer);
// Get the actual height after layout
$actualHeight = $child->getViewport()->height;
$currentY += $actualHeight;
}
}
// Layout overlays separately (they position themselves)
foreach ($this->children as $child) {
if ($child->isOverlay()) {
// Give overlay a viewport based on the window size
// The overlay can then adjust its position in its own layout() method
if (!isset($child->getViewport()->windowWidth)) {
$overlayViewport = new Viewport(
x: 0,
y: 0,
width: $this->contentViewport->windowWidth,
height: $this->contentViewport->windowHeight,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$child->setViewport($overlayViewport);
}
$child->layout($textRenderer);
}
}
// Calculate total content size after children are laid out
$this->calculateContentSize();
// Adjust viewport to content size if no explicit size
// But limit to available parent size (for proper overflow/scrolling)
$padding = $this->computedStyles[\PHPNative\Tailwind\Style\Padding::class] ?? null;
$paddingX = $padding ? ($padding->left + $padding->right) : 0;
$paddingY = $padding ? ($padding->top + $padding->bottom) : 0;
// Store original available size before adjustment
$availableWidth = $this->viewport->width;
$availableHeight = $this->viewport->height;
if (!$hasExplicitWidth) {
// Set viewport to min(contentSize + padding, availableSize)
$desiredWidth = $this->contentWidth + $paddingX;
$this->viewport->width = (int) min($desiredWidth, $availableWidth);
$this->contentViewport->width = max(0, $this->viewport->width - ((int) $paddingX));
}
if (!$hasExplicitHeight) {
// Set viewport to content height + padding (don't expand to fill available space unnecessarily)
$desiredHeight = $this->contentHeight + $paddingY;
// Only limit to availableHeight if we're not trying to measure natural size
$this->viewport->height = (int) $desiredHeight;
$this->contentViewport->height = max(0, $this->viewport->height - ((int) $paddingY));
}
}
private function layoutChildren(Flex $flex, null|TextRenderer $textRenderer): void
{
if (empty($this->children)) {
return;
}
$isRow = $flex->direction === DirectionEnum::row;
$availableSpace = $isRow ? $this->contentViewport->width : $this->contentViewport->height;
// First pass: calculate fixed sizes and count flex-grow items
$childSizes = [];
$flexGrowCount = 0;
$usedSpace = 0;
foreach ($this->children as $index => $child) {
// Skip overlays in flex layout
if ($child->isOverlay()) {
continue;
}
// Parse child styles to get basis, width, height, and flex
$childStyles = \PHPNative\Tailwind\StyleParser::parse($child->style)->getValidStyles(
\PHPNative\Tailwind\Style\MediaQueryEnum::normal,
\PHPNative\Tailwind\Style\StateEnum::normal,
);
$childFlex = $childStyles[Flex::class] ?? null;
$basis = $childStyles[Basis::class] ?? null;
$width = $childStyles[Width::class] ?? null;
$height = $childStyles[Height::class] ?? null;
$size = 0;
$hasExplicitSize = false;
// Check if child has flex-grow
if ($childFlex && $childFlex->type !== FlexTypeEnum::none) {
$flexGrowCount++;
$childSizes[$index] = ['size' => 0, 'flexGrow' => true, 'natural' => false];
} else {
// Calculate fixed size from basis, width, or height
if ($basis) {
$size = $this->calculateSize($basis, $availableSpace);
$hasExplicitSize = true;
} elseif ($isRow && $width) {
$size = $this->calculateSize($width, $availableSpace);
$hasExplicitSize = true;
} elseif (!$isRow && $height) {
$size = $this->calculateSize($height, $availableSpace);
$hasExplicitSize = true;
}
if (!$hasExplicitSize) {
// Need to measure natural size - do a temporary layout
// For flex-col without explicit height, give minimal space to measure intrinsic size
// For flex-row without explicit width, give minimal space to measure intrinsic size
$tempViewport = new Viewport(
x: $this->contentViewport->x,
y: $this->contentViewport->y,
width: $isRow ? 9999 : $this->contentViewport->width,
height: $isRow ? $this->contentViewport->height : 9999,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$child->setViewport($tempViewport);
$child->layout($textRenderer);
// Get natural size (the actual size the child computed after layout)
$size = $isRow ? $child->getViewport()->width : $child->getViewport()->height;
}
$usedSpace += $size;
$childSizes[$index] = ['size' => $size, 'flexGrow' => false, 'natural' => !$hasExplicitSize];
}
}
// Calculate remaining space for flex-grow items
$remainingSpace = max(0, $availableSpace - $usedSpace);
$flexGrowSize = $flexGrowCount > 0 ? ($remainingSpace / $flexGrowCount) : 0;
// Second pass: assign sizes and position children
$currentPosition = $isRow ? $this->contentViewport->x : $this->contentViewport->y;
foreach ($this->children as $index => $child) {
// Skip overlays in flex layout
if ($child->isOverlay()) {
continue;
}
$childSize = $childSizes[$index];
$size = $childSize['flexGrow'] ? $flexGrowSize : $childSize['size'];
// Create viewport for child
if ($isRow) {
// Flex row
$childViewport = new Viewport(
x: (int) $currentPosition,
y: $this->contentViewport->y,
width: $size,
height: $this->contentViewport->height,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$currentPosition += $size;
} else {
// Flex column
$childViewport = new Viewport(
x: $this->contentViewport->x,
y: (int) $currentPosition,
width: $this->contentViewport->width,
height: $size,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$currentPosition += $size;
}
$child->setViewport($childViewport);
$child->layout($textRenderer);
}
}
private function calculateSize(Width|Height|Basis $style, float $availableSpace): float
{
return match ($style->unit) {
Unit::Pixel => (float) $style->value,
Unit::Point => (float) $style->value,
Unit::Percent => ($availableSpace * $style->value) / 100,
};
}
private function calculateContentSize(): void
{
if (empty($this->children)) {
// No children: content size is 0
$this->contentWidth = 0;
$this->contentHeight = 0;
return;
}
$maxX = 0;
$maxY = 0;
foreach ($this->children as $child) {
// Skip overlays - they don't contribute to content size
if ($child->isOverlay()) {
continue;
}
$childViewport = $child->getViewport();
$maxX = max($maxX, ($childViewport->x + $childViewport->width) - $this->contentViewport->x);
$maxY = max($maxY, ($childViewport->y + $childViewport->height) - $this->contentViewport->y);
}
// Content size is the actual space used by children within contentViewport
$this->contentWidth = $maxX;
$this->contentHeight = $maxY;
}
private function hasOverflow(): array
{
$overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class] ?? null;
if (!$overflow) {
return ['x' => false, 'y' => false];
}
$needsScrollX = $this->contentWidth > $this->contentViewport->width;
$needsScrollY = $this->contentHeight > $this->contentViewport->height;
return [
'x' =>
$needsScrollX &&
in_array($overflow->x, [
\PHPNative\Tailwind\Style\OverflowEnum::scroll,
\PHPNative\Tailwind\Style\OverflowEnum::auto,
])
,
'y' =>
$needsScrollY &&
in_array($overflow->y, [
\PHPNative\Tailwind\Style\OverflowEnum::scroll,
\PHPNative\Tailwind\Style\OverflowEnum::auto,
])
,
];
}
public function render(&$renderer, null|TextRenderer $textRenderer = null): void
{
parent::render($renderer, $textRenderer);
}
public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void
{
if (!$this->visible) {
return;
}
$overflow = $this->hasOverflow();
// Save original viewport
$originalViewport = $this->contentViewport;
// Apply scroll offset to children
if ($overflow['x'] || $overflow['y']) {
// Enable clipping
$scissorX = (int) $this->contentViewport->x;
$scissorY = (int) $this->contentViewport->y;
$scissorW = (int) $this->contentViewport->width;
$scissorH = (int) $this->contentViewport->height;
// SDL3: Set clip rect for clipping
sdl_set_render_clip_rect($renderer, [
'x' => $scissorX,
'y' => $scissorY,
'w' => $scissorW,
'h' => $scissorH,
]);
// Render children with scroll offset (skip overlays)
foreach ($this->children as $child) {
// Skip overlays - they'll be rendered later
if ($child->isOverlay()) {
continue;
}
// Apply scroll offset recursively to child and all its descendants
$child->applyScrollOffset((int) $this->scrollX, (int) $this->scrollY);
// Performance optimization: skip completely invisible children
$childViewport = $child->getViewport();
$isVisible =
($childViewport->x + $childViewport->width) > $scissorX &&
$childViewport->x < ($scissorX + $scissorW) &&
($childViewport->y + $childViewport->height) > $scissorY &&
$childViewport->y < ($scissorY + $scissorH);
if ($isVisible) {
$child->render($renderer, $textRenderer);
$child->renderContent($renderer, $textRenderer);
}
// Restore by applying negative offset
$child->applyScrollOffset((int) -$this->scrollX, (int) -$this->scrollY);
}
// Disable clipping
sdl_set_render_clip_rect($renderer, null);
// Render scrollbars
$this->renderScrollbars($renderer, $overflow);
} else {
// No overflow, render normally (skip overlays)
foreach ($this->children as $child) {
if (!$child->isOverlay()) {
$child->render($renderer, $textRenderer);
$child->renderContent($renderer, $textRenderer);
}
}
}
// Note: Overlays are NOT rendered here - they will be rendered by Window.php
// at the global level to ensure they appear on top of everything
}
private function renderScrollbars(&$renderer, array $overflow): void
{
$scrollbarColor = [100, 100, 100, 200]; // Gray with some transparency
// Vertical scrollbar
if ($overflow['y']) {
$scrollbarHeight = $this->contentViewport->height;
$thumbHeight = max(
self::SCROLLBAR_MIN_SIZE,
($this->contentViewport->height / $this->contentHeight) * $scrollbarHeight,
);
$maxScroll = $this->contentHeight - $this->contentViewport->height;
$thumbY = $this->contentViewport->y + (($this->scrollY / $maxScroll) * ($scrollbarHeight - $thumbHeight));
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH;
// Track
sdl_set_render_draw_color($renderer, 200, 200, 200, 100);
sdl_render_fill_rect($renderer, [
'x' => (int) $scrollbarX,
'y' => (int) $this->contentViewport->y,
'w' => (int) self::SCROLLBAR_WIDTH,
'h' => (int) $scrollbarHeight,
]);
// Thumb - using sdl_rounded_box for rounded rectangle
$thumbX = (int) ($scrollbarX + 2);
$thumbW = (int) (self::SCROLLBAR_WIDTH - 4);
sdl_rounded_box(
$renderer,
$thumbX,
(int) $thumbY,
$thumbX + $thumbW,
(int) ($thumbY + $thumbHeight),
4,
$scrollbarColor[0],
$scrollbarColor[1],
$scrollbarColor[2],
$scrollbarColor[3],
);
}
// Horizontal scrollbar
if ($overflow['x']) {
$scrollbarWidth = $this->contentViewport->width;
$thumbWidth = max(
self::SCROLLBAR_MIN_SIZE,
($this->contentViewport->width / $this->contentWidth) * $scrollbarWidth,
);
$maxScroll = $this->contentWidth - $this->contentViewport->width;
$thumbX = $this->contentViewport->x + (($this->scrollX / $maxScroll) * ($scrollbarWidth - $thumbWidth));
$scrollbarY = ($this->contentViewport->y + $this->contentViewport->height) - self::SCROLLBAR_WIDTH;
// Track
sdl_set_render_draw_color($renderer, 200, 200, 200, 100);
sdl_render_fill_rect($renderer, [
'x' => (int) $this->contentViewport->x,
'y' => (int) $scrollbarY,
'w' => (int) $scrollbarWidth,
'h' => (int) self::SCROLLBAR_WIDTH,
]);
// Thumb - using sdl_rounded_box for rounded rectangle
$thumbY = (int) ($scrollbarY + 2);
$thumbH = (int) (self::SCROLLBAR_WIDTH - 4);
sdl_rounded_box(
$renderer,
(int) $thumbX,
$thumbY,
(int) ($thumbX + $thumbWidth),
$thumbY + $thumbH,
4,
$scrollbarColor[0],
$scrollbarColor[1],
$scrollbarColor[2],
$scrollbarColor[3],
);
}
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
// Don't handle events if container is not visible
if (!$this->visible) {
return false;
}
// Check if click is on scrollbar
$overflow = $this->hasOverflow();
if ($overflow['y']) {
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH;
if (
$mouseX >= $scrollbarX &&
$mouseX <= ($scrollbarX + self::SCROLLBAR_WIDTH) &&
$mouseY >= $this->contentViewport->y &&
$mouseY <= ($this->contentViewport->y + $this->contentViewport->height)
) {
$this->isDraggingScrollbarY = true;
$this->dragStartY = $mouseY;
$this->scrollStartY = $this->scrollY;
return true;
}
}
if ($overflow['x']) {
$scrollbarY = ($this->contentViewport->y + $this->contentViewport->height) - self::SCROLLBAR_WIDTH;
if (
$mouseY >= $scrollbarY &&
$mouseY <= ($scrollbarY + self::SCROLLBAR_WIDTH) &&
$mouseX >= $this->contentViewport->x &&
$mouseX <= ($this->contentViewport->x + $this->contentViewport->width)
) {
$this->isDraggingScrollbarX = true;
$this->dragStartX = $mouseX;
$this->scrollStartX = $this->scrollX;
return true;
}
}
// Propagate to children if not on scrollbar
// Only adjust coordinates if we have active scrolling
$adjustedMouseX = $overflow['x'] || $overflow['y'] ? ($mouseX + $this->scrollX) : $mouseX;
$adjustedMouseY = $overflow['x'] || $overflow['y'] ? ($mouseY + $this->scrollY) : $mouseY;
foreach ($this->children as $child) {
if (method_exists($child, 'handleMouseClick')) {
if ($child->handleMouseClick($adjustedMouseX, $adjustedMouseY, $button)) {
return true;
}
}
}
return false;
}
public function handleMouseMove(float $mouseX, float $mouseY): void
{
parent::handleMouseMove($mouseX, $mouseY);
$overflow = $this->hasOverflow();
if ($this->isDraggingScrollbarY) {
$deltaY = $mouseY - $this->dragStartY;
$scrollbarHeight = $this->contentViewport->height;
$thumbHeight = max(
self::SCROLLBAR_MIN_SIZE,
($this->contentViewport->height / $this->contentHeight) * $scrollbarHeight,
);
$maxScroll = $this->contentHeight - $this->contentViewport->height;
$scrollRatio = $deltaY / ($scrollbarHeight - $thumbHeight);
$this->scrollY = max(0, min($maxScroll, $this->scrollStartY + ($scrollRatio * $maxScroll)));
}
if ($this->isDraggingScrollbarX) {
$deltaX = $mouseX - $this->dragStartX;
$scrollbarWidth = $this->contentViewport->width;
$thumbWidth = max(
self::SCROLLBAR_MIN_SIZE,
($this->contentViewport->width / $this->contentWidth) * $scrollbarWidth,
);
$maxScroll = $this->contentWidth - $this->contentViewport->width;
$scrollRatio = $deltaX / ($scrollbarWidth - $thumbWidth);
$this->scrollX = max(0, min($maxScroll, $this->scrollStartX + ($scrollRatio * $maxScroll)));
}
// Propagate to children - only adjust coordinates if we have active scrolling
$adjustedMouseX = $overflow['x'] || $overflow['y'] ? ($mouseX + $this->scrollX) : $mouseX;
$adjustedMouseY = $overflow['x'] || $overflow['y'] ? ($mouseY + $this->scrollY) : $mouseY;
foreach ($this->children as $child) {
if (method_exists($child, 'handleMouseMove')) {
$child->handleMouseMove($adjustedMouseX, $adjustedMouseY);
}
}
}
public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void
{
$this->isDraggingScrollbarX = false;
$this->isDraggingScrollbarY = false;
// Propagate to children - only adjust coordinates if we have active scrolling
$overflow = $this->hasOverflow();
$adjustedMouseX = $overflow['x'] || $overflow['y'] ? ($mouseX + $this->scrollX) : $mouseX;
$adjustedMouseY = $overflow['x'] || $overflow['y'] ? ($mouseY + $this->scrollY) : $mouseY;
foreach ($this->children as $child) {
if (method_exists($child, 'handleMouseRelease')) {
$child->handleMouseRelease($adjustedMouseX, $adjustedMouseY, $button);
}
}
}
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool
{
// Don't handle events if container is not visible
if (!$this->visible) {
return false;
}
$overflow = $this->hasOverflow();
// Check if mouse is over this container
if (
$mouseX >= $this->contentViewport->x &&
$mouseX <= ($this->contentViewport->x + $this->contentViewport->width) &&
$mouseY >= $this->contentViewport->y &&
$mouseY <= ($this->contentViewport->y + $this->contentViewport->height)
) {
if ($overflow['y']) {
$maxScroll = $this->contentHeight - $this->contentViewport->height;
$this->scrollY = max(0, min($maxScroll, $this->scrollY + ($deltaY * 20)));
return true;
}
if ($overflow['x']) {
$maxScroll = $this->contentWidth - $this->contentViewport->width;
$this->scrollX = max(0, min($maxScroll, $this->scrollX + ($deltaY * 20)));
return true;
}
}
// Propagate to children - only adjust coordinates if we have active scrolling
$adjustedMouseX = $overflow['x'] || $overflow['y'] ? ($mouseX + $this->scrollX) : $mouseX;
$adjustedMouseY = $overflow['x'] || $overflow['y'] ? ($mouseY + $this->scrollY) : $mouseY;
foreach ($this->children as $child) {
if (method_exists($child, 'handleMouseWheel')) {
if ($child->handleMouseWheel($adjustedMouseX, $adjustedMouseY, $deltaY)) {
return true;
}
}
}
return false;
}
}