This commit is contained in:
Thomas Peterson 2025-10-26 22:47:12 +01:00
parent a1fabd3623
commit 6ed95d47e5
14 changed files with 967 additions and 101 deletions

View File

@ -0,0 +1,119 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\MenuBar;
use PHPNative\Ui\Widget\StatusBar;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Widget\Table;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Windows Application Example', 800, 600);
// Main container (flex-col: menu, content, status)
$mainContainer = new Container('flex flex-col bg-gray-100');
// === 1. MenuBar ===
$menuBar = new MenuBar();
// File Menu
$fileMenu = $menuBar->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();

View File

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\MenuBar;
use PHPNative\Ui\Widget\StatusBar;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Widget\Table;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Windows Application Example', 800, 600);
$mainContainer = new Container('bg-gray-100');
// === 1. MenuBar ===
$menuBar = new MenuBar();
// File Menu
$fileMenu = $menuBar->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();

View File

@ -84,9 +84,7 @@ class Application
while ($this->running && count($this->windows) > 0) { while ($this->running && count($this->windows) > 0) {
// Layout all windows FIRST (sets window references and calculates positions) // Layout all windows FIRST (sets window references and calculates positions)
foreach ($this->windows as $windowId => $window) { foreach ($this->windows as $windowId => $window) {
if ($window->layout()) { $window->layout();
echo 'Layoutout: ' . PHP_EOL;
}
} }
// SDL3: Poll all events globally and distribute to the correct windows // SDL3: Poll all events globally and distribute to the correct windows
$events = []; $events = [];
@ -124,12 +122,16 @@ class Application
// Check if this is a recently closed window (expected) // Check if this is a recently closed window (expected)
if (!in_array($eventWindowId, $this->closedWindowIds)) { if (!in_array($eventWindowId, $this->closedWindowIds)) {
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) { 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 { } else {
// This is a recently closed window, events are expected // This is a recently closed window, events are expected
if (defined('DEBUG_EVENTS') && DEBUG_EVENTS) { 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}",
);
} }
} }
} }

View File

@ -18,6 +18,10 @@ abstract class Component
protected bool $visible = true; protected bool $visible = true;
protected bool $isOverlay = false;
protected int $zIndex = 0;
protected StateEnum $currentState = StateEnum::normal; protected StateEnum $currentState = StateEnum::normal;
protected Viewport $viewport; protected Viewport $viewport;
@ -83,6 +87,36 @@ abstract class Component
$this->pixelRatio = $pixelRatio; $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 public function update(): void
{ {
foreach ($this->children as $child) { foreach ($this->children as $child) {
@ -184,6 +218,26 @@ abstract class Component
$this->children[] = $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 * Handle mouse click event
* @return bool True if event was handled * @return bool True if event was handled

View File

@ -22,7 +22,7 @@ class Button extends Container
// Create label inside button // Create label inside button
$this->label = new Label( $this->label = new Label(
text: $text, text: $text,
style: '', style: 'text-black',
); );
$this->addComponent($this->label); $this->addComponent($this->label);
@ -45,6 +45,11 @@ class Button extends Container
$this->onClick = $onClick; $this->onClick = $onClick;
} }
public function setStyle(string $style): void
{
$this->style = $style;
}
/** /**
* Set async click handler that runs in background thread * Set async click handler that runs in background thread
* @param callable $onClickAsync Task to run asynchronously * @param callable $onClickAsync Task to run asynchronously
@ -53,8 +58,8 @@ class Button extends Container
*/ */
public function setOnClickAsync( public function setOnClickAsync(
callable $onClickAsync, callable $onClickAsync,
?callable $onComplete = null, null|callable $onComplete = null,
?callable $onError = null null|callable $onError = null,
): void { ): void {
$this->onClickAsync = [ $this->onClickAsync = [
'task' => $onClickAsync, 'task' => $onClickAsync,
@ -75,7 +80,7 @@ class Button extends Container
$this->viewport->x, $this->viewport->x,
$this->viewport->y, $this->viewport->y,
$this->viewport->x + $this->viewport->width, $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) { if ($this->onClickAsync['onError'] !== null) {
$task->onError($this->onClickAsync['onError']); $task->onError($this->onClickAsync['onError']);
} }
} } // Call sync onClick callback if set
// Call sync onClick callback if set
elseif ($this->onClick !== null) { elseif ($this->onClick !== null) {
($this->onClick)(); ($this->onClick)();
} }

View File

@ -103,6 +103,11 @@ class Container extends Component
$currentY = $this->contentViewport->y; $currentY = $this->contentViewport->y;
foreach ($this->children as $child) { 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 // Create a viewport for the child with available width but let height be determined by child
$childViewport = new Viewport( $childViewport = new Viewport(
x: $this->contentViewport->x, 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 // Calculate total content size after children are laid out
$this->calculateContentSize(); $this->calculateContentSize();
@ -166,6 +191,10 @@ class Container extends Component
$usedSpace = 0; $usedSpace = 0;
foreach ($this->children as $index => $child) { 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 // Parse child styles to get basis, width, height, and flex
$childStyles = \PHPNative\Tailwind\StyleParser::parse($child->style)->getValidStyles( $childStyles = \PHPNative\Tailwind\StyleParser::parse($child->style)->getValidStyles(
\PHPNative\Tailwind\Style\MediaQueryEnum::normal, \PHPNative\Tailwind\Style\MediaQueryEnum::normal,
@ -229,6 +258,11 @@ class Container extends Component
$currentPosition = $isRow ? $this->contentViewport->x : $this->contentViewport->y; $currentPosition = $isRow ? $this->contentViewport->x : $this->contentViewport->y;
foreach ($this->children as $index => $child) { foreach ($this->children as $index => $child) {
// Skip overlays in flex layout
if ($child->isOverlay()) {
continue;
}
$childSize = $childSizes[$index]; $childSize = $childSizes[$index];
$size = $childSize['flexGrow'] ? $flexGrowSize : $childSize['size']; $size = $childSize['flexGrow'] ? $flexGrowSize : $childSize['size'];
@ -284,6 +318,11 @@ class Container extends Component
$maxY = 0; $maxY = 0;
foreach ($this->children as $child) { foreach ($this->children as $child) {
// Skip overlays - they don't contribute to content size
if ($child->isOverlay()) {
continue;
}
$childViewport = $child->getViewport(); $childViewport = $child->getViewport();
$maxX = max($maxX, ($childViewport->x + $childViewport->width) - $this->contentViewport->x); $maxX = max($maxX, ($childViewport->x + $childViewport->width) - $this->contentViewport->x);
$maxY = max($maxY, ($childViewport->y + $childViewport->height) - $this->contentViewport->y); $maxY = max($maxY, ($childViewport->y + $childViewport->height) - $this->contentViewport->y);
@ -348,11 +387,16 @@ class Container extends Component
'x' => $scissorX, 'x' => $scissorX,
'y' => $scissorY, 'y' => $scissorY,
'w' => $scissorW, 'w' => $scissorW,
'h' => $scissorH 'h' => $scissorH,
]); ]);
// Render children with scroll offset // Render children with scroll offset (skip overlays)
foreach ($this->children as $child) { 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 // Apply scroll offset recursively to child and all its descendants
$child->applyScrollOffset((int) $this->scrollX, (int) $this->scrollY); $child->applyScrollOffset((int) $this->scrollX, (int) $this->scrollY);
@ -379,10 +423,18 @@ class Container extends Component
// Render scrollbars // Render scrollbars
$this->renderScrollbars($renderer, $overflow); $this->renderScrollbars($renderer, $overflow);
} else { } else {
// No overflow, render normally // No overflow, render normally (skip overlays)
parent::renderContent($renderer, $textRenderer); 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 private function renderScrollbars(&$renderer, array $overflow): void
{ {
@ -402,15 +454,12 @@ class Container extends Component
// Track // Track
sdl_set_render_draw_color($renderer, 200, 200, 200, 100); sdl_set_render_draw_color($renderer, 200, 200, 200, 100);
sdl_render_fill_rect( sdl_render_fill_rect($renderer, [
$renderer,
[
'x' => (int) $scrollbarX, 'x' => (int) $scrollbarX,
'y' => (int) $this->contentViewport->y, 'y' => (int) $this->contentViewport->y,
'w' => (int) self::SCROLLBAR_WIDTH, 'w' => (int) self::SCROLLBAR_WIDTH,
'h' => (int) $scrollbarHeight, 'h' => (int) $scrollbarHeight,
] ]);
);
// Thumb - using sdl_rounded_box for rounded rectangle // Thumb - using sdl_rounded_box for rounded rectangle
$thumbX = (int) ($scrollbarX + 2); $thumbX = (int) ($scrollbarX + 2);
@ -443,15 +492,12 @@ class Container extends Component
// Track // Track
sdl_set_render_draw_color($renderer, 200, 200, 200, 100); sdl_set_render_draw_color($renderer, 200, 200, 200, 100);
sdl_render_fill_rect( sdl_render_fill_rect($renderer, [
$renderer,
[
'x' => (int) $this->contentViewport->x, 'x' => (int) $this->contentViewport->x,
'y' => (int) $scrollbarY, 'y' => (int) $scrollbarY,
'w' => (int) $scrollbarWidth, 'w' => (int) $scrollbarWidth,
'h' => (int) self::SCROLLBAR_WIDTH, 'h' => (int) self::SCROLLBAR_WIDTH,
] ]);
);
// Thumb - using sdl_rounded_box for rounded rectangle // Thumb - using sdl_rounded_box for rounded rectangle
$thumbY = (int) ($scrollbarY + 2); $thumbY = (int) ($scrollbarY + 2);

100
src/Ui/Widget/Menu.php Normal file
View File

@ -0,0 +1,100 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class Menu extends Container
{
private Button $menuButton;
private Container $dropdown;
private bool $isOpen = false;
private array $items = [];
public function __construct(string $title, callable $onToggle)
{
parent::__construct('relative');
// Create menu button
$this->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);
}
}

82
src/Ui/Widget/MenuBar.php Normal file
View File

@ -0,0 +1,82 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class MenuBar extends Container
{
private array $menus = [];
private null|int $openMenuIndex = null;
public function __construct(string $style = 'w-full bg-gray-100 border-b border-gray-300')
{
parent::__construct('flex flex-row ' . $style);
}
/**
* Add a menu to the menu bar
*
* @param string $title Menu title
* @return Menu The created menu
*/
public function addMenu(string $title): Menu
{
$menuIndex = count($this->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;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace PHPNative\Ui\Widget;
class MenuItem extends Button
{
public function __construct(string $text, callable $onClick, string $style = '')
{
parent::__construct($text, 'px-4 text py-2 hover:bg-gray-100 text-left w-full ' . $style, $onClick);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Ui\Component;
class Separator extends Component
{
private bool $horizontal;
public function __construct(
bool $horizontal = true,
string $style = '',
) {
$this->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;
}
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class StatusBar extends Container
{
private array $segments = [];
public function __construct(string $style = 'w-full bg-gray-200 border-t border-gray-300 flex-row')
{
parent::__construct($style);
}
public function addSegment(Label $label): void
{
$this->addComponent($label);
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class TabContainer extends Container
{
private Container $tabHeaderContainer;
private Container $tabContentContainer;
private array $tabs = [];
private int $activeTabIndex = 0;
public function __construct(
string $style = '',
) {
parent::__construct('flex flex-col ' . $style);
// Create header container for tab buttons
$this->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);
}
}

171
src/Ui/Widget/Table.php Normal file
View File

@ -0,0 +1,171 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
class Table extends Container
{
private array $columns = [];
private array $rows = [];
private Container $headerContainer;
private Container $bodyContainer;
private ?int $selectedRowIndex = null;
private $onRowSelect = null;
public function __construct(
string $style = '',
) {
parent::__construct('flex flex-col overflow-auto ' . $style);
// Create header container
$this->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;
}
}

View File

@ -10,7 +10,7 @@ class Window
private mixed $renderer = null; private mixed $renderer = null;
private int $windowId = 0; private int $windowId = 0;
private null|Component $rootComponent = null; private null|Component $rootComponent = null;
private ?TextRenderer $textRenderer; private null|TextRenderer $textRenderer;
private float $mouseX = 0; private float $mouseX = 0;
private float $mouseY = 0; private float $mouseY = 0;
private Viewport $viewport; private Viewport $viewport;
@ -18,6 +18,7 @@ class Window
private float $pixelRatio = 2; private float $pixelRatio = 2;
private bool $shouldClose = false; private bool $shouldClose = false;
private bool $hasBeenLaidOut = false; private bool $hasBeenLaidOut = false;
private $onResize = null;
public function __construct( public function __construct(
private string $title, private string $title,
@ -187,7 +188,6 @@ class Window
// Update window dimensions (from event data) // Update window dimensions (from event data)
$newWidth = $event['data1'] ?? $this->width; $newWidth = $event['data1'] ?? $this->width;
$newHeight = $event['data2'] ?? $this->height; $newHeight = $event['data2'] ?? $this->height;
$this->width = $newWidth; $this->width = $newWidth;
$this->height = $newHeight; $this->height = $newHeight;
@ -203,6 +203,9 @@ class Window
$this->viewport->height = $newHeight; $this->viewport->height = $newHeight;
$this->viewport->windowHeight = $newHeight; $this->viewport->windowHeight = $newHeight;
$this->shouldBeReLayouted = true; $this->shouldBeReLayouted = true;
if ($this->onResize) {
($this->onResize)($this);
}
break; break;
case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_MOTION:
@ -295,6 +298,17 @@ class Window
if ($this->hasBeenLaidOut && $this->rootComponent) { if ($this->hasBeenLaidOut && $this->rootComponent) {
$this->rootComponent->render($this->renderer, $this->textRenderer); $this->rootComponent->render($this->renderer, $this->textRenderer);
$this->rootComponent->renderContent($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 // Present the rendered content
@ -337,4 +351,14 @@ class Window
{ {
$this->shouldBeReLayouted = $shouldBeReLayouted; $this->shouldBeReLayouted = $shouldBeReLayouted;
} }
public function getViewport(): Viewport
{
return $this->viewport;
}
public function setOnResize(callable $callback): void
{
$this->onResize = $callback;
}
} }