FTP Manager
This commit is contained in:
parent
6d969944dc
commit
402ad74582
@ -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
29
examples/test_simple.php
Normal 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();
|
||||||
@ -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.
@ -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.
@ -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, ¢er);
|
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, ¢erRect);
|
||||||
}
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
7
src/Async/bootstrap.php
Normal 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';
|
||||||
@ -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;
|
||||||
|
|||||||
68
src/Framework/HetznerClient.php
Normal file
68
src/Framework/HetznerClient.php
Normal 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
137
src/Framework/Profiler.php
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
273
src/Ui/Widget/FileBrowser.php
Normal file
273
src/Ui/Widget/FileBrowser.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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
36
test_scroll.php
Normal 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
44
test_table_scroll.php
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user