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('/(?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, uiScale: $this->contentViewport->uiScale, ); $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()) { // Overlays always get a full-window viewport; they will decide inner layout themselves $overlayViewport = new Viewport( x: 0, y: 0, width: $this->contentViewport->windowWidth, height: $this->contentViewport->windowHeight, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, uiScale: $this->contentViewport->uiScale, ); $child->setViewport($overlayViewport); $child->setContentViewport(clone $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; // Get gap from styles $gap = $this->computedStyles[\PHPNative\Tailwind\Style\Gap::class] ?? null; $gapSize = 0; if ($gap) { $gapSize = $isRow ? $gap->x : $gap->y; } // First pass: calculate fixed sizes and count flex-grow items $childSizes = []; $flexGrowCount = 0; $usedSpace = 0; $nonOverlayCount = 0; // Count non-overlay children first foreach ($this->children as $child) { if (!$child->isOverlay()) { $nonOverlayCount++; } } // Add gap space to used space (n-1 gaps for n children) if ($nonOverlayCount > 1 && $gapSize > 0) { $usedSpace += ($nonOverlayCount - 1) * $gapSize; } 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; // Track explicit cross-axis sizing so we don't stretch components unintentionally. $crossSize = null; $hasExplicitCrossSize = false; if ($isRow && $height) { $crossSize = $this->calculateSize($height, $this->contentViewport->height); $hasExplicitCrossSize = true; } elseif (!$isRow && $width) { $crossSize = $this->calculateSize($width, $this->contentViewport->width); $hasExplicitCrossSize = true; } $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, 'crossSize' => $crossSize, 'crossExplicit' => $hasExplicitCrossSize, ]; } 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, uiScale: $this->contentViewport->uiScale, ); $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, 'crossSize' => $crossSize, 'crossExplicit' => $hasExplicitCrossSize, ]; } } // 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; $childIndex = 0; foreach ($this->children as $index => $child) { // Skip overlays in flex layout if ($child->isOverlay()) { continue; } $childSize = $childSizes[$index]; $size = $childSize['flexGrow'] ? $flexGrowSize : $childSize['size']; $crossSize = $childSize['crossSize'] ?? null; $hasExplicitCrossSize = $childSize['crossExplicit'] ?? false; // Add gap before this child (except for first child) if ($childIndex > 0 && $gapSize > 0) { $currentPosition += $gapSize; } // Create viewport for child if ($isRow) { // Flex row $childHeight = $hasExplicitCrossSize && $crossSize !== null ? min($crossSize, $this->contentViewport->height) : $this->contentViewport->height; $childViewport = new Viewport( x: (int) $currentPosition, y: $this->contentViewport->y, width: $size, height: $childHeight, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, uiScale: $this->contentViewport->uiScale, ); $currentPosition += $size; } else { $childWidth = $hasExplicitCrossSize && $crossSize !== null ? min($crossSize, $this->contentViewport->width) : $this->contentViewport->width; // Flex column $childViewport = new Viewport( x: $this->contentViewport->x, y: (int) $currentPosition, width: $childWidth, height: $size, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, uiScale: $this->contentViewport->uiScale, ); $currentPosition += $size; } $child->setViewport($childViewport); $child->layout($textRenderer); $childIndex++; } } private function calculateSize(Width|Height|Basis $style, float $availableSpace): float { return match ($style->unit) { Unit::Pixel => ((float) $style->value) * $this->contentViewport->uiScale, 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; } // First pass: Render shadows of all children (behind them) foreach ($this->children as $child) { if (!$child->isOverlay() && method_exists($child, 'renderShadowIfPresent')) { $child->renderShadowIfPresent($renderer); } } $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 } /** * Returns the computed content width/height within the container (excludes overlays). */ public function getContentSize(): array { return [ 'width' => $this->contentWidth, 'height' => $this->contentHeight, ]; } private function renderScrollbars(&$renderer, array $overflow): void { $scrollbarColor = [120, 120, 120, 230]; // 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 (light gray background) sdl_set_render_draw_color($renderer, 220, 220, 220, 255); sdl_render_fill_rect($renderer, [ 'x' => (int) $scrollbarX, 'y' => (int) $this->contentViewport->y, 'w' => (int) self::SCROLLBAR_WIDTH, 'h' => (int) $scrollbarHeight, ]); // Thumb (darker gray, moved part) - using regular rect for now $thumbX = (int) ($scrollbarX + 2); $thumbW = (int) (self::SCROLLBAR_WIDTH - 4); sdl_set_render_draw_color($renderer, 128, 128, 128, 255); sdl_render_fill_rect($renderer, [ 'x' => $thumbX, 'y' => (int) $thumbY, 'w' => $thumbW, 'h' => (int) $thumbHeight, ]); } // 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; $handled = false; foreach ($this->children as $child) { if (method_exists($child, 'handleMouseClick')) { if ($child->handleMouseClick($adjustedMouseX, $adjustedMouseY, $button)) { $handled = true; } } } return $handled; } 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; } }