diff --git a/composer.json b/composer.json index 2558e2d..7ef5a9a 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,9 @@ "ext-parallel": "^1.2", "monolog/monolog": "^3.9", "php": "^8.4", - "symfony/var-dumper": "^7.3" + "symfony/var-dumper": "^7.3", + "lkdevelopment/hetzner-cloud-php-sdk": "^2.9", + "phpseclib/phpseclib": "^3.0" }, "license": "MIT", "autoload": { diff --git a/examples/test_simple.php b/examples/test_simple.php new file mode 100644 index 0000000..b33675b --- /dev/null +++ b/examples/test_simple.php @@ -0,0 +1,29 @@ +addComponent($label); + +$button = new Button('Test Button', 'px-4 py-2 bg-blue-600 text-white rounded'); +$button->setOnClick(function() use ($label) { + $label->setText('Button clicked!'); +}); +$container->addComponent($button); + +$window->setRoot($container); +$app->addWindow($window); + +echo "Simple test starting...\n"; +$app->run(); diff --git a/examples/windows_app_example.php b/examples/windows_app_example.php index 542235a..3a954c9 100644 --- a/examples/windows_app_example.php +++ b/examples/windows_app_example.php @@ -3,11 +3,14 @@ require_once __DIR__ . '/../vendor/autoload.php'; use PHPNative\Framework\Application; +use PHPNative\Framework\HetznerClient; use PHPNative\Framework\IconFontRegistry; +use PHPNative\Framework\Profiler; use PHPNative\Framework\Settings; use PHPNative\Tailwind\Data\Icon as IconName; use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Container; +use PHPNative\Ui\Widget\FileBrowser; use PHPNative\Ui\Widget\Icon; use PHPNative\Ui\Widget\Label; use PHPNative\Ui\Widget\Menu; @@ -41,26 +44,34 @@ if ($iconFontPath !== null) { echo "Hinweis: FontAwesome Font nicht gefunden. Icons werden ohne Symbol dargestellt.\n"; } +// Enable profiler to identify performance bottlenecks (disabled by default) +// Profiler::enable(); + $app = new Application(); $window = new Window('Windows Application Example', 800, 600); // Initialize settings $settings = new Settings('WindowsAppExample'); $currentApiKey = $settings->get('api_key', ''); +$currentPrivateKeyPath = $settings->get('private_key_path', ''); /** @var Label|null $statusLabel */ $statusLabel = null; +// Store selected server for SFTP connection +$selectedServer = null; + // Main container (flex-col: menu, content, status) $mainContainer = new Container('flex flex-col bg-gray-100'); // Modal dialog setup (hidden by default) $apiKeyInput = new TextInput('API Key', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black'); +$privateKeyPathInput = new TextInput('Private Key Path', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black'); $modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3'); $modalDialog->addComponent(new Label('API Einstellungen', 'text-xl font-bold text-black')); $modalDialog->addComponent(new Label( - 'Bitte gib deinen API Key ein, um externe Dienste zu verbinden.', + 'Bitte gib deinen API Key und den Pfad zum Private Key für SSH-Verbindungen ein.', 'text-sm text-gray-700', )); @@ -69,6 +80,11 @@ $fieldContainer->addComponent(new Label('API Key', 'text-sm text-gray-600')); $fieldContainer->addComponent($apiKeyInput); $modalDialog->addComponent($fieldContainer); +$privateKeyFieldContainer = new Container('flex flex-col gap-1'); +$privateKeyFieldContainer->addComponent(new Label('Private Key Pfad', 'text-sm text-gray-600')); +$privateKeyFieldContainer->addComponent($privateKeyPathInput); +$modalDialog->addComponent($privateKeyFieldContainer); + $buttonRow = new Container('flex flex-row gap-2 justify-end'); $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300'); $saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center'); @@ -99,9 +115,10 @@ $fileMenu->addItem('Beenden', function () use ($app) { // Settings Menu $settingsMenu = new Menu(title: 'Einstellungen'); -$settingsMenu->addItem('Optionen', function () use ($menuBar, $modal, $apiKeyInput, &$currentApiKey) { +$settingsMenu->addItem('Optionen', function () use ($menuBar, $modal, $apiKeyInput, $privateKeyPathInput, &$currentApiKey, &$currentPrivateKeyPath) { $menuBar->closeAllMenus(); $apiKeyInput->setValue($currentApiKey); + $privateKeyPathInput->setValue($currentPrivateKeyPath); $modal->setVisible(true); }); $settingsMenu->addItem('Sprache', function () { @@ -114,43 +131,156 @@ $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(style: ''); +// Tab 1: Table with server data (Master-Detail Layout) +$tab1 = new Container('flex flex-row p-4 gap-4'); // Changed to flex-row for side-by-side layout + +// Left side: Table with refresh button +$leftSide = new Container('flex flex-col gap-2 flex-1'); // flex-1 to take remaining space + +// Refresh button (will be configured after loadServers function is defined) +$refreshButton = new Button( + 'Server aktualisieren', + 'flex flex-row gap-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', +); +$refreshIcon = new Icon(IconName::search, 16, 'text-white'); +$refreshButton->setIcon($refreshIcon); + +$leftSide->addComponent($refreshButton); + +$table = new Table(style: ' flex-1'); // flex-1 to fill available height but not expand infinitely $table->setColumns([ - ['key' => 'id', 'title' => 'ID', 'width' => 80], - ['key' => 'name', 'title' => 'Name'], - ['key' => 'email', 'title' => 'E-Mail'], + ['key' => 'id', 'title' => 'ID', 'width' => 100], + ['key' => 'name', 'title' => 'Name', 'width' => 400], ['key' => 'status', 'title' => 'Status', 'width' => 120], + ['key' => 'type', 'title' => 'Typ', 'width' => 120], + ['key' => 'ipv4', 'title' => 'IPv4'], ]); -$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'], -]); +// Test data with 63 entries +$testData = []; +for ($i = 1; $i <= 63; $i++) { + $testData[] = [ + 'id' => $i, + 'name' => "Server-{$i}", + 'status' => ($i % 3) === 0 ? 'stopped' : 'running', + 'type' => 'cx' . (11 + (($i % 4) * 10)), + 'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255), + ]; +} +$table->setData($testData); -// Row selection handler +$leftSide->addComponent($table); +$tab1->addComponent($leftSide); + +// Right side: Detail panel +$detailPanel = new Container('flex flex-col gap-3 w-80 bg-white border-2 border-gray-300 rounded p-4'); +$detailTitle = new Label('Server Details', 'text-xl font-bold text-black mb-2'); +$detailPanel->addComponent($detailTitle); + +// Detail fields (initially empty) +$detailId = new Label('Bitte einen Server auswählen', 'text-sm text-gray-600'); +$detailName = new Label('', 'text-sm text-black'); +$detailStatus = new Label('', 'text-sm text-black'); +$detailType = new Label('', 'text-sm text-black'); +$detailIpv4 = new Label('', 'text-sm text-black'); + +$detailPanel->addComponent(new Label('ID:', 'text-xs text-gray-500 mt-2')); +$detailPanel->addComponent($detailId); +$detailPanel->addComponent(new Label('Name:', 'text-xs text-gray-500 mt-2')); +$detailPanel->addComponent($detailName); +$detailPanel->addComponent(new Label('Status:', 'text-xs text-gray-500 mt-2')); +$detailPanel->addComponent($detailStatus); +$detailPanel->addComponent(new Label('Typ:', 'text-xs text-gray-500 mt-2')); +$detailPanel->addComponent($detailType); +$detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2')); +$detailPanel->addComponent($detailIpv4); + +// SFTP Manager Button +$sftpButton = new Button('SFTP Manager öffnen', 'w-full mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center'); +$sftpIcon = new Icon(IconName::home, 18, 'text-white mr-2'); +$sftpButton->setIcon($sftpIcon); +$detailPanel->addComponent($sftpButton); + +$tab1->addComponent($detailPanel); + +// Row selection handler - update detail panel $statusLabel = new Label( text: 'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight, style: 'basis-4/8 text-black', ); -$table->setOnRowSelect(function ($index, $row) use (&$statusLabel) { +$table->setOnRowSelect(function ($index, $row) use (&$statusLabel, &$selectedServer, $detailId, $detailName, $detailStatus, $detailType, $detailIpv4) { if ($row) { - $statusLabel->setText("Selected: {$row['name']} ({$row['email']})"); + $statusLabel->setText("Server: {$row['name']} - {$row['status']} ({$row['ipv4']})"); + + // Update detail panel + $detailId->setText("#{$row['id']}"); + $detailName->setText($row['name']); + $detailStatus->setText($row['status']); + $detailType->setText($row['type']); + $detailIpv4->setText($row['ipv4']); + + // Store selected server for SFTP connection + $selectedServer = $row; } }); -$tab1->addComponent($table); -$tabContainer->addTab('Daten', $tab1); +// Function to load servers from Hetzner API (only returns simple data, no objects) +// Now uses HetznerClient class thanks to bootstrap.php in async runtime +$loadServersAsync = function () use ($currentApiKey) { + try { + if (empty($currentApiKey)) { + return ['error' => 'Kein API-Key konfiguriert']; + } + + // Use HetznerClient class (autoloader is available in async runtime) + $hetznerClient = new \LKDev\HetznerCloud\HetznerAPIClient($currentApiKey); + $result = ['servers' => []]; + foreach ($hetznerClient->servers()->all() as $server) { + $result['servers'][] = [ + 'id' => $server->id, + 'name' => $server->name, + 'status' => $server->status, + 'type' => $server->serverType->name, + 'ipv4' => $server->publicNet->ipv4->ip, + ]; + } + + if (isset($result['servers'])) { + // Return only simple array data + return ['success' => true, 'servers' => $result['servers'], 'count' => count($result['servers'])]; + } + + return ['error' => 'Unerwartete Antwort']; + } catch (\Exception $e) { + return ['error' => 'Exception: ' . $e->getMessage()]; + } +}; + +// Configure refresh button to load servers asynchronously +$refreshButton->setOnClickAsync( + $loadServersAsync, + function ($result) use ($table, $statusLabel) { + // Handle the result in the main thread (can access objects here) + if (is_array($result)) { + if (isset($result['error'])) { + $statusLabel->setText('Fehler: ' . $result['error']); + echo "Error: {$result['error']}\n"; + } elseif (isset($result['success'], $result['servers'])) { + $table->setData($result['servers']); + $statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); + echo "Success: {$result['count']} servers loaded\n"; + } + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : ((string) $error); + $statusLabel->setText('Async Fehler: ' . $errorMsg); + echo "Async error: {$errorMsg}\n"; + }, +); + +$tabContainer->addTab('Server', $tab1); // Tab 2: Some info $tab2 = new Container('flex flex-col p-4'); @@ -164,6 +294,111 @@ $tab3->addComponent(new Label('Einstellungen', 'text-xl font-bold mb-4')); $tab3->addComponent(new Label('Konfigurationsoptionen...', '')); $tabContainer->addTab('Einstellungen', $tab3); +// Tab 4: SFTP Manager (will be populated dynamically when server is selected) +$sftpTab = new Container('flex flex-row p-4 gap-4 bg-gray-50'); + +// Left side: Local file browser +$localBrowserContainer = new Container('flex flex-col flex-1 gap-2'); +$localBrowserContainer->addComponent(new Label('Lokal', 'text-lg font-bold text-black mb-2')); +$localFileBrowser = new FileBrowser(getcwd(), false, 'flex-1 bg-white border-2 border-gray-300 rounded p-2'); +$localBrowserContainer->addComponent($localFileBrowser); + +// Right side: Remote file browser +$remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); +$remoteBrowserContainer->addComponent(new Label('Remote', 'text-lg font-bold text-black mb-2')); +$remoteFileBrowser = new FileBrowser('/', true, 'flex-1 bg-white border-2 border-gray-300 rounded p-2'); +$remoteBrowserContainer->addComponent($remoteFileBrowser); + +// Connection status label +$connectionStatusLabel = new Label('Nicht verbunden', 'text-sm text-gray-600 italic mb-2'); +$remoteBrowserContainer->addComponent($connectionStatusLabel); + +$sftpTab->addComponent($localBrowserContainer); +$sftpTab->addComponent($remoteBrowserContainer); + +$tabContainer->addTab('SFTP Manager', $sftpTab); + +// SFTP Button Click Handler - Connect to server and load remote files +$sftpButton->setOnClickAsync( + function () use (&$selectedServer, &$currentPrivateKeyPath) { + // This runs in a separate thread + if ($selectedServer === null) { + return ['error' => 'Kein Server ausgewählt']; + } + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + return ['error' => 'Private Key Pfad nicht konfiguriert oder Datei nicht gefunden']; + } + + try { + $ssh = new \phpseclib3\Net\SSH2($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$ssh->login('root', $key)) { + return ['error' => 'SSH Login fehlgeschlagen']; + } + + // Create SFTP connection + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login fehlgeschlagen']; + } + + // Read root directory + $files = $sftp->nlist('/'); + if ($files === false) { + return ['error' => 'Kann Root-Verzeichnis nicht lesen']; + } + + $fileList = []; + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $stat = $sftp->stat('/' . $file); + $fileList[] = [ + 'name' => $file, + 'path' => '/' . $file, + 'isDir' => ($stat['type'] ?? 0) === 2, + 'size' => $stat['size'] ?? 0, + 'mtime' => $stat['mtime'] ?? 0, + ]; + } + + return [ + 'success' => true, + 'server' => $selectedServer, + 'files' => $fileList, + ]; + } catch (\Exception $e) { + return ['error' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()]; + } + }, + function ($result) use ($tabContainer, $remoteFileBrowser, $connectionStatusLabel, &$statusLabel) { + // This runs in the main thread + if (isset($result['error'])) { + $statusLabel->setText('SFTP Fehler: ' . $result['error']); + return; + } + + if (isset($result['success']) && $result['success']) { + $connectionStatusLabel->setText('Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')'); + $statusLabel->setText('SFTP Verbindung erfolgreich zu ' . $result['server']['name']); + + // Populate remote file browser with the file list + $remoteFileBrowser->setPath('/'); + $remoteFileBrowser->setFileData($result['files']); + + // Switch to SFTP Manager tab + $tabContainer->setActiveTab(3); // Index 3 = SFTP Manager tab + } + }, + function ($error) use (&$statusLabel) { + $errorMsg = is_string($error) ? $error : (is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : 'Unbekannter Fehler'); + $statusLabel->setText('SFTP Async Fehler: ' . $errorMsg); + }, +); + $mainContainer->addComponent($tabContainer); // === 3. StatusBar === @@ -189,18 +424,20 @@ $cancelButton->setOnClick(function () use ($menuBar, $modal) { $modal->setVisible(false); }); -$saveButton->setOnClick(function () use ($settings, &$currentApiKey, $apiKeyInput, $menuBar, $modal, &$statusLabel) { +$saveButton->setOnClick(function () use ($settings, &$currentApiKey, &$currentPrivateKeyPath, $apiKeyInput, $privateKeyPathInput, $menuBar, $modal, &$statusLabel) { $currentApiKey = trim($apiKeyInput->getValue()); + $currentPrivateKeyPath = trim($privateKeyPathInput->getValue()); // Save to settings $settings->set('api_key', $currentApiKey); + $settings->set('private_key_path', $currentPrivateKeyPath); $settings->save(); if ($statusLabel !== null) { $masked = strlen($currentApiKey) > 4 ? (str_repeat('*', max(0, strlen($currentApiKey) - 4)) . substr($currentApiKey, -4)) : $currentApiKey; - $statusLabel->setText('API-Key gespeichert: ' . $masked . ' (' . $settings->getPath() . ')'); + $statusLabel->setText('Einstellungen gespeichert: API-Key ' . $masked . ' (' . $settings->getPath() . ')'); } $menuBar->closeAllMenus(); $modal->setVisible(false); diff --git a/php-sdl3/.libs/sdl3.o b/php-sdl3/.libs/sdl3.o index 9144c6d..991b1e1 100644 Binary files a/php-sdl3/.libs/sdl3.o and b/php-sdl3/.libs/sdl3.o differ diff --git a/php-sdl3/.libs/sdl3.so b/php-sdl3/.libs/sdl3.so index e6c55b6..cd9adfd 100755 Binary files a/php-sdl3/.libs/sdl3.so and b/php-sdl3/.libs/sdl3.so differ diff --git a/php-sdl3/helper.h b/php-sdl3/helper.h index 9bb85f4..0a94a91 100644 --- a/php-sdl3/helper.h +++ b/php-sdl3/helper.h @@ -14,22 +14,71 @@ static void filled_quarter_circle(SDL_Renderer *renderer, int cx, int cy, int r, Uint8 r_col, g_col, b_col, a_col; SDL_GetRenderDrawColor(renderer, &r_col, &g_col, &b_col, &a_col); - // Use SDL_gfx filledCircle with proper quadrant rendering - // We'll draw the arc using the primitives library - switch (quadrant) { - case 0: // top-left - filledPieRGBA(renderer, cx, cy, r, 180, 270, r_col, g_col, b_col, a_col); - break; - case 1: // top-right - filledPieRGBA(renderer, cx, cy, r, 270, 360, r_col, g_col, b_col, a_col); - break; - case 2: // bottom-right - filledPieRGBA(renderer, cx, cy, r, 0, 90, r_col, g_col, b_col, a_col); - break; - case 3: // bottom-left - filledPieRGBA(renderer, cx, cy, r, 90, 180, r_col, g_col, b_col, a_col); - break; + // Use scanline filling with antialiasing on the edge + float radius = (float)r; + + // Iterate through each row of the quadrant + for (int y = 0; y < r; y++) { + // Calculate distance from center + float dy = (float)y + 0.5f; + float x_exact = sqrtf(fmaxf(0.0f, radius * radius - dy * dy)); + int x_max = (int)floorf(x_exact); + float frac = x_exact - (float)x_max; + + // Draw the fully opaque pixels for this scanline + for (int x = 0; x <= x_max; x++) { + int px, py; + switch (quadrant) { + case 0: // top-left: draw from center going left and up + px = cx - x; + py = cy - y; + break; + case 1: // top-right: draw from center going right and up + px = cx + x; + py = cy - y; + break; + case 2: // bottom-right: draw from center going right and down + px = cx + x; + py = cy + y; + break; + case 3: // bottom-left: draw from center going left and down + px = cx - x; + py = cy + y; + break; + } + SDL_SetRenderDrawColor(renderer, r_col, g_col, b_col, a_col); + SDL_RenderPoint(renderer, px, py); + } + + // Draw the antialiased edge pixel + if (frac > 0.01f && x_max + 1 < r) { + int px, py; + switch (quadrant) { + case 0: // top-left + px = cx - (x_max + 1); + py = cy - y; + break; + case 1: // top-right + px = cx + (x_max + 1); + py = cy - y; + break; + case 2: // bottom-right + px = cx + (x_max + 1); + py = cy + y; + break; + case 3: // bottom-left + px = cx - (x_max + 1); + py = cy + y; + break; + } + Uint8 edge_alpha = (Uint8)(frac * a_col); + SDL_SetRenderDrawColor(renderer, r_col, g_col, b_col, edge_alpha); + SDL_RenderPoint(renderer, px, py); + } } + + // Restore original draw color + SDL_SetRenderDrawColor(renderer, r_col, g_col, b_col, a_col); } #endif diff --git a/php-sdl3/modules/sdl3.so b/php-sdl3/modules/sdl3.so index e6c55b6..cd9adfd 100755 Binary files a/php-sdl3/modules/sdl3.so and b/php-sdl3/modules/sdl3.so differ diff --git a/php-sdl3/sdl3.c b/php-sdl3/sdl3.c index 1f1d8f5..74675ae 100644 --- a/php-sdl3/sdl3.c +++ b/php-sdl3/sdl3.c @@ -571,48 +571,38 @@ PHP_FUNCTION(sdl_rounded_box_ex) if (rad_br > halfw) rad_br = halfw; if (rad_br > halfh) rad_br = halfh; if (rad_bl > halfw) rad_bl = halfw; if (rad_bl > halfh) rad_bl = halfh; - int r_left = (rad_tl > rad_bl) ? rad_tl : rad_bl; - int r_right = (rad_tr > rad_br) ? rad_tr : rad_br; + // Einfachere Strategie: Zeichne Rechtecke die sich überlappen dürfen, + // dann die Kreise darüber - // 1) center vertical band (zwischen links und rechts Radien), ganze Höhe - SDL_FRect center = { x1 + r_left, y1, x2 - x1-r_left-r_right, y2-y1 }; - if (center.w > 0 && center.h > 0) SDL_RenderFillRect(ren, ¢er); + // 1) Horizontales Rechteck oben (von linker Ecke bis rechter Ecke) + SDL_FRect topRect = { x1 + rad_tl, y1, x2 - x1 - rad_tl - rad_tr, rad_tl > rad_tr ? rad_tl : rad_tr }; + if (topRect.w > 0 && topRect.h > 0) SDL_RenderFillRect(ren, &topRect); - // 2) left vertical rectangle (zwischen oberen und unteren Ecken links) - SDL_FRect leftRect = { x1, y1 + rad_tl, r_left, y2 - y1 - rad_tl - rad_bl }; + // 2) Horizontales Rechteck unten (von linker Ecke bis rechter Ecke) + int maxBottomRad = rad_bl > rad_br ? rad_bl : rad_br; + SDL_FRect bottomRect = { x1 + rad_bl, y2 - maxBottomRad, x2 - x1 - rad_bl - rad_br, maxBottomRad }; + if (bottomRect.w > 0 && bottomRect.h > 0) SDL_RenderFillRect(ren, &bottomRect); + + // 3) Vertikales Rechteck links (volle Höhe zwischen Ecken) + SDL_FRect leftRect = { x1, y1 + rad_tl, rad_tl > rad_bl ? rad_tl : rad_bl, y2 - y1 - rad_tl - rad_bl }; if (leftRect.w > 0 && leftRect.h > 0) SDL_RenderFillRect(ren, &leftRect); - // 3) right vertical rectangle (zwischen oberen und unteren Ecken rechts) - SDL_FRect rightRect = { x2 - r_right, y1 + rad_tr, r_right, y2 - y1 - rad_tr - rad_br }; + // 4) Vertikales Rechteck rechts (volle Höhe zwischen Ecken) + int maxRightRad = rad_tr > rad_br ? rad_tr : rad_br; + SDL_FRect rightRect = { x2 - maxRightRad, y1 + rad_tr, maxRightRad, y2 - y1 - rad_tr - rad_br }; if (rightRect.w > 0 && rightRect.h > 0) SDL_RenderFillRect(ren, &rightRect); - // 4) Horizontale Füllrechtecke für Lücken zwischen Ecken und vertikalen Rechtecken - // Oben links: wenn rad_tl < r_left - if (rad_tl < r_left) { - SDL_FRect topLeft = { x1 + rad_tl, y1, r_left - rad_tl, rad_tl }; - if (topLeft.w > 0 && topLeft.h > 0) SDL_RenderFillRect(ren, &topLeft); - } - // Oben rechts: wenn rad_tr < r_right - if (rad_tr < r_right) { - SDL_FRect topRight = { x2 - r_right, y1, r_right - rad_tr, rad_tr }; - if (topRight.w > 0 && topRight.h > 0) SDL_RenderFillRect(ren, &topRight); - } - // Unten rechts: wenn rad_br < r_right - if (rad_br < r_right) { - SDL_FRect bottomRight = { x2 - r_right, y2 - rad_br, r_right - rad_br, rad_br }; - if (bottomRight.w > 0 && bottomRight.h > 0) SDL_RenderFillRect(ren, &bottomRight); - } - // Unten links: wenn rad_bl < r_left - if (rad_bl < r_left) { - SDL_FRect bottomLeft = { x1 + rad_bl, y2 - rad_bl, r_left - rad_bl, rad_bl }; - if (bottomLeft.w > 0 && bottomLeft.h > 0) SDL_RenderFillRect(ren, &bottomLeft); - } + // 5) Zentrales Rechteck (füllt die Mitte) + int maxLeftRad = rad_tl > rad_bl ? rad_tl : rad_bl; + maxRightRad = rad_tr > rad_br ? rad_tr : rad_br; + SDL_FRect centerRect = { x1 + maxLeftRad, y1, x2 - x1 - maxLeftRad - maxRightRad, y2 - y1 }; + if (centerRect.w > 0 && centerRect.h > 0) SDL_RenderFillRect(ren, ¢erRect); - // 5) vier gefüllte Viertel-Kreise in den Ecken + // 6) vier gefüllte Viertel-Kreise in den Ecken (darüber zeichnen) if (rad_tl > 0) filled_quarter_circle(ren, x1 + rad_tl, y1 + rad_tl, rad_tl, 0); - if (rad_tr > 0) filled_quarter_circle(ren, x2 - rad_tr, y1 + rad_tr, rad_tr, 1); - if (rad_br > 0) filled_quarter_circle(ren, x2 - rad_br, y2- rad_br, rad_br, 2); - if (rad_bl > 0) filled_quarter_circle(ren, x1 + rad_bl, y2 - rad_bl, rad_bl, 3); + if (rad_tr > 0) filled_quarter_circle(ren, x2 - rad_tr - 1, y1 + rad_tr, rad_tr, 1); + if (rad_br > 0) filled_quarter_circle(ren, x2 - rad_br - 1, y2 - rad_br - 1, rad_br, 2); + if (rad_bl > 0) filled_quarter_circle(ren, x1 + rad_bl, y2 - rad_bl - 1, rad_bl, 3); RETURN_TRUE; } diff --git a/src/Async/AsyncTask.php b/src/Async/AsyncTask.php index 837efed..f2261f4 100644 --- a/src/Async/AsyncTask.php +++ b/src/Async/AsyncTask.php @@ -32,7 +32,9 @@ class AsyncTask } try { - $runtime = new Runtime(); + // Create runtime with bootstrap file to load autoloader + $bootstrapPath = __DIR__ . '/bootstrap.php'; + $runtime = new Runtime($bootstrapPath); $this->future = $runtime->run($this->task); } catch (\Throwable $e) { $this->failed = true; diff --git a/src/Async/bootstrap.php b/src/Async/bootstrap.php new file mode 100644 index 0000000..b1d59d3 --- /dev/null +++ b/src/Async/bootstrap.php @@ -0,0 +1,7 @@ + 0) { + Profiler::increment('sdl_mouse_motion_events', $mouseMotionCount); + Profiler::increment('coalesced_mouse_motion_events', count($lastMouseMotionPerWindow)); + } + + $events = $coalescedEvents; + // Distribute events to windows based on window_id foreach ($events as $event) { $eventWindowId = $event['window_id'] ?? null; diff --git a/src/Framework/HetznerClient.php b/src/Framework/HetznerClient.php new file mode 100644 index 0000000..e0493b0 --- /dev/null +++ b/src/Framework/HetznerClient.php @@ -0,0 +1,68 @@ +apiToken = $apiToken; + } + + /** + * Fetch all servers from Hetzner Cloud API + * @return array Array of server data or error + */ + public function getServers(): array + { + $ch = curl_init($this->baseUrl . '/servers'); + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $this->apiToken, + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + return ['error' => 'cURL Error: ' . $error]; + } + + if ($httpCode !== 200) { + return ['error' => 'HTTP Error ' . $httpCode . ': ' . $response]; + } + + $data = json_decode($response, true); + + if (!$data || !isset($data['servers'])) { + return ['error' => 'Invalid API response']; + } + + // Transform server data for table display + $servers = []; + foreach ($data['servers'] as $server) { + $servers[] = [ + 'id' => $server['id'] ?? '-', + 'name' => $server['name'] ?? '-', + 'status' => $server['status'] ?? '-', + 'type' => $server['server_type']['name'] ?? '-', + 'location' => $server['datacenter']['location']['city'] ?? '-', + 'ipv4' => $server['public_net']['ipv4']['ip'] ?? '-', + ]; + } + + return ['servers' => $servers]; + } +} diff --git a/src/Framework/Profiler.php b/src/Framework/Profiler.php new file mode 100644 index 0000000..ee7b926 --- /dev/null +++ b/src/Framework/Profiler.php @@ -0,0 +1,137 @@ + 0.0, + 'count' => 0, + 'min' => PHP_FLOAT_MAX, + 'max' => 0.0, + 'start' => null, + ]; + } + + self::$timings[$label]['start'] = microtime(true); + } + + public static function end(string $label): void + { + if (!self::$enabled) { + return; + } + + if (!isset(self::$timings[$label]) || self::$timings[$label]['start'] === null) { + return; + } + + $duration = microtime(true) - self::$timings[$label]['start']; + self::$timings[$label]['total'] += $duration; + self::$timings[$label]['count']++; + self::$timings[$label]['min'] = min(self::$timings[$label]['min'], $duration); + self::$timings[$label]['max'] = max(self::$timings[$label]['max'], $duration); + self::$timings[$label]['start'] = null; + } + + public static function increment(string $label, int $amount = 1): void + { + if (!self::$enabled) { + return; + } + + if (!isset(self::$counters[$label])) { + self::$counters[$label] = 0; + } + + self::$counters[$label] += $amount; + } + + public static function report(): void + { + if (!self::$enabled) { + return; + } + + $now = microtime(true); + if ($now - self::$lastReportTime < self::$reportInterval) { + return; + } + + echo "\n=== Performance Report ===\n"; + + if (!empty(self::$timings)) { + echo "\nTimings:\n"; + foreach (self::$timings as $label => $data) { + if ($data['count'] === 0) { + continue; + } + + $avg = $data['total'] / $data['count']; + echo sprintf( + " %s: avg=%.3fms, total=%.3fms, count=%d, min=%.3fms, max=%.3fms\n", + $label, + $avg * 1000, + $data['total'] * 1000, + $data['count'], + $data['min'] * 1000, + $data['max'] * 1000 + ); + } + } + + if (!empty(self::$counters)) { + echo "\nCounters:\n"; + foreach (self::$counters as $label => $count) { + echo sprintf(" %s: %d\n", $label, $count); + } + } + + echo "=========================\n\n"; + + // Reset for next interval + self::reset(); + self::$lastReportTime = $now; + } + + public static function reset(): void + { + foreach (self::$timings as $label => $data) { + self::$timings[$label]['total'] = 0.0; + self::$timings[$label]['count'] = 0; + self::$timings[$label]['min'] = PHP_FLOAT_MAX; + self::$timings[$label]['max'] = 0.0; + } + + self::$counters = []; + } +} diff --git a/src/Tailwind/Parser/Gap.php b/src/Tailwind/Parser/Gap.php index a8bd91a..7d7b81a 100644 --- a/src/Tailwind/Parser/Gap.php +++ b/src/Tailwind/Parser/Gap.php @@ -32,9 +32,7 @@ class Gap implements Parser } if ($x !== null || $y !== null) { - $gap = new \PHPNative\Tailwind\Style\Gap($x ?? 0, $y ?? 0); - error_log("Gap parsed from '$style': x={$gap->x}, y={$gap->y}"); - return $gap; + return new \PHPNative\Tailwind\Style\Gap($x ?? 0, $y ?? 0); } return null; diff --git a/src/Ui/Component.php b/src/Ui/Component.php index 38e7a9a..c1c19ea 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -2,7 +2,9 @@ namespace PHPNative\Ui; +use PHPNative\Framework\Profiler; use PHPNative\Framework\TextRenderer; +use PHPNative\Tailwind\Parser\State; use PHPNative\Tailwind\Style\Margin; use PHPNative\Tailwind\Style\MediaQueryEnum; use PHPNative\Tailwind\Style\Padding; @@ -29,17 +31,24 @@ abstract class Component protected null|Component $parent = null; // Reference to parent component - protected $cachedTexture = null; // SDL texture cache for this component - - protected bool $useTextureCache = false; // Disabled by default, enable per component if needed + // Texture caching for performance + protected $cachedTexture = null; // Normal state texture + protected $cachedHoverTexture = null; // Hover state texture + protected bool $useTextureCache = false; + protected bool $textureCacheValid = false; protected Viewport $viewport; protected array $computedStyles = []; + protected array $hoverStylesCached = []; + protected array $normalStylesCached = []; protected Viewport $contentViewport; protected null|Window $attachedWindow = null; + // Cache whether this component or any of its children have hover styles + protected null|bool $hasHoverStylesCache = null; + public function __construct( protected string $style = '', ) { @@ -161,9 +170,108 @@ abstract class Component sdl_destroy_texture($this->cachedTexture); $this->cachedTexture = null; } + if ($this->cachedHoverTexture !== null) { + sdl_destroy_texture($this->cachedHoverTexture); + $this->cachedHoverTexture = null; + } + $this->textureCacheValid = false; $this->renderDirty = true; } + public function buildTextureCache(&$renderer, null|TextRenderer $textRenderer = null): void + { + if (!$this->useTextureCache || $this->viewport->width <= 0 || $this->viewport->height <= 0) { + return; + } + + Profiler::start('buildTextureCache'); + Profiler::increment('texture_cache_built'); + + // Clean up old textures + $this->invalidateTextureCache(); + + // Create texture for normal state + $this->currentState = StateEnum::normal; + $this->computedStyles = $this->normalStylesCached; + $normalTexture = sdl_create_texture( + $renderer, + SDL_PIXELFORMAT_RGBA8888, + SDL_TEXTUREACCESS_TARGET, + (int)$this->viewport->width, + (int)$this->viewport->height + ); + + if ($normalTexture) { + sdl_set_texture_blend_mode($normalTexture, SDL_BLENDMODE_BLEND); + sdl_set_render_target($renderer, $normalTexture); + sdl_set_render_draw_color($renderer, 0, 0, 0, 0); + sdl_render_clear($renderer); + + // Temporarily disable caching to render to texture + $oldCache = $this->useTextureCache; + $this->useTextureCache = false; + + // Render at 0,0 into texture + $oldX = $this->viewport->x; + $oldY = $this->viewport->y; + $this->viewport->x = 0; + $this->viewport->y = 0; + + $this->render($renderer, $textRenderer); + $this->renderContent($renderer, $textRenderer); + + $this->viewport->x = $oldX; + $this->viewport->y = $oldY; + $this->useTextureCache = $oldCache; + + sdl_set_render_target($renderer, null); + $this->cachedTexture = $normalTexture; + } + + // Create texture for hover state + $this->currentState = StateEnum::hover; + $this->computedStyles = $this->hoverStylesCached; + $hoverTexture = sdl_create_texture( + $renderer, + SDL_PIXELFORMAT_RGBA8888, + SDL_TEXTUREACCESS_TARGET, + (int)$this->viewport->width, + (int)$this->viewport->height + ); + + if ($hoverTexture) { + sdl_set_texture_blend_mode($hoverTexture, SDL_BLENDMODE_BLEND); + sdl_set_render_target($renderer, $hoverTexture); + sdl_set_render_draw_color($renderer, 0, 0, 0, 0); + sdl_render_clear($renderer); + + $oldCache = $this->useTextureCache; + $this->useTextureCache = false; + + $oldX = $this->viewport->x; + $oldY = $this->viewport->y; + $this->viewport->x = 0; + $this->viewport->y = 0; + + $this->render($renderer, $textRenderer); + $this->renderContent($renderer, $textRenderer); + + $this->viewport->x = $oldX; + $this->viewport->y = $oldY; + $this->useTextureCache = $oldCache; + + sdl_set_render_target($renderer, null); + $this->cachedHoverTexture = $hoverTexture; + } + + // Restore normal state + $this->currentState = StateEnum::normal; + $this->computedStyles = $this->normalStylesCached; + $this->textureCacheValid = true; + + Profiler::end('buildTextureCache'); + } + public function getCachedTexture() { return $this->cachedTexture; @@ -220,10 +328,19 @@ abstract class Component public function layout(null|TextRenderer $textRenderer = null): void { - $this->computedStyles = StyleParser::parse($this->style)->getValidStyles( + $this->normalStylesCached = StyleParser::parse($this->style)->getValidStyles( MediaQueryEnum::normal, - $this->currentState, + StateEnum::normal, ); + $this->hoverStylesCached = StyleParser::parse($this->style)->getValidStyles( + MediaQueryEnum::normal, + StateEnum::hover, + ); + if ($this->currentState == StateEnum::hover) { + $this->computedStyles = $this->hoverStylesCached; + } else { + $this->computedStyles = $this->normalStylesCached; + } if (isset($this->computedStyles[Margin::class]) && ($m = $this->computedStyles[Margin::class])) { $this->viewport->x = (int) ($this->viewport->x + $m->left); $this->viewport->width = max(0, ($this->viewport->width - $m->right) - $m->left); @@ -254,6 +371,39 @@ abstract class Component return; } + Profiler::start('render'); + + // Use cached texture if available and valid + if ($this->useTextureCache) { + Profiler::increment('uses_cache'); + if ($this->textureCacheValid) { + Profiler::increment('cache_valid'); + $texture = ($this->currentState == StateEnum::hover) ? $this->cachedHoverTexture : $this->cachedTexture; + + if ($texture !== null) { + Profiler::increment('texture_cache_hit'); + Profiler::start('render_cached'); + // Render cached texture + sdl_render_texture($renderer, $texture, [ + 'x' => $this->viewport->x, + 'y' => $this->viewport->y, + 'w' => $this->viewport->width, + 'h' => $this->viewport->height, + ]); + Profiler::end('render_cached'); + Profiler::end('render'); + return; + } else { + Profiler::increment('texture_null'); + } + } else { + Profiler::increment('cache_invalid'); + } + } + + Profiler::increment('render_normal'); + + // Render normally if no cache or cache building if ( isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) && ($bg = $this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) @@ -311,6 +461,8 @@ abstract class Component 'h' => $this->viewport->height, ]); } + + Profiler::end('render'); } public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void @@ -319,10 +471,70 @@ abstract class Component return; } - // Render children + // Check if we need to clip children based on overflow + $needsClipping = false; + $clipRect = null; + + if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class])) { + $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; + // Enable clipping for hidden, clip, scroll, or auto overflow + if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || + $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { + // Actually, let's clip for anything that's not the default + $needsClipping = true; + $clipRect = [ + 'x' => (int)$this->contentViewport->x, + 'y' => (int)$this->contentViewport->y, + 'w' => (int)$this->contentViewport->width, + 'h' => (int)$this->contentViewport->height, + ]; + } + } + + // Set clip rect if needed + if ($needsClipping && $clipRect) { + sdl_set_render_clip_rect($renderer, $clipRect); + } + + // Render children (unless we're using texture cache and it's valid) + if (!($this->useTextureCache && $this->textureCacheValid)) { + foreach ($this->children as $child) { + // Skip rendering children that are completely outside the clip rect + if ($needsClipping && $clipRect) { + $childViewport = $child->getViewport(); + // Check if child is visible within clip rect + if ($childViewport->x + $childViewport->width < $clipRect['x'] || + $childViewport->x > $clipRect['x'] + $clipRect['w'] || + $childViewport->y + $childViewport->height < $clipRect['y'] || + $childViewport->y > $clipRect['y'] + $clipRect['h']) { + continue; // Child is completely outside clip rect, skip rendering + } + } + + $child->render($renderer, $textRenderer); + $child->renderContent($renderer, $textRenderer); + } + } + + // Reset clip rect + if ($needsClipping) { + sdl_set_render_clip_rect($renderer, null); + } + } + + /** + * Build texture cache for this component and all children that have caching enabled + */ + public function buildTextureCacheRecursive(&$renderer, null|TextRenderer $textRenderer = null): void + { + // Build cache for this component if needed + if ($this->useTextureCache && !$this->textureCacheValid) { + $this->buildTextureCache($renderer, $textRenderer); + } + + // Recursively build cache for children foreach ($this->children as $child) { - $child->render($renderer, $textRenderer); - $child->renderContent($renderer, $textRenderer); + $child->buildTextureCacheRecursive($renderer, $textRenderer); } } @@ -381,6 +593,9 @@ abstract class Component */ public function handleMouseMove(float $mouseX, float $mouseY): void { + Profiler::start('handleMouseMove'); + Profiler::increment('mouse_move_events'); + // Check if mouse is over this component $isMouseOver = $mouseX >= $this->viewport->x && @@ -394,19 +609,74 @@ abstract class Component // Recompute styles if state changed if ($previousState !== $this->currentState) { - $this->computedStyles = StyleParser::parse($this->style)->getValidStyles( - MediaQueryEnum::normal, - $this->currentState, - ); - // Mark as dirty since visual state changed - $this->markDirty(false, false); - } - foreach ($this->children as $child) { - // Skip overlays - they are handled separately by the Window - if (!$child->isOverlay()) { - $child->handleMouseMove($mouseX, $mouseY); + Profiler::increment('state_changes'); + if ($this->currentState == StateEnum::hover) { + $this->computedStyles = $this->hoverStylesCached; + } else { + $this->computedStyles = $this->normalStylesCached; + } + + // Only mark dirty if we're NOT using texture cache + // If we use texture cache, we already have both states cached + if (!$this->useTextureCache) { + $this->markDirty(false, false); } } + + // Only propagate to children if mouse is over this component or we need to clear hover states + if ($isMouseOver || $previousState === StateEnum::hover) { + // Check if we have overflow clipping + $hasClipping = isset($this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]); + $clipRect = null; + + if ($hasClipping) { + $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; + if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || + $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { + $clipRect = [ + 'x' => $this->contentViewport->x, + 'y' => $this->contentViewport->y, + 'w' => $this->contentViewport->width, + 'h' => $this->contentViewport->height, + ]; + } + } + + foreach ($this->children as $child) { + // Skip overlays - they are handled separately by the Window + if (!$child->isOverlay() && $child->isVisible()) { + // OPTIMIZATION: Skip propagation if child has no hover styles + // This dramatically reduces event processing for components like Labels + if (!$child->hasHoverStyles()) { + continue; + } + + $childViewport = $child->getViewport(); + + // Skip if child viewport is invalid + if ($childViewport->width <= 0 || $childViewport->height <= 0) { + continue; + } + + // If we have clipping, check if child is visible within clip rect + if ($clipRect !== null) { + // Skip children that are completely outside the clip rect + if ($childViewport->x + $childViewport->width < $clipRect['x'] || + $childViewport->x > $clipRect['x'] + $clipRect['w'] || + $childViewport->y + $childViewport->height < $clipRect['y'] || + $childViewport->y > $clipRect['y'] + $clipRect['h']) { + // Child is outside visible area, send fake event to clear hover + $child->handleMouseMove(-1000, -1000); + continue; + } + } + + $child->handleMouseMove($mouseX, $mouseY); + } + } + } + + Profiler::end('handleMouseMove'); } /** @@ -516,6 +786,34 @@ abstract class Component } } + /** + * Check if this component or any of its children have hover styles + * This is cached for performance + */ + public function hasHoverStyles(): bool + { + if ($this->hasHoverStylesCache !== null) { + return $this->hasHoverStylesCache; + } + + // Check if this component has hover styles + if (str_contains($this->style, ':hover') || str_contains($this->style, 'hover:')) { + $this->hasHoverStylesCache = true; + return true; + } + + // Check if any children have hover styles + foreach ($this->children as $child) { + if ($child->hasHoverStyles()) { + $this->hasHoverStylesCache = true; + return true; + } + } + + $this->hasHoverStylesCache = false; + return false; + } + public function detachFromWindow(): void { $this->attachedWindow = null; diff --git a/src/Ui/Widget/Button.php b/src/Ui/Widget/Button.php index 3c5edf1..751379d 100644 --- a/src/Ui/Widget/Button.php +++ b/src/Ui/Widget/Button.php @@ -22,6 +22,9 @@ class Button extends Container ) { parent::__construct($style); + // Enable texture caching for buttons (huge performance boost!) + $this->setUseTextureCache(true); + // Create label inside button $this->label = new Label( text: $text, diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index 801a7c1..3d83fe0 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -188,10 +188,6 @@ class Container extends Component $gapSize = 0; if ($gap) { $gapSize = $isRow ? $gap->x : $gap->y; - // Debug output - if ($gapSize > 0) { - error_log("Container gap detected: " . $gapSize . "px (" . ($isRow ? "row" : "col") . ")"); - } } // First pass: calculate fixed sizes and count flex-grow items @@ -483,8 +479,6 @@ class Container extends Component private function renderScrollbars(&$renderer, array $overflow): void { - $scrollbarColor = [100, 100, 100, 200]; // Gray with some transparency - // Vertical scrollbar if ($overflow['y']) { $scrollbarHeight = $this->contentViewport->height; @@ -497,8 +491,8 @@ class Container extends Component $scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH; - // Track - sdl_set_render_draw_color($renderer, 200, 200, 200, 100); + // 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, @@ -506,21 +500,16 @@ class Container extends Component 'h' => (int) $scrollbarHeight, ]); - // Thumb - using sdl_rounded_box for rounded rectangle + // Thumb (darker gray, moved part) - using regular rect for now $thumbX = (int) ($scrollbarX + 2); $thumbW = (int) (self::SCROLLBAR_WIDTH - 4); - sdl_rounded_box( - $renderer, - $thumbX, - (int) $thumbY, - $thumbX + $thumbW, - (int) ($thumbY + $thumbHeight), - 4, - $scrollbarColor[0], - $scrollbarColor[1], - $scrollbarColor[2], - $scrollbarColor[3], - ); + 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 diff --git a/src/Ui/Widget/FileBrowser.php b/src/Ui/Widget/FileBrowser.php new file mode 100644 index 0000000..9cf587a --- /dev/null +++ b/src/Ui/Widget/FileBrowser.php @@ -0,0 +1,273 @@ +currentPath = $initialPath; + $this->isRemote = $isRemote; + + // Path display + $this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono'); + $this->addComponent($this->pathLabel); + + // File table with explicit flex-1 for scrolling + $this->fileTable = new Table(' flex-1'); + $this->fileTable->setColumns([ + ['key' => 'type', 'title' => 'Typ', 'width' => 60], + ['key' => 'name', 'title' => 'Name'], + ['key' => 'size', 'title' => 'Größe', 'width' => 100], + ['key' => 'modified', 'title' => 'Geändert', 'width' => 160], + ]); + + $this->addComponent($this->fileTable); + + // Load initial directory (only if local) + if (!$isRemote) { + $this->loadDirectory($initialPath); + } + } + + /** + * Load directory contents (local only) + */ + public function loadDirectory(string $path): void + { + if ($this->isRemote) { + return; // Remote loading is handled separately + } + + $this->currentPath = realpath($path) ?: $path; + $this->pathLabel->setText($this->currentPath); + + $files = []; + + // Add parent directory entry if not at root + if ($this->currentPath !== '/') { + $files[] = [ + 'type' => 'DIR', + 'name' => '..', + 'size' => '', + 'modified' => '', + 'path' => dirname($this->currentPath), + 'isDir' => true, + ]; + } + + // Read directory + try { + $entries = scandir($this->currentPath); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $fullPath = $this->currentPath . DIRECTORY_SEPARATOR . $entry; + $isDir = is_dir($fullPath); + $mtime = filemtime($fullPath); + + $files[] = [ + 'type' => $isDir ? 'DIR' : 'FILE', + 'name' => $entry, + 'size' => $isDir ? '' : $this->formatSize(filesize($fullPath)), + 'modified' => $mtime ? date('Y-m-d H:i:s', $mtime) : '', + 'path' => $fullPath, + 'isDir' => $isDir, + ]; + } + } catch (\Exception $e) { + // Handle permission errors + $files[] = [ + 'type' => 'ERR', + 'name' => 'Fehler: ' . $e->getMessage(), + 'size' => '', + 'modified' => '', + 'path' => '', + 'isDir' => false, + ]; + } + + $this->fileTable->setData($files); + + // Handle row selection + $fileBrowser = $this; + $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) { + if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { + // Navigate to directory + $fileBrowser->loadDirectory($row['path']); + } elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) { + // File selected + ($fileBrowser->onFileSelect)($row['path'], $row); + } + }); + } + + /** + * Load remote directory contents via SFTP + */ + public function loadRemoteDirectory(string $path, $sftpConnection): void + { + if (!$this->isRemote) { + return; + } + + $this->currentPath = $path; + $this->pathLabel->setText($path); + + $files = []; + + // Add parent directory entry if not at root + if ($path !== '/') { + $parentPath = dirname($path); + if ($parentPath === '.') { + $parentPath = '/'; + } + $files[] = [ + 'type' => 'DIR', + 'name' => '..', + 'size' => '', + 'modified' => '', + 'path' => $parentPath, + 'isDir' => true, + ]; + } + + // Read remote directory + try { + $entries = $sftpConnection->nlist($path); + if ($entries === false) { + throw new \Exception('Cannot read directory'); + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $fullPath = rtrim($path, '/') . '/' . $entry; + $stat = $sftpConnection->stat($fullPath); + $isDir = ($stat['type'] ?? 0) === 2; // NET_SFTP_TYPE_DIRECTORY + $mtime = $stat['mtime'] ?? 0; + + $files[] = [ + 'type' => $isDir ? 'DIR' : 'FILE', + 'name' => $entry, + 'size' => $isDir ? '' : $this->formatSize($stat['size'] ?? 0), + 'modified' => $mtime ? date('Y-m-d H:i:s', $mtime) : '', + 'path' => $fullPath, + 'isDir' => $isDir, + ]; + } + } catch (\Exception $e) { + $files[] = [ + 'type' => 'ERR', + 'name' => 'Fehler: ' . $e->getMessage(), + 'size' => '', + 'modified' => '', + 'path' => '', + 'isDir' => false, + ]; + } + + $this->fileTable->setData($files); + + // Handle row selection + $fileBrowser = $this; + $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser, $sftpConnection) { + if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { + // Navigate to directory + $fileBrowser->loadRemoteDirectory($row['path'], $sftpConnection); + } elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) { + // File selected + ($fileBrowser->onFileSelect)($row['path'], $row); + } + }); + } + + /** + * Format file size + */ + private function formatSize(int|float $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= pow(1024, $pow); + + return round($bytes, 2) . ' ' . $units[$pow]; + } + + /** + * Set file select callback + */ + public function setOnFileSelect(callable $callback): void + { + $this->onFileSelect = $callback; + } + + /** + * Get current path + */ + public function getCurrentPath(): string + { + return $this->currentPath; + } + + /** + * Set path (updates label only, doesn't reload) + */ + public function setPath(string $path): void + { + $this->currentPath = $path; + $this->pathLabel->setText($path); + } + + /** + * Set file data directly (for remote browsers where data comes from async operations) + */ + public function setFileData(array $files): void + { + $tableData = []; + + // Add parent directory if not at root + if ($this->currentPath !== '/') { + $parentPath = dirname($this->currentPath); + if ($parentPath === '.') { + $parentPath = '/'; + } + $tableData[] = [ + 'type' => 'DIR', + 'name' => '..', + 'size' => '', + 'modified' => '', + 'path' => $parentPath, + 'isDir' => true, + ]; + } + + // Add files + foreach ($files as $file) { + $tableData[] = [ + 'type' => $file['isDir'] ? 'DIR' : 'FILE', + 'name' => $file['name'], + 'size' => $file['isDir'] ? '' : $this->formatSize($file['size']), + 'modified' => isset($file['mtime']) ? date('Y-m-d H:i:s', $file['mtime']) : '', + 'path' => $file['path'], + 'isDir' => $file['isDir'], + ]; + } + + $this->fileTable->setData($tableData); + } +} diff --git a/src/Ui/Widget/Label.php b/src/Ui/Widget/Label.php index 1dceec1..a15dd05 100644 --- a/src/Ui/Widget/Label.php +++ b/src/Ui/Widget/Label.php @@ -30,7 +30,8 @@ class Label extends Component $this->text = $text; $this->clearTextTexture(); - $this->markDirty(true); + + // $this->markDirty(true); } public function layout(null|TextRenderer $textRenderer = null): void @@ -108,7 +109,7 @@ class Label extends Component $this->text, (int) $this->contentViewport->x, (int) $this->contentViewport->y, - $textStyle->size + $textStyle->size, ); } diff --git a/src/Ui/Widget/TabContainer.php b/src/Ui/Widget/TabContainer.php index d3c235f..0ddc15b 100644 --- a/src/Ui/Widget/TabContainer.php +++ b/src/Ui/Widget/TabContainer.php @@ -11,9 +11,8 @@ class TabContainer extends Container private array $tabs = []; private int $activeTabIndex = 0; - public function __construct( - string $style = '', - ) { + public function __construct(string $style = '') + { parent::__construct('flex flex-col ' . $style); // Create header container for tab buttons @@ -21,7 +20,7 @@ class TabContainer extends Container $this->addComponent($this->tabHeaderContainer); // Create content container - $this->tabContentContainer = new Container('flex-1 overflow-auto'); + $this->tabContentContainer = new Container('flex-1'); $this->addComponent($this->tabContentContainer); } @@ -42,7 +41,7 @@ class TabContainer extends Container : '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) { + $tabButton->setOnClick(function () use ($tabIndex) { $this->setActiveTab($tabIndex); }); diff --git a/src/Ui/Widget/Table.php b/src/Ui/Widget/Table.php index 827c965..c211912 100644 --- a/src/Ui/Widget/Table.php +++ b/src/Ui/Widget/Table.php @@ -12,17 +12,20 @@ class Table extends Container private Container $bodyContainer; private null|int $selectedRowIndex = null; private $onRowSelect = null; + private null|string $sortColumn = null; + private bool $sortAscending = true; public function __construct(string $style = '') { - parent::__construct('flex flex-col overflow-auto ' . $style); + parent::__construct('flex flex-col w-full' . $style); // Create header container - $this->headerContainer = new Container('flex flex-row bg-gray-200 border-b-2 border-gray-400'); + $this->headerContainer = new Container('flex flex-row w-full 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'); + // Use flex-1 to fill available space, overflow-auto to enable scrolling + $this->bodyContainer = new Container('flex flex-col w-full overflow-auto flex-1'); $this->addComponent($this->bodyContainer); } @@ -81,13 +84,16 @@ class Table extends Container 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'; + $rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-400'; if ($isSelected) { $rowStyle .= ' bg-blue-100'; } $rowContainer = new Container($rowStyle); + // Enable texture caching for table rows (huge performance boost!) + $rowContainer->setUseTextureCache(true); + foreach ($this->columns as $column) { $key = $column['key']; $value = $rowData[$key] ?? ''; @@ -136,6 +142,35 @@ class Table extends Container $this->bodyContainer->addComponent($clickHandler); } + /** + * Sort by column + */ + public function sortByColumn(string $columnKey): void + { + // Toggle sort direction if clicking same column + if ($this->sortColumn === $columnKey) { + $this->sortAscending = !$this->sortAscending; + } else { + $this->sortColumn = $columnKey; + $this->sortAscending = true; + } + + // Sort the data + $sortedRows = $this->rows; + usort($sortedRows, function ($a, $b) use ($columnKey) { + $aVal = $a[$columnKey] ?? ''; + $bVal = $b[$columnKey] ?? ''; + + // Natural sort for strings/numbers + $result = strnatcasecmp((string) $aVal, (string) $bVal); + + return $this->sortAscending ? $result : -$result; + }); + + // Re-render with sorted data + $this->setData($sortedRows); + } + /** * Select a row */ @@ -149,7 +184,7 @@ class Table extends Container } // Re-render rows to update selection - // $this->setData($this->rows); + $this->setData($this->rows); } /** diff --git a/src/Ui/Window.php b/src/Ui/Window.php index 0c5c4a6..985fccb 100644 --- a/src/Ui/Window.php +++ b/src/Ui/Window.php @@ -2,6 +2,7 @@ namespace PHPNative\Ui; +use PHPNative\Framework\Profiler; use PHPNative\Framework\TextRenderer; class Window @@ -22,6 +23,7 @@ class Window private float $lastFpsUpdate = 0.0; private int $frameCounter = 0; private float $currentFps = 0.0; + public function __construct( private string $title, private int $width = 800, @@ -83,7 +85,6 @@ class Window ); $this->lastFpsUpdate = microtime(true); - } public function setRoot(Component $component): self @@ -219,8 +220,11 @@ class Window break; case SDL_EVENT_MOUSE_MOTION: - $this->mouseX = $event['x'] ?? 0; - $this->mouseY = $event['y'] ?? 0; + $newMouseX = (float) ($event['x'] ?? 0); + $newMouseY = (float) ($event['y'] ?? 0); + + $this->mouseX = $newMouseX; + $this->mouseY = $newMouseY; // Check overlays first (in reverse z-index order - highest first) if ($this->rootComponent) { @@ -360,15 +364,26 @@ class Window return; } + Profiler::start('window_render'); + if ($this->shouldBeReLayouted) { + Profiler::start('layout'); $this->layout(); + Profiler::end('layout'); } sdl_set_render_draw_color($this->renderer, 255, 255, 255, 255); sdl_render_clear($this->renderer); + // Build texture cache for components that need it (after layout) + Profiler::start('buildTextureCacheRecursive'); + $this->rootComponent->buildTextureCacheRecursive($this->renderer, $this->textRenderer); + Profiler::end('buildTextureCacheRecursive'); + + Profiler::start('render_tree'); $this->rootComponent->render($this->renderer, $this->textRenderer); $this->rootComponent->renderContent($this->renderer, $this->textRenderer); + Profiler::end('render_tree'); $overlays = $this->rootComponent->collectOverlays(); @@ -390,6 +405,11 @@ class Window $this->updateFps(); sdl_render_present($this->renderer); + + Profiler::end('window_render'); + + // Report profiling data periodically + Profiler::report(); } /** @@ -439,7 +459,7 @@ class Window $this->onResize = $callback; } - public function setOnFpsChange(?callable $callback): void + public function setOnFpsChange(null|callable $callback): void { $this->onFpsChange = $callback; } diff --git a/test_scroll.php b/test_scroll.php new file mode 100644 index 0000000..dcc7959 --- /dev/null +++ b/test_scroll.php @@ -0,0 +1,36 @@ +addComponent($label); +} + +$mainContainer->addComponent(new Label('Scroll Test (red box should be scrollable)', 'text-xl font-bold p-4 text-black')); +$mainContainer->addComponent($scrollContainer); +$mainContainer->addComponent(new Label('Try scrolling with mouse wheel over the red box', 'p-4 text-black')); + +$window->setRoot($mainContainer); +$app->addWindow($window); + +echo "Scroll Test started!\n"; +echo "- Red box should show scrollbar\n"; +echo "- Try scrolling with mouse wheel\n\n"; + +$app->run(); diff --git a/test_table_scroll.php b/test_table_scroll.php new file mode 100644 index 0000000..fdad984 --- /dev/null +++ b/test_table_scroll.php @@ -0,0 +1,44 @@ +setColumns([ + ['key' => 'id', 'title' => 'ID', 'width' => 100], + ['key' => 'name', 'title' => 'Name', 'width' => 400], + ['key' => 'status', 'title' => 'Status', 'width' => 120], +]); + +// Test data with 63 entries (same as your example) +$testData = []; +for ($i = 1; $i <= 63; $i++) { + $testData[] = [ + 'id' => $i, + 'name' => "Server-{$i}", + 'status' => ($i % 3 === 0) ? 'stopped' : 'running', + ]; +} +$table->setData($testData); + +$mainContainer->addComponent($table); + +$window->setRoot($mainContainer); +$app->addWindow($window); + +echo "Table Scroll Test started!\n"; +echo "- Table should show scrollbar\n"; +echo "- Try scrolling with mouse wheel over the table\n\n"; + +$app->run();