FTP Manager

This commit is contained in:
Thomas Peterson 2025-11-11 11:39:33 +01:00
parent 6d969944dc
commit 402ad74582
24 changed files with 1387 additions and 137 deletions

View File

@ -5,7 +5,9 @@
"ext-parallel": "^1.2", "ext-parallel": "^1.2",
"monolog/monolog": "^3.9", "monolog/monolog": "^3.9",
"php": "^8.4", "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", "license": "MIT",
"autoload": { "autoload": {

29
examples/test_simple.php Normal file
View File

@ -0,0 +1,29 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Simple Test', 400, 300);
$container = new Container('flex flex-col p-4 gap-4');
$label = new Label('Test Label', 'text-lg');
$container->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();

View File

@ -3,11 +3,14 @@
require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application; use PHPNative\Framework\Application;
use PHPNative\Framework\HetznerClient;
use PHPNative\Framework\IconFontRegistry; use PHPNative\Framework\IconFontRegistry;
use PHPNative\Framework\Profiler;
use PHPNative\Framework\Settings; use PHPNative\Framework\Settings;
use PHPNative\Tailwind\Data\Icon as IconName; use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container; use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\FileBrowser;
use PHPNative\Ui\Widget\Icon; use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label; use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\Menu; use PHPNative\Ui\Widget\Menu;
@ -41,26 +44,34 @@ if ($iconFontPath !== null) {
echo "Hinweis: FontAwesome Font nicht gefunden. Icons werden ohne Symbol dargestellt.\n"; 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(); $app = new Application();
$window = new Window('Windows Application Example', 800, 600); $window = new Window('Windows Application Example', 800, 600);
// Initialize settings // Initialize settings
$settings = new Settings('WindowsAppExample'); $settings = new Settings('WindowsAppExample');
$currentApiKey = $settings->get('api_key', ''); $currentApiKey = $settings->get('api_key', '');
$currentPrivateKeyPath = $settings->get('private_key_path', '');
/** @var Label|null $statusLabel */ /** @var Label|null $statusLabel */
$statusLabel = null; $statusLabel = null;
// Store selected server for SFTP connection
$selectedServer = null;
// Main container (flex-col: menu, content, status) // Main container (flex-col: menu, content, status)
$mainContainer = new Container('flex flex-col bg-gray-100'); $mainContainer = new Container('flex flex-col bg-gray-100');
// Modal dialog setup (hidden by default) // 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'); $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 = 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('API Einstellungen', 'text-xl font-bold text-black'));
$modalDialog->addComponent(new Label( $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', 'text-sm text-gray-700',
)); ));
@ -69,6 +80,11 @@ $fieldContainer->addComponent(new Label('API Key', 'text-sm text-gray-600'));
$fieldContainer->addComponent($apiKeyInput); $fieldContainer->addComponent($apiKeyInput);
$modalDialog->addComponent($fieldContainer); $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'); $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'); $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'); $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 // Settings Menu
$settingsMenu = new Menu(title: 'Einstellungen'); $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(); $menuBar->closeAllMenus();
$apiKeyInput->setValue($currentApiKey); $apiKeyInput->setValue($currentApiKey);
$privateKeyPathInput->setValue($currentPrivateKeyPath);
$modal->setVisible(true); $modal->setVisible(true);
}); });
$settingsMenu->addItem('Sprache', function () { $settingsMenu->addItem('Sprache', function () {
@ -114,43 +131,156 @@ $mainContainer->addComponent($menuBar);
// === 2. Tab Container (flex-1) === // === 2. Tab Container (flex-1) ===
$tabContainer = new TabContainer('flex-1'); $tabContainer = new TabContainer('flex-1');
// Tab 1: Table with data // Tab 1: Table with server data (Master-Detail Layout)
$tab1 = new Container('flex flex-col p-4'); $tab1 = new Container('flex flex-row p-4 gap-4'); // Changed to flex-row for side-by-side layout
$table = new Table(style: '');
// 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([ $table->setColumns([
['key' => 'id', 'title' => 'ID', 'width' => 80], ['key' => 'id', 'title' => 'ID', 'width' => 100],
['key' => 'name', 'title' => 'Name'], ['key' => 'name', 'title' => 'Name', 'width' => 400],
['key' => 'email', 'title' => 'E-Mail'],
['key' => 'status', 'title' => 'Status', 'width' => 120], ['key' => 'status', 'title' => 'Status', 'width' => 120],
['key' => 'type', 'title' => 'Typ', 'width' => 120],
['key' => 'ipv4', 'title' => 'IPv4'],
]); ]);
$table->setData([ // Test data with 63 entries
['id' => 1, 'name' => 'Max Mustermann', 'email' => 'max@example.com', 'status' => 'Aktiv'], $testData = [];
['id' => 2, 'name' => 'Anna Schmidt', 'email' => 'anna@example.com', 'status' => 'Aktiv'], for ($i = 1; $i <= 63; $i++) {
['id' => 3, 'name' => 'Peter Weber', 'email' => 'peter@example.com', 'status' => 'Inaktiv'], $testData[] = [
['id' => 4, 'name' => 'Lisa Müller', 'email' => 'lisa@example.com', 'status' => 'Aktiv'], 'id' => $i,
['id' => 5, 'name' => 'Tom Klein', 'email' => 'tom@example.com', 'status' => 'Aktiv'], 'name' => "Server-{$i}",
['id' => 6, 'name' => 'Sarah Wagner', 'email' => 'sarah@example.com', 'status' => 'Inaktiv'], 'status' => ($i % 3) === 0 ? 'stopped' : 'running',
['id' => 7, 'name' => 'Michael Becker', 'email' => 'michael@example.com', 'status' => 'Aktiv'], 'type' => 'cx' . (11 + (($i % 4) * 10)),
['id' => 8, 'name' => 'Julia Fischer', 'email' => 'julia@example.com', 'status' => 'Aktiv'], 'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255),
['id' => 9, 'name' => 'Daniel Schneider', 'email' => 'daniel@example.com', 'status' => 'Inaktiv'], ];
['id' => 10, 'name' => 'Laura Hoffmann', 'email' => 'laura@example.com', 'status' => 'Aktiv'], }
]); $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( $statusLabel = new Label(
text: 'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight, text: 'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight,
style: 'basis-4/8 text-black', 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) { 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); // Function to load servers from Hetzner API (only returns simple data, no objects)
$tabContainer->addTab('Daten', $tab1); // 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 // Tab 2: Some info
$tab2 = new Container('flex flex-col p-4'); $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...', '')); $tab3->addComponent(new Label('Konfigurationsoptionen...', ''));
$tabContainer->addTab('Einstellungen', $tab3); $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); $mainContainer->addComponent($tabContainer);
// === 3. StatusBar === // === 3. StatusBar ===
@ -189,18 +424,20 @@ $cancelButton->setOnClick(function () use ($menuBar, $modal) {
$modal->setVisible(false); $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()); $currentApiKey = trim($apiKeyInput->getValue());
$currentPrivateKeyPath = trim($privateKeyPathInput->getValue());
// Save to settings // Save to settings
$settings->set('api_key', $currentApiKey); $settings->set('api_key', $currentApiKey);
$settings->set('private_key_path', $currentPrivateKeyPath);
$settings->save(); $settings->save();
if ($statusLabel !== null) { if ($statusLabel !== null) {
$masked = strlen($currentApiKey) > 4 $masked = strlen($currentApiKey) > 4
? (str_repeat('*', max(0, strlen($currentApiKey) - 4)) . substr($currentApiKey, -4)) ? (str_repeat('*', max(0, strlen($currentApiKey) - 4)) . substr($currentApiKey, -4))
: $currentApiKey; : $currentApiKey;
$statusLabel->setText('API-Key gespeichert: ' . $masked . ' (' . $settings->getPath() . ')'); $statusLabel->setText('Einstellungen gespeichert: API-Key ' . $masked . ' (' . $settings->getPath() . ')');
} }
$menuBar->closeAllMenus(); $menuBar->closeAllMenus();
$modal->setVisible(false); $modal->setVisible(false);

Binary file not shown.

Binary file not shown.

View File

@ -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; Uint8 r_col, g_col, b_col, a_col;
SDL_GetRenderDrawColor(renderer, &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 // Use scanline filling with antialiasing on the edge
// We'll draw the arc using the primitives library float radius = (float)r;
switch (quadrant) {
case 0: // top-left // Iterate through each row of the quadrant
filledPieRGBA(renderer, cx, cy, r, 180, 270, r_col, g_col, b_col, a_col); for (int y = 0; y < r; y++) {
break; // Calculate distance from center
case 1: // top-right float dy = (float)y + 0.5f;
filledPieRGBA(renderer, cx, cy, r, 270, 360, r_col, g_col, b_col, a_col); float x_exact = sqrtf(fmaxf(0.0f, radius * radius - dy * dy));
break; int x_max = (int)floorf(x_exact);
case 2: // bottom-right float frac = x_exact - (float)x_max;
filledPieRGBA(renderer, cx, cy, r, 0, 90, r_col, g_col, b_col, a_col);
break; // Draw the fully opaque pixels for this scanline
case 3: // bottom-left for (int x = 0; x <= x_max; x++) {
filledPieRGBA(renderer, cx, cy, r, 90, 180, r_col, g_col, b_col, a_col); int px, py;
break; 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 #endif

Binary file not shown.

View File

@ -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_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; 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; // Einfachere Strategie: Zeichne Rechtecke die sich überlappen dürfen,
int r_right = (rad_tr > rad_br) ? rad_tr : rad_br; // dann die Kreise darüber
// 1) center vertical band (zwischen links und rechts Radien), ganze Höhe // 1) Horizontales Rechteck oben (von linker Ecke bis rechter Ecke)
SDL_FRect center = { x1 + r_left, y1, x2 - x1-r_left-r_right, y2-y1 }; SDL_FRect topRect = { x1 + rad_tl, y1, x2 - x1 - rad_tl - rad_tr, rad_tl > rad_tr ? rad_tl : rad_tr };
if (center.w > 0 && center.h > 0) SDL_RenderFillRect(ren, &center); if (topRect.w > 0 && topRect.h > 0) SDL_RenderFillRect(ren, &topRect);
// 2) left vertical rectangle (zwischen oberen und unteren Ecken links) // 2) Horizontales Rechteck unten (von linker Ecke bis rechter Ecke)
SDL_FRect leftRect = { x1, y1 + rad_tl, r_left, y2 - y1 - rad_tl - rad_bl }; 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); if (leftRect.w > 0 && leftRect.h > 0) SDL_RenderFillRect(ren, &leftRect);
// 3) right vertical rectangle (zwischen oberen und unteren Ecken rechts) // 4) Vertikales Rechteck rechts (volle Höhe zwischen Ecken)
SDL_FRect rightRect = { x2 - r_right, y1 + rad_tr, r_right, y2 - y1 - rad_tr - rad_br }; 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); if (rightRect.w > 0 && rightRect.h > 0) SDL_RenderFillRect(ren, &rightRect);
// 4) Horizontale Füllrechtecke für Lücken zwischen Ecken und vertikalen Rechtecken // 5) Zentrales Rechteck (füllt die Mitte)
// Oben links: wenn rad_tl < r_left int maxLeftRad = rad_tl > rad_bl ? rad_tl : rad_bl;
if (rad_tl < r_left) { maxRightRad = rad_tr > rad_br ? rad_tr : rad_br;
SDL_FRect topLeft = { x1 + rad_tl, y1, r_left - rad_tl, rad_tl }; SDL_FRect centerRect = { x1 + maxLeftRad, y1, x2 - x1 - maxLeftRad - maxRightRad, y2 - y1 };
if (topLeft.w > 0 && topLeft.h > 0) SDL_RenderFillRect(ren, &topLeft); if (centerRect.w > 0 && centerRect.h > 0) SDL_RenderFillRect(ren, &centerRect);
}
// 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) 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_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_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, y2- rad_br, rad_br, 2); 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, rad_bl, 3); if (rad_bl > 0) filled_quarter_circle(ren, x1 + rad_bl, y2 - rad_bl - 1, rad_bl, 3);
RETURN_TRUE; RETURN_TRUE;
} }

View File

@ -32,7 +32,9 @@ class AsyncTask
} }
try { 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); $this->future = $runtime->run($this->task);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->failed = true; $this->failed = true;

7
src/Async/bootstrap.php Normal file
View File

@ -0,0 +1,7 @@
<?php
// Bootstrap file for parallel runtime
// This file is loaded in each parallel runtime thread
// Load autoloader
require_once __DIR__ . '/../../vendor/autoload.php';

View File

@ -92,10 +92,43 @@ class Application
} }
// SDL3: Poll all events globally and distribute to the correct windows // SDL3: Poll all events globally and distribute to the correct windows
$events = []; $events = [];
$mouseMotionCount = 0;
while ($event = sdl_poll_event()) { while ($event = sdl_poll_event()) {
$events[] = $event; $events[] = $event;
if ($event['type'] === SDL_EVENT_MOUSE_MOTION) {
$mouseMotionCount++;
}
} }
// Coalesce mouse motion events: Only keep the last MouseMotion event per window
// This dramatically reduces the number of events to process
$coalescedEvents = [];
$lastMouseMotionPerWindow = [];
foreach ($events as $event) {
if ($event['type'] === SDL_EVENT_MOUSE_MOTION) {
$windowId = $event['window_id'] ?? null;
// Store the last mouse motion event for each window
$lastMouseMotionPerWindow[$windowId] = $event;
} else {
// Keep all non-motion events
$coalescedEvents[] = $event;
}
}
// Add the last mouse motion event for each window at the end
foreach ($lastMouseMotionPerWindow as $event) {
$coalescedEvents[] = $event;
}
// Track coalescing effectiveness
if ($mouseMotionCount > 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 // Distribute events to windows based on window_id
foreach ($events as $event) { foreach ($events as $event) {
$eventWindowId = $event['window_id'] ?? null; $eventWindowId = $event['window_id'] ?? null;

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace PHPNative\Framework;
class HetznerClient
{
private string $apiToken;
private string $baseUrl = 'https://api.hetzner.cloud/v1';
public function __construct(string $apiToken)
{
$this->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];
}
}

137
src/Framework/Profiler.php Normal file
View File

@ -0,0 +1,137 @@
<?php
namespace PHPNative\Framework;
class Profiler
{
private static array $timings = [];
private static array $counters = [];
private static bool $enabled = false;
private static float $lastReportTime = 0;
private static float $reportInterval = 2.0; // Report every 2 seconds
public static function enable(): void
{
self::$enabled = true;
self::$lastReportTime = microtime(true);
}
public static function disable(): void
{
self::$enabled = false;
}
public static function isEnabled(): bool
{
return self::$enabled;
}
public static function start(string $label): void
{
if (!self::$enabled) {
return;
}
if (!isset(self::$timings[$label])) {
self::$timings[$label] = [
'total' => 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 = [];
}
}

View File

@ -32,9 +32,7 @@ class Gap implements Parser
} }
if ($x !== null || $y !== null) { if ($x !== null || $y !== null) {
$gap = new \PHPNative\Tailwind\Style\Gap($x ?? 0, $y ?? 0); return new \PHPNative\Tailwind\Style\Gap($x ?? 0, $y ?? 0);
error_log("Gap parsed from '$style': x={$gap->x}, y={$gap->y}");
return $gap;
} }
return null; return null;

View File

@ -2,7 +2,9 @@
namespace PHPNative\Ui; namespace PHPNative\Ui;
use PHPNative\Framework\Profiler;
use PHPNative\Framework\TextRenderer; use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Parser\State;
use PHPNative\Tailwind\Style\Margin; use PHPNative\Tailwind\Style\Margin;
use PHPNative\Tailwind\Style\MediaQueryEnum; use PHPNative\Tailwind\Style\MediaQueryEnum;
use PHPNative\Tailwind\Style\Padding; use PHPNative\Tailwind\Style\Padding;
@ -29,17 +31,24 @@ abstract class Component
protected null|Component $parent = null; // Reference to parent component protected null|Component $parent = null; // Reference to parent component
protected $cachedTexture = null; // SDL texture cache for this component // Texture caching for performance
protected $cachedTexture = null; // Normal state texture
protected bool $useTextureCache = false; // Disabled by default, enable per component if needed protected $cachedHoverTexture = null; // Hover state texture
protected bool $useTextureCache = false;
protected bool $textureCacheValid = false;
protected Viewport $viewport; protected Viewport $viewport;
protected array $computedStyles = []; protected array $computedStyles = [];
protected array $hoverStylesCached = [];
protected array $normalStylesCached = [];
protected Viewport $contentViewport; protected Viewport $contentViewport;
protected null|Window $attachedWindow = null; 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( public function __construct(
protected string $style = '', protected string $style = '',
) { ) {
@ -161,9 +170,108 @@ abstract class Component
sdl_destroy_texture($this->cachedTexture); sdl_destroy_texture($this->cachedTexture);
$this->cachedTexture = null; $this->cachedTexture = null;
} }
if ($this->cachedHoverTexture !== null) {
sdl_destroy_texture($this->cachedHoverTexture);
$this->cachedHoverTexture = null;
}
$this->textureCacheValid = false;
$this->renderDirty = true; $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() public function getCachedTexture()
{ {
return $this->cachedTexture; return $this->cachedTexture;
@ -220,10 +328,19 @@ abstract class Component
public function layout(null|TextRenderer $textRenderer = null): void public function layout(null|TextRenderer $textRenderer = null): void
{ {
$this->computedStyles = StyleParser::parse($this->style)->getValidStyles( $this->normalStylesCached = StyleParser::parse($this->style)->getValidStyles(
MediaQueryEnum::normal, 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])) { if (isset($this->computedStyles[Margin::class]) && ($m = $this->computedStyles[Margin::class])) {
$this->viewport->x = (int) ($this->viewport->x + $m->left); $this->viewport->x = (int) ($this->viewport->x + $m->left);
$this->viewport->width = max(0, ($this->viewport->width - $m->right) - $m->left); $this->viewport->width = max(0, ($this->viewport->width - $m->right) - $m->left);
@ -254,6 +371,39 @@ abstract class Component
return; 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 ( if (
isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) && isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) &&
($bg = $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, 'h' => $this->viewport->height,
]); ]);
} }
Profiler::end('render');
} }
public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void
@ -319,10 +471,70 @@ abstract class Component
return; 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) { foreach ($this->children as $child) {
$child->render($renderer, $textRenderer); $child->buildTextureCacheRecursive($renderer, $textRenderer);
$child->renderContent($renderer, $textRenderer);
} }
} }
@ -381,6 +593,9 @@ abstract class Component
*/ */
public function handleMouseMove(float $mouseX, float $mouseY): void public function handleMouseMove(float $mouseX, float $mouseY): void
{ {
Profiler::start('handleMouseMove');
Profiler::increment('mouse_move_events');
// Check if mouse is over this component // Check if mouse is over this component
$isMouseOver = $isMouseOver =
$mouseX >= $this->viewport->x && $mouseX >= $this->viewport->x &&
@ -394,19 +609,74 @@ abstract class Component
// Recompute styles if state changed // Recompute styles if state changed
if ($previousState !== $this->currentState) { if ($previousState !== $this->currentState) {
$this->computedStyles = StyleParser::parse($this->style)->getValidStyles( Profiler::increment('state_changes');
MediaQueryEnum::normal, if ($this->currentState == StateEnum::hover) {
$this->currentState, $this->computedStyles = $this->hoverStylesCached;
); } else {
// Mark as dirty since visual state changed $this->computedStyles = $this->normalStylesCached;
$this->markDirty(false, false); }
}
foreach ($this->children as $child) { // Only mark dirty if we're NOT using texture cache
// Skip overlays - they are handled separately by the Window // If we use texture cache, we already have both states cached
if (!$child->isOverlay()) { if (!$this->useTextureCache) {
$child->handleMouseMove($mouseX, $mouseY); $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 public function detachFromWindow(): void
{ {
$this->attachedWindow = null; $this->attachedWindow = null;

View File

@ -22,6 +22,9 @@ class Button extends Container
) { ) {
parent::__construct($style); parent::__construct($style);
// Enable texture caching for buttons (huge performance boost!)
$this->setUseTextureCache(true);
// Create label inside button // Create label inside button
$this->label = new Label( $this->label = new Label(
text: $text, text: $text,

View File

@ -188,10 +188,6 @@ class Container extends Component
$gapSize = 0; $gapSize = 0;
if ($gap) { if ($gap) {
$gapSize = $isRow ? $gap->x : $gap->y; $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 // 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 private function renderScrollbars(&$renderer, array $overflow): void
{ {
$scrollbarColor = [100, 100, 100, 200]; // Gray with some transparency
// Vertical scrollbar // Vertical scrollbar
if ($overflow['y']) { if ($overflow['y']) {
$scrollbarHeight = $this->contentViewport->height; $scrollbarHeight = $this->contentViewport->height;
@ -497,8 +491,8 @@ class Container extends Component
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH; $scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH;
// Track // Track (light gray background)
sdl_set_render_draw_color($renderer, 200, 200, 200, 100); sdl_set_render_draw_color($renderer, 220, 220, 220, 255);
sdl_render_fill_rect($renderer, [ sdl_render_fill_rect($renderer, [
'x' => (int) $scrollbarX, 'x' => (int) $scrollbarX,
'y' => (int) $this->contentViewport->y, 'y' => (int) $this->contentViewport->y,
@ -506,21 +500,16 @@ class Container extends Component
'h' => (int) $scrollbarHeight, '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); $thumbX = (int) ($scrollbarX + 2);
$thumbW = (int) (self::SCROLLBAR_WIDTH - 4); $thumbW = (int) (self::SCROLLBAR_WIDTH - 4);
sdl_rounded_box( sdl_set_render_draw_color($renderer, 128, 128, 128, 255);
$renderer, sdl_render_fill_rect($renderer, [
$thumbX, 'x' => $thumbX,
(int) $thumbY, 'y' => (int) $thumbY,
$thumbX + $thumbW, 'w' => $thumbW,
(int) ($thumbY + $thumbHeight), 'h' => (int) $thumbHeight,
4, ]);
$scrollbarColor[0],
$scrollbarColor[1],
$scrollbarColor[2],
$scrollbarColor[3],
);
} }
// Horizontal scrollbar // Horizontal scrollbar

View File

@ -0,0 +1,273 @@
<?php
namespace PHPNative\Ui\Widget;
class FileBrowser extends Container
{
private Table $fileTable;
private Label $pathLabel;
private string $currentPath;
private $onFileSelect = null;
private bool $isRemote = false;
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
{
parent::__construct('flex flex-col gap-2 ' . $style);
$this->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);
}
}

View File

@ -30,7 +30,8 @@ class Label extends Component
$this->text = $text; $this->text = $text;
$this->clearTextTexture(); $this->clearTextTexture();
$this->markDirty(true);
// $this->markDirty(true);
} }
public function layout(null|TextRenderer $textRenderer = null): void public function layout(null|TextRenderer $textRenderer = null): void
@ -108,7 +109,7 @@ class Label extends Component
$this->text, $this->text,
(int) $this->contentViewport->x, (int) $this->contentViewport->x,
(int) $this->contentViewport->y, (int) $this->contentViewport->y,
$textStyle->size $textStyle->size,
); );
} }

View File

@ -11,9 +11,8 @@ class TabContainer extends Container
private array $tabs = []; private array $tabs = [];
private int $activeTabIndex = 0; private int $activeTabIndex = 0;
public function __construct( public function __construct(string $style = '')
string $style = '', {
) {
parent::__construct('flex flex-col ' . $style); parent::__construct('flex flex-col ' . $style);
// Create header container for tab buttons // Create header container for tab buttons
@ -21,7 +20,7 @@ class TabContainer extends Container
$this->addComponent($this->tabHeaderContainer); $this->addComponent($this->tabHeaderContainer);
// Create content container // Create content container
$this->tabContentContainer = new Container('flex-1 overflow-auto'); $this->tabContentContainer = new Container('flex-1');
$this->addComponent($this->tabContentContainer); $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'; : '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 = new Button($title, $tabStyle);
$tabButton->setOnClick(function() use ($tabIndex) { $tabButton->setOnClick(function () use ($tabIndex) {
$this->setActiveTab($tabIndex); $this->setActiveTab($tabIndex);
}); });

View File

@ -12,17 +12,20 @@ class Table extends Container
private Container $bodyContainer; private Container $bodyContainer;
private null|int $selectedRowIndex = null; private null|int $selectedRowIndex = null;
private $onRowSelect = null; private $onRowSelect = null;
private null|string $sortColumn = null;
private bool $sortAscending = true;
public function __construct(string $style = '') public function __construct(string $style = '')
{ {
parent::__construct('flex flex-col overflow-auto ' . $style); parent::__construct('flex flex-col w-full' . $style);
// Create header container // 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); $this->addComponent($this->headerContainer);
// Create body container (scrollable) // 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); $this->addComponent($this->bodyContainer);
} }
@ -81,13 +84,16 @@ class Table extends Container
private function addRow(array $rowData, int $rowIndex): void private function addRow(array $rowData, int $rowIndex): void
{ {
$isSelected = $rowIndex === $this->selectedRowIndex; $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) { if ($isSelected) {
$rowStyle .= ' bg-blue-100'; $rowStyle .= ' bg-blue-100';
} }
$rowContainer = new Container($rowStyle); $rowContainer = new Container($rowStyle);
// Enable texture caching for table rows (huge performance boost!)
$rowContainer->setUseTextureCache(true);
foreach ($this->columns as $column) { foreach ($this->columns as $column) {
$key = $column['key']; $key = $column['key'];
$value = $rowData[$key] ?? ''; $value = $rowData[$key] ?? '';
@ -136,6 +142,35 @@ class Table extends Container
$this->bodyContainer->addComponent($clickHandler); $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 * Select a row
*/ */
@ -149,7 +184,7 @@ class Table extends Container
} }
// Re-render rows to update selection // Re-render rows to update selection
// $this->setData($this->rows); $this->setData($this->rows);
} }
/** /**

View File

@ -2,6 +2,7 @@
namespace PHPNative\Ui; namespace PHPNative\Ui;
use PHPNative\Framework\Profiler;
use PHPNative\Framework\TextRenderer; use PHPNative\Framework\TextRenderer;
class Window class Window
@ -22,6 +23,7 @@ class Window
private float $lastFpsUpdate = 0.0; private float $lastFpsUpdate = 0.0;
private int $frameCounter = 0; private int $frameCounter = 0;
private float $currentFps = 0.0; private float $currentFps = 0.0;
public function __construct( public function __construct(
private string $title, private string $title,
private int $width = 800, private int $width = 800,
@ -83,7 +85,6 @@ class Window
); );
$this->lastFpsUpdate = microtime(true); $this->lastFpsUpdate = microtime(true);
} }
public function setRoot(Component $component): self public function setRoot(Component $component): self
@ -219,8 +220,11 @@ class Window
break; break;
case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_MOTION:
$this->mouseX = $event['x'] ?? 0; $newMouseX = (float) ($event['x'] ?? 0);
$this->mouseY = $event['y'] ?? 0; $newMouseY = (float) ($event['y'] ?? 0);
$this->mouseX = $newMouseX;
$this->mouseY = $newMouseY;
// Check overlays first (in reverse z-index order - highest first) // Check overlays first (in reverse z-index order - highest first)
if ($this->rootComponent) { if ($this->rootComponent) {
@ -360,15 +364,26 @@ class Window
return; return;
} }
Profiler::start('window_render');
if ($this->shouldBeReLayouted) { if ($this->shouldBeReLayouted) {
Profiler::start('layout');
$this->layout(); $this->layout();
Profiler::end('layout');
} }
sdl_set_render_draw_color($this->renderer, 255, 255, 255, 255); sdl_set_render_draw_color($this->renderer, 255, 255, 255, 255);
sdl_render_clear($this->renderer); 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->render($this->renderer, $this->textRenderer);
$this->rootComponent->renderContent($this->renderer, $this->textRenderer); $this->rootComponent->renderContent($this->renderer, $this->textRenderer);
Profiler::end('render_tree');
$overlays = $this->rootComponent->collectOverlays(); $overlays = $this->rootComponent->collectOverlays();
@ -390,6 +405,11 @@ class Window
$this->updateFps(); $this->updateFps();
sdl_render_present($this->renderer); sdl_render_present($this->renderer);
Profiler::end('window_render');
// Report profiling data periodically
Profiler::report();
} }
/** /**
@ -439,7 +459,7 @@ class Window
$this->onResize = $callback; $this->onResize = $callback;
} }
public function setOnFpsChange(?callable $callback): void public function setOnFpsChange(null|callable $callback): void
{ {
$this->onFpsChange = $callback; $this->onFpsChange = $callback;
} }

36
test_scroll.php Normal file
View File

@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Scroll Test', 400, 300);
// Main container
$mainContainer = new Container('flex flex-col bg-white');
// Create a scrollable container with fixed height
$scrollContainer = new Container('flex flex-col w-full h-48 overflow-auto bg-gray-100 border-2 border-red-500');
// Add many labels to force scrolling
for ($i = 1; $i <= 30; $i++) {
$label = new Label("Item $i", 'px-4 py-2 border-b border-gray-300 text-black bg-white');
$scrollContainer->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();

44
test_table_scroll.php Normal file
View File

@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Table;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('Table Scroll Test', 800, 600);
// Simple container with limited height
$mainContainer = new Container('flex flex-col bg-gray-100 p-4');
// Table with max height
$table = new Table('h-96'); // 384px max height
$table->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();