From 6ed95d47e511524ca4cc1ddef4a054cd44742213 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Sun, 26 Oct 2025 22:47:12 +0100 Subject: [PATCH] Fixes --- examples/windows_app_example.php | 119 +++++++++++++++++++++ examples/windows_menu_example.php | 70 ++++++++++++ src/Framework/Application.php | 12 ++- src/Ui/Component.php | 54 ++++++++++ src/Ui/Widget/Button.php | 16 +-- src/Ui/Widget/Container.php | 90 ++++++++++++---- src/Ui/Widget/Menu.php | 100 +++++++++++++++++ src/Ui/Widget/MenuBar.php | 82 ++++++++++++++ src/Ui/Widget/MenuItem.php | 11 ++ src/Ui/Widget/Separator.php | 52 +++++++++ src/Ui/Widget/StatusBar.php | 20 ++++ src/Ui/Widget/TabContainer.php | 111 +++++++++++++++++++ src/Ui/Widget/Table.php | 171 ++++++++++++++++++++++++++++++ src/Ui/Window.php | 160 ++++++++++++++++------------ 14 files changed, 967 insertions(+), 101 deletions(-) create mode 100644 examples/windows_app_example.php create mode 100644 examples/windows_menu_example.php create mode 100644 src/Ui/Widget/Menu.php create mode 100644 src/Ui/Widget/MenuBar.php create mode 100644 src/Ui/Widget/MenuItem.php create mode 100644 src/Ui/Widget/Separator.php create mode 100644 src/Ui/Widget/StatusBar.php create mode 100644 src/Ui/Widget/TabContainer.php create mode 100644 src/Ui/Widget/Table.php diff --git a/examples/windows_app_example.php b/examples/windows_app_example.php new file mode 100644 index 0000000..b8b5a8e --- /dev/null +++ b/examples/windows_app_example.php @@ -0,0 +1,119 @@ +addMenu('Datei'); +$fileMenu->addItem('Neu', function () { + echo "Neu clicked\n"; +}); +$fileMenu->addItem('Öffnen', function () { + echo "Öffnen clicked\n"; +}); +$fileMenu->addSeparator(); +$fileMenu->addItem('Beenden', function () use ($app) { + echo "Beenden clicked\n"; + exit(0); +}); + +// Settings Menu +$settingsMenu = $menuBar->addMenu('Einstellungen'); +$settingsMenu->addItem('Optionen', function () { + echo "Optionen clicked\n"; +}); +$settingsMenu->addItem('Sprache', function () { + echo "Sprache clicked\n"; +}); + +$mainContainer->addComponent($menuBar); + +// === 2. Tab Container (flex-1) === +$tabContainer = new TabContainer('flex-1'); + +// Tab 1: Table with data +$tab1 = new Container('flex flex-col p-4'); +$table = new Table(); + +$table->setColumns([ + ['key' => 'id', 'title' => 'ID', 'width' => 80], + ['key' => 'name', 'title' => 'Name'], + ['key' => 'email', 'title' => 'E-Mail'], + ['key' => 'status', 'title' => 'Status', 'width' => 120], +]); + +$table->setData([ + ['id' => 1, 'name' => 'Max Mustermann', 'email' => 'max@example.com', 'status' => 'Aktiv'], + ['id' => 2, 'name' => 'Anna Schmidt', 'email' => 'anna@example.com', 'status' => 'Aktiv'], + ['id' => 3, 'name' => 'Peter Weber', 'email' => 'peter@example.com', 'status' => 'Inaktiv'], + ['id' => 4, 'name' => 'Lisa Müller', 'email' => 'lisa@example.com', 'status' => 'Aktiv'], + ['id' => 5, 'name' => 'Tom Klein', 'email' => 'tom@example.com', 'status' => 'Aktiv'], + ['id' => 6, 'name' => 'Sarah Wagner', 'email' => 'sarah@example.com', 'status' => 'Inaktiv'], + ['id' => 7, 'name' => 'Michael Becker', 'email' => 'michael@example.com', 'status' => 'Aktiv'], + ['id' => 8, 'name' => 'Julia Fischer', 'email' => 'julia@example.com', 'status' => 'Aktiv'], + ['id' => 9, 'name' => 'Daniel Schneider', 'email' => 'daniel@example.com', 'status' => 'Inaktiv'], + ['id' => 10, 'name' => 'Laura Hoffmann', 'email' => 'laura@example.com', 'status' => 'Aktiv'], +]); + +// Row selection handler +$statusBar = null; // Will be set later +$table->setOnRowSelect(function ($index, $row) use (&$statusBar) { + if ($statusBar && $row) { + $statusBar->updateSegment(0, "Selected: {$row['name']} ({$row['email']})"); + } +}); + +$tab1->addComponent($table); +$tabContainer->addTab('Daten', $tab1); + +// Tab 2: Some info +$tab2 = new Container('flex flex-col p-4'); +$tab2->addComponent(new Label('Dies ist Tab 2', 'text-xl font-bold mb-4')); +$tab2->addComponent(new Label('Hier könnte weiterer Inhalt stehen...', '')); +$tabContainer->addTab('Info', $tab2); + +// Tab 3: Settings +$tab3 = new Container('flex flex-col p-4'); +$tab3->addComponent(new Label('Einstellungen', 'text-xl font-bold mb-4')); +$tab3->addComponent(new Label('Konfigurationsoptionen...', '')); +$tabContainer->addTab('Einstellungen', $tab3); + +$mainContainer->addComponent($tabContainer); + +// === 3. StatusBar === +$statusBar = new StatusBar(); +$statusBar->addSegment('Bereit', '', 0); // Flexible segment +$statusBar->addSegment('Zeilen: 10', 'border-l', 200); // Fixed width +$statusBar->addSegment('Version 1.0', 'border-l', 150); // Fixed width + +$mainContainer->addComponent($statusBar); + +// Set root and run +$window->setRoot($mainContainer); +$app->addWindow($window); + +echo "Windows Application Example started!\n"; +echo "Features:\n"; +echo "- MenuBar with 'Datei' and 'Einstellungen'\n"; +echo "- Tab Container with 3 tabs\n"; +echo "- Scrollable Table in first tab\n"; +echo "- StatusBar at the bottom\n\n"; + +$app->run(); diff --git a/examples/windows_menu_example.php b/examples/windows_menu_example.php new file mode 100644 index 0000000..1a1fbac --- /dev/null +++ b/examples/windows_menu_example.php @@ -0,0 +1,70 @@ +addMenu('Datei'); +$fileMenu->addItem('Neu', function () { + echo "Neu clicked\n"; +}); +$fileMenu->addItem('Öffnen', function () { + echo "Öffnen clicked\n"; +}); +$fileMenu->addSeparator(); +$fileMenu->addItem('Beenden', function () use ($app) { + echo "Beenden clicked\n"; + exit(0); +}); + +// Settings Menu +$settingsMenu = $menuBar->addMenu('Einstellungen'); +$settingsMenu->addItem('Optionen', function () { + echo "Optionen clicked\n"; +}); +$settingsMenu->addItem('Sprache', function () { + echo "Sprache clicked\n"; +}); + +$mainContainer->addComponent($menuBar); + +$statusBar = new StatusBar(); +$statusLabel = new Label( + text: 'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight, + style: 'basis-2/8 text-black', +); +$statusBar->addSegment($statusLabel); +$statusBar->addSegment(new Label( + text: 'Zeilen: 10', + style: 'basis-4/8 text-black border-l', +)); // Fixed width +$statusBar->addSegment(new Label( + text: 'Version 1.0', + style: 'border-l text-black basis-2/8', +)); +$mainContainer->addComponent($statusBar); +$window->setOnResize(function (Window $window) use (&$statusLabel) { + $statusLabel->setText( + 'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight, + ); +}); +// Set root and run +$window->setRoot($mainContainer); +$app->addWindow($window); +$app->run(); diff --git a/src/Framework/Application.php b/src/Framework/Application.php index 33c91b1..f8ad824 100644 --- a/src/Framework/Application.php +++ b/src/Framework/Application.php @@ -84,9 +84,7 @@ class Application while ($this->running && count($this->windows) > 0) { // Layout all windows FIRST (sets window references and calculates positions) foreach ($this->windows as $windowId => $window) { - if ($window->layout()) { - echo 'Layoutout: ' . PHP_EOL; - } + $window->layout(); } // SDL3: Poll all events globally and distribute to the correct windows $events = []; @@ -124,12 +122,16 @@ class Application // Check if this is a recently closed window (expected) if (!in_array($eventWindowId, $this->closedWindowIds)) { if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) { - error_log("[Application] WARNING: Event for window_id={$eventWindowId} could not be delivered (window not found)"); + error_log( + "[Application] WARNING: Event for window_id={$eventWindowId} could not be delivered (window not found)", + ); } } else { // This is a recently closed window, events are expected if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) { - error_log("[Application] Ignoring event for recently closed window_id={$eventWindowId}"); + error_log( + "[Application] Ignoring event for recently closed window_id={$eventWindowId}", + ); } } } diff --git a/src/Ui/Component.php b/src/Ui/Component.php index 7b26eec..2d23332 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -18,6 +18,10 @@ abstract class Component protected bool $visible = true; + protected bool $isOverlay = false; + + protected int $zIndex = 0; + protected StateEnum $currentState = StateEnum::normal; protected Viewport $viewport; @@ -83,6 +87,36 @@ abstract class Component $this->pixelRatio = $pixelRatio; } + public function setVisible(bool $visible): void + { + $this->visible = $visible; + } + + public function isVisible(): bool + { + return $this->visible; + } + + public function setOverlay(bool $isOverlay): void + { + $this->isOverlay = $isOverlay; + } + + public function isOverlay(): bool + { + return $this->isOverlay; + } + + public function setZIndex(int $zIndex): void + { + $this->zIndex = $zIndex; + } + + public function getZIndex(): int + { + return $this->zIndex; + } + public function update(): void { foreach ($this->children as $child) { @@ -184,6 +218,26 @@ abstract class Component $this->children[] = $component; } + /** + * 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 diff --git a/src/Ui/Widget/Button.php b/src/Ui/Widget/Button.php index 09aef4b..5ba36fb 100644 --- a/src/Ui/Widget/Button.php +++ b/src/Ui/Widget/Button.php @@ -22,7 +22,7 @@ class Button extends Container // Create label inside button $this->label = new Label( text: $text, - style: '', + style: 'text-black', ); $this->addComponent($this->label); @@ -45,6 +45,11 @@ class Button extends Container $this->onClick = $onClick; } + public function setStyle(string $style): void + { + $this->style = $style; + } + /** * Set async click handler that runs in background thread * @param callable $onClickAsync Task to run asynchronously @@ -53,8 +58,8 @@ class Button extends Container */ public function setOnClickAsync( callable $onClickAsync, - ?callable $onComplete = null, - ?callable $onError = null + null|callable $onComplete = null, + null|callable $onError = null, ): void { $this->onClickAsync = [ 'task' => $onClickAsync, @@ -75,7 +80,7 @@ class Button extends Container $this->viewport->x, $this->viewport->y, $this->viewport->x + $this->viewport->width, - $this->viewport->y + $this->viewport->height + $this->viewport->y + $this->viewport->height, )); } @@ -101,8 +106,7 @@ class Button extends Container if ($this->onClickAsync['onError'] !== null) { $task->onError($this->onClickAsync['onError']); } - } - // Call sync onClick callback if set + } // Call sync onClick callback if set elseif ($this->onClick !== null) { ($this->onClick)(); } diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index 1907eb3..b12a9e5 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -103,6 +103,11 @@ class Container extends Component $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, @@ -122,6 +127,26 @@ class Container extends Component } } + // 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(); @@ -166,6 +191,10 @@ class Container extends Component $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, @@ -229,6 +258,11 @@ class Container extends Component $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']; @@ -284,6 +318,11 @@ class Container extends Component $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); @@ -348,11 +387,16 @@ class Container extends Component 'x' => $scissorX, 'y' => $scissorY, 'w' => $scissorW, - 'h' => $scissorH + 'h' => $scissorH, ]); - // Render children with scroll offset + // 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); @@ -379,9 +423,17 @@ class Container extends Component // Render scrollbars $this->renderScrollbars($renderer, $overflow); } else { - // No overflow, render normally - parent::renderContent($renderer, $textRenderer); + // 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 @@ -402,15 +454,12 @@ class Container extends Component // 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, - ] - ); + 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); @@ -443,15 +492,12 @@ class Container extends Component // 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, - ] - ); + 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); diff --git a/src/Ui/Widget/Menu.php b/src/Ui/Widget/Menu.php new file mode 100644 index 0000000..30f6af5 --- /dev/null +++ b/src/Ui/Widget/Menu.php @@ -0,0 +1,100 @@ +menuButton = new Button($title, 'px-4 py-2 hover:bg-gray-200', $onToggle); + + $this->addComponent($this->menuButton); + + // Create dropdown container (initially hidden) + $this->dropdown = new Container('flex flex-col absolute bg-white border border-gray-300 shadow-lg z-50'); + $this->dropdown->setVisible(false); + $this->dropdown->setOverlay(true); // Mark as overlay so it doesn't affect layout and renders on top + $this->dropdown->setZIndex(100); // Dropdown menus at z-index 100 (modals could use 1000+) + $this->addComponent($this->dropdown); + } + + /** + * Add a menu item + * + * @param string $text Menu item text + * @param callable $onClick Click handler + */ + public function addItem(string $text, callable $onClick): MenuItem + { + $menuItem = new MenuItem($text, function () use ($onClick) { + // Close menu and execute action + $this->close(); + $onClick(); + }); + + $this->items[] = $menuItem; + $this->dropdown->addComponent($menuItem); + + return $menuItem; + } + + /** + * Add a separator + */ + public function addSeparator(): void + { + $separator = new Separator(true, 'my-1'); + $this->items[] = $separator; + $this->dropdown->addComponent($separator); + } + + /** + * Open the menu + */ + public function open(): void + { + $this->isOpen = true; + $this->dropdown->setVisible(true); + } + + /** + * Close the menu + */ + public function close(): void + { + $this->isOpen = false; + $this->dropdown->setVisible(false); + } + + /** + * Check if menu is open + */ + public function isOpen(): bool + { + return $this->isOpen; + } + + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + // Get the button's viewport to position dropdown correctly + $buttonViewport = $this->menuButton->getViewport(); + $this->dropdown->getViewport()->x = $buttonViewport->x; + $this->dropdown->getViewport()->y = $buttonViewport->y + $buttonViewport->height; + $this->dropdown->getViewport()->width = 200; // Fixed width for dropdown + $this->dropdown->getViewport()->windowWidth = $buttonViewport->windowWidth; + $this->dropdown->getViewport()->windowHeight = $buttonViewport->windowHeight; + $this->dropdown->layout($textRenderer); + } +} diff --git a/src/Ui/Widget/MenuBar.php b/src/Ui/Widget/MenuBar.php new file mode 100644 index 0000000..f7d934f --- /dev/null +++ b/src/Ui/Widget/MenuBar.php @@ -0,0 +1,82 @@ +menus); + + $menu = new Menu($title, function () use ($menuIndex) { + $this->toggleMenu($menuIndex); + }); + + $this->menus[] = $menu; + $this->addComponent($menu); + + return $menu; + } + + /** + * Toggle a menu open/closed + * + * @param int $index Menu index + */ + private function toggleMenu(int $index): void + { + if ($this->openMenuIndex === $index) { + // Close the currently open menu + $this->menus[$index]->close(); + $this->openMenuIndex = null; + } else { + // Close previously open menu + if ($this->openMenuIndex !== null) { + $this->menus[$this->openMenuIndex]->close(); + } + + // Open the clicked menu + $this->menus[$index]->open(); + $this->openMenuIndex = $index; + } + } + + /** + * Close all menus + */ + public function closeAllMenus(): void + { + if ($this->openMenuIndex !== null) { + $this->menus[$this->openMenuIndex]->close(); + $this->openMenuIndex = null; + } + } + + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + $handled = parent::handleMouseClick($mouseX, $mouseY, $button); + + // If click was not handled by any menu, close all menus + if (!$handled && $this->openMenuIndex !== null) { + $this->closeAllMenus(); + } + + return $handled; + } +} diff --git a/src/Ui/Widget/MenuItem.php b/src/Ui/Widget/MenuItem.php new file mode 100644 index 0000000..90cb68f --- /dev/null +++ b/src/Ui/Widget/MenuItem.php @@ -0,0 +1,11 @@ +horizontal = $horizontal; + + // Default styles based on orientation + $defaultStyle = $horizontal + ? 'bg-gray-300 h-0.5 w-full my-1' + : 'bg-gray-300 w-0.5 h-full mx-1'; + + parent::__construct($defaultStyle . ' ' . $style); + } + + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + // Apply explicit height/width from styles + $height = $this->computedStyles[\PHPNative\Tailwind\Style\Height::class] ?? null; + $width = $this->computedStyles[\PHPNative\Tailwind\Style\Width::class] ?? null; + + if ($this->horizontal) { + // For horizontal separator, enforce the height from style (default h-0.5 = 2px) + if ($height) { + $this->viewport->height = (int) $height->value; + } elseif ($this->viewport->height < 1) { + $this->viewport->height = 2; // Default 2px + } + $this->contentViewport->height = $this->viewport->height; + } else { + // For vertical separator, enforce the width from style (default w-0.5 = 2px) + if ($width) { + $this->viewport->width = (int) $width->value; + } elseif ($this->viewport->width < 1) { + $this->viewport->width = 2; // Default 2px + } + $this->contentViewport->width = $this->viewport->width; + } + } +} diff --git a/src/Ui/Widget/StatusBar.php b/src/Ui/Widget/StatusBar.php new file mode 100644 index 0000000..241c817 --- /dev/null +++ b/src/Ui/Widget/StatusBar.php @@ -0,0 +1,20 @@ +addComponent($label); + } +} diff --git a/src/Ui/Widget/TabContainer.php b/src/Ui/Widget/TabContainer.php new file mode 100644 index 0000000..d3c235f --- /dev/null +++ b/src/Ui/Widget/TabContainer.php @@ -0,0 +1,111 @@ +tabHeaderContainer = new Container('flex flex-row bg-gray-100 border-b border-gray-300'); + $this->addComponent($this->tabHeaderContainer); + + // Create content container + $this->tabContentContainer = new Container('flex-1 overflow-auto'); + $this->addComponent($this->tabContentContainer); + } + + /** + * Add a tab with title and content + * + * @param string $title Tab title + * @param Container $content Tab content container + */ + public function addTab(string $title, Container $content): void + { + $tabIndex = count($this->tabs); + $isActive = $tabIndex === $this->activeTabIndex; + + // Create tab button + $tabStyle = $isActive + ? 'bg-white border-t border-l border-r border-gray-300 px-4 py-2' + : 'bg-gray-200 hover:bg-gray-300 border-t border-l border-r border-transparent px-4 py-2'; + + $tabButton = new Button($title, $tabStyle); + $tabButton->setOnClick(function() use ($tabIndex) { + $this->setActiveTab($tabIndex); + }); + + $this->tabHeaderContainer->addComponent($tabButton); + + $this->tabs[] = [ + 'title' => $title, + 'button' => $tabButton, + 'content' => $content, + ]; + + // Add content to container if it's the active tab + if ($isActive) { + $this->tabContentContainer->clearChildren(); + $this->tabContentContainer->addComponent($content); + } + } + + /** + * Set the active tab by index + * + * @param int $index Tab index + */ + public function setActiveTab(int $index): void + { + if ($index < 0 || $index >= count($this->tabs)) { + return; + } + + $this->activeTabIndex = $index; + + // Update tab button styles + foreach ($this->tabs as $i => $tab) { + $isActive = $i === $index; + $style = $isActive + ? 'bg-white border-t border-l border-r border-gray-300 px-4 py-2' + : 'bg-gray-200 hover:bg-gray-300 border-t border-l border-r border-transparent px-4 py-2'; + + $tab['button']->setStyle($style); + } + + // Update content + $this->tabContentContainer->clearChildren(); + $this->tabContentContainer->addComponent($this->tabs[$index]['content']); + } + + /** + * Get the active tab index + * + * @return int + */ + public function getActiveTabIndex(): int + { + return $this->activeTabIndex; + } + + /** + * Get total number of tabs + * + * @return int + */ + public function getTabCount(): int + { + return count($this->tabs); + } +} diff --git a/src/Ui/Widget/Table.php b/src/Ui/Widget/Table.php new file mode 100644 index 0000000..b3c2449 --- /dev/null +++ b/src/Ui/Widget/Table.php @@ -0,0 +1,171 @@ +headerContainer = new Container('flex flex-row bg-gray-200 border-b-2 border-gray-400'); + $this->addComponent($this->headerContainer); + + // Create body container (scrollable) + $this->bodyContainer = new Container('flex flex-col overflow-auto flex-1'); + $this->addComponent($this->bodyContainer); + } + + /** + * Define columns + * + * @param array $columns Array of column definitions ['key' => string, 'title' => string, 'width' => int|null] + */ + public function setColumns(array $columns): void + { + $this->columns = $columns; + $this->headerContainer->clearChildren(); + + foreach ($columns as $column) { + $title = $column['title'] ?? $column['key']; + $width = $column['width'] ?? null; + + $style = 'px-4 py-2 font-bold border-r border-gray-300'; + if ($width) { + $style .= ' w-' . ((int)($width / 4)); + } else { + $style .= ' flex-1'; + } + + $headerLabel = new Label($title, $style); + $this->headerContainer->addComponent($headerLabel); + } + } + + /** + * Set table data + * + * @param array $data Array of row data (associative arrays) + */ + public function setData(array $data): void + { + $this->rows = $data; + $this->bodyContainer->clearChildren(); + + foreach ($data as $rowIndex => $row) { + $this->addRow($row, $rowIndex); + } + } + + /** + * Add a single row + */ + private function addRow(array $rowData, int $rowIndex): void + { + $isSelected = $rowIndex === $this->selectedRowIndex; + $rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-100'; + if ($isSelected) { + $rowStyle .= ' bg-blue-100'; + } + + $rowContainer = new Container($rowStyle); + + foreach ($this->columns as $column) { + $key = $column['key']; + $value = $rowData[$key] ?? ''; + $width = $column['width'] ?? null; + + $cellStyle = 'px-4 py-2 border-r border-gray-300'; + if ($width) { + $cellStyle .= ' w-' . ((int)($width / 4)); + } else { + $cellStyle .= ' flex-1'; + } + + $cellLabel = new Label((string)$value, $cellStyle); + $rowContainer->addComponent($cellLabel); + } + + // Make row clickable + $clickHandler = new class($rowContainer, $rowIndex, $this) extends Container { + private int $rowIndex; + private Table $table; + + public function __construct(Container $rowContainer, int $rowIndex, Table $table) + { + $this->rowIndex = $rowIndex; + $this->table = $table; + parent::__construct(''); + $this->addComponent($rowContainer); + } + + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + // Check if click is within row bounds + if ( + $mouseX >= $this->viewport->x && + $mouseX <= ($this->viewport->x + $this->viewport->width) && + $mouseY >= $this->viewport->y && + $mouseY <= ($this->viewport->y + $this->viewport->height) + ) { + $this->table->selectRow($this->rowIndex); + return true; + } + return parent::handleMouseClick($mouseX, $mouseY, $button); + } + }; + + $this->bodyContainer->addComponent($clickHandler); + } + + /** + * Select a row + */ + public function selectRow(int $rowIndex): void + { + $this->selectedRowIndex = $rowIndex; + + // Trigger callback + if ($this->onRowSelect !== null) { + ($this->onRowSelect)($rowIndex, $this->rows[$rowIndex] ?? null); + } + + // Re-render rows to update selection + $this->setData($this->rows); + } + + /** + * Set row select callback + */ + public function setOnRowSelect(callable $callback): void + { + $this->onRowSelect = $callback; + } + + /** + * Get selected row index + */ + public function getSelectedRowIndex(): ?int + { + return $this->selectedRowIndex; + } + + /** + * Get selected row data + */ + public function getSelectedRow(): ?array + { + return $this->selectedRowIndex !== null ? ($this->rows[$this->selectedRowIndex] ?? null) : null; + } +} diff --git a/src/Ui/Window.php b/src/Ui/Window.php index 43439f9..e705b02 100644 --- a/src/Ui/Window.php +++ b/src/Ui/Window.php @@ -10,7 +10,7 @@ class Window private mixed $renderer = null; private int $windowId = 0; private null|Component $rootComponent = null; - private ?TextRenderer $textRenderer; + private null|TextRenderer $textRenderer; private float $mouseX = 0; private float $mouseY = 0; private Viewport $viewport; @@ -18,6 +18,7 @@ class Window private float $pixelRatio = 2; private bool $shouldClose = false; private bool $hasBeenLaidOut = false; + private $onResize = null; public function __construct( private string $title, @@ -160,87 +161,89 @@ class Window } switch ($event['type']) { - case SDL_EVENT_QUIT: - case SDL_EVENT_WINDOW_CLOSE_REQUESTED: - $this->shouldClose = true; - break; + case SDL_EVENT_QUIT: + case SDL_EVENT_WINDOW_CLOSE_REQUESTED: + $this->shouldClose = true; + break; - case SDL_EVENT_KEY_DOWN: - $keycode = $event['keycode'] ?? 0; + case SDL_EVENT_KEY_DOWN: + $keycode = $event['keycode'] ?? 0; - // Propagate key event to root component - if ($this->rootComponent) { - $this->rootComponent->handleKeyDown($keycode); - } - break; + // Propagate key event to root component + if ($this->rootComponent) { + $this->rootComponent->handleKeyDown($keycode); + } + break; - case SDL_EVENT_TEXT_INPUT: - $text = $event['text'] ?? ''; + case SDL_EVENT_TEXT_INPUT: + $text = $event['text'] ?? ''; - // Propagate text input to root component - if ($this->rootComponent && !empty($text)) { - $this->rootComponent->handleTextInput($text); - } - break; + // 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; + 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; - $this->width = $newWidth; - $this->height = $newHeight; + // Update text renderer framebuffer + if ($this->textRenderer && $this->textRenderer->isInitialized()) { + $this->textRenderer->updateFramebuffer($newWidth, $newHeight); + } - // Update text renderer framebuffer - if ($this->textRenderer && $this->textRenderer->isInitialized()) { - $this->textRenderer->updateFramebuffer($newWidth, $newHeight); - } + $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; - $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; - break; + case SDL_EVENT_MOUSE_MOTION: + $this->mouseX = $event['x'] ?? 0; + $this->mouseY = $event['y'] ?? 0; - case SDL_EVENT_MOUSE_MOTION: - $this->mouseX = $event['x'] ?? 0; - $this->mouseY = $event['y'] ?? 0; + // Propagate mouse move to root component + if ($this->rootComponent) { + $this->rootComponent->handleMouseMove($this->mouseX, $this->mouseY); + } + break; - // Propagate mouse move to root component - if ($this->rootComponent) { - $this->rootComponent->handleMouseMove($this->mouseX, $this->mouseY); - } - break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + $button = $event['button'] ?? 0; + // Propagate click to root component + if ($this->rootComponent) { + $this->rootComponent->handleMouseClick($this->mouseX, $this->mouseY, $button); + } + break; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - $button = $event['button'] ?? 0; - // Propagate click to root component - if ($this->rootComponent) { - $this->rootComponent->handleMouseClick($this->mouseX, $this->mouseY, $button); - } - break; + case SDL_EVENT_MOUSE_BUTTON_UP: + $button = $event['button'] ?? 0; - 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; - // 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; - case SDL_EVENT_MOUSE_WHEEL: - $deltaY = $event['y'] ?? 0; - - // Propagate wheel to root component - if ($this->rootComponent) { - $this->rootComponent->handleMouseWheel($this->mouseX, $this->mouseY, $deltaY); - } - break; - } + // Propagate wheel to root component + if ($this->rootComponent) { + $this->rootComponent->handleMouseWheel($this->mouseX, $this->mouseY, $deltaY); + } + break; + } } /** @@ -295,6 +298,17 @@ class Window if ($this->hasBeenLaidOut && $this->rootComponent) { $this->rootComponent->render($this->renderer, $this->textRenderer); $this->rootComponent->renderContent($this->renderer, $this->textRenderer); + + // Render all overlays last (they appear on top of everything) + $overlays = $this->rootComponent->collectOverlays(); + usort($overlays, fn($a, $b) => $a->getZIndex() <=> $b->getZIndex()); + + foreach ($overlays as $overlay) { + if ($overlay->isVisible()) { + $overlay->render($this->renderer, $this->textRenderer); + $overlay->renderContent($this->renderer, $this->textRenderer); + } + } } // Present the rendered content @@ -337,4 +351,14 @@ class Window { $this->shouldBeReLayouted = $shouldBeReLayouted; } + + public function getViewport(): Viewport + { + return $this->viewport; + } + + public function setOnResize(callable $callback): void + { + $this->onResize = $callback; + } }