760 lines
30 KiB
PHP
760 lines
30 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,
|
|
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;
|
|
}
|
|
}
|