From 312eb2c4bdffe8abce437678492404fda54942c1 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Mon, 17 Nov 2025 09:09:25 +0100 Subject: [PATCH] Backup --- .../ServerManager/Services/HetznerService.php | 2 + examples/ServerManager/UI/ServerListTab.php | 212 ++++++++-- examples/ServerManager/UI/SftpManagerTab.php | 365 ++++++++++++++---- examples/kanban_data.json | 13 +- src/Framework/TextRenderer.php | 3 +- src/Ui/Component.php | 47 +-- src/Ui/Widget/Container.php | 7 +- src/Ui/Widget/Icon.php | 6 +- src/Ui/Window.php | 90 +---- 9 files changed, 543 insertions(+), 202 deletions(-) diff --git a/examples/ServerManager/Services/HetznerService.php b/examples/ServerManager/Services/HetznerService.php index 2fca374..0089ab3 100644 --- a/examples/ServerManager/Services/HetznerService.php +++ b/examples/ServerManager/Services/HetznerService.php @@ -53,6 +53,8 @@ class HetznerService 'status' => ($i % 3) === 0 ? 'stopped' : 'running', 'type' => 'cx' . (11 + (($i % 4) * 10)), 'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255), + 'docker_status' => 'pending', + 'domains' => [], ]; } return $testData; diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php index 1804acb..647a588 100644 --- a/examples/ServerManager/UI/ServerListTab.php +++ b/examples/ServerManager/UI/ServerListTab.php @@ -2,6 +2,7 @@ namespace ServerManager\UI; +use PHPNative\Async\TaskManager; use PHPNative\Tailwind\Data\Icon as IconName; use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Container; @@ -30,6 +31,7 @@ class ServerListTab private Label $detailStatus; private Label $detailType; private Label $detailIpv4; + private Container $detailDomainsContainer; public function __construct( string &$apiKey, @@ -66,11 +68,12 @@ class ServerListTab // Table $this->table = new Table(style: ' flex-1'); $this->table->setColumns([ - ['key' => 'id', 'title' => 'ID', 'width' => 100], - ['key' => 'name', 'title' => 'Name', 'width' => 400], - ['key' => 'status', 'title' => 'Status', 'width' => 120], - ['key' => 'type', 'title' => 'Typ', 'width' => 120], - ['key' => 'ipv4', 'title' => 'IPv4'], + ['key' => 'id', 'title' => 'ID', 'width' => 80], + ['key' => 'name', 'title' => 'Name'], + ['key' => 'status', 'title' => 'Status', 'width' => 100], + ['key' => 'type', 'title' => 'Typ', 'width' => 80], + ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 160], + ['key' => 'docker_status', 'title' => 'Docker', 'width' => 100], ]); // Load initial test data @@ -101,6 +104,11 @@ class ServerListTab $detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent($this->detailIpv4); + // Domains list + $detailPanel->addComponent(new Label('Domains:', 'text-xs text-gray-500 mt-2')); + $this->detailDomainsContainer = new Container('flex flex-col gap-1'); + $detailPanel->addComponent($this->detailDomainsContainer); + // SFTP Manager Button (handler will be set by SftpManagerTab) $this->sftpButton = new Button( 'SFTP Manager öffnen', @@ -184,6 +192,9 @@ class ServerListTab $serverListTab->detailType->setText($row['type']); $serverListTab->detailIpv4->setText($row['ipv4']); + $domains = $row['domains'] ?? []; + $serverListTab->updateDomainDetails(is_array($domains) ? $domains : []); + $serverListTab->selectedServer = $row; } }); @@ -196,13 +207,18 @@ class ServerListTab $serverListTab->table->setData($serverListTab->currentServerData); } else { $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ($searchTerm) { - return str_contains(strtolower($row['name']), $searchTerm); + return ( + str_contains(strtolower($row['name']), $searchTerm) || + count(array_filter($row['domains'], function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ); }); $serverListTab->table->setData(array_values($filteredData)); } }); - // Refresh button - use reference to apiKey variable + // Refresh button - use reference to apiKey & privateKey variable $this->refreshButton->setOnClickAsync( function () use (&$currentApiKey) { try { @@ -220,6 +236,8 @@ class ServerListTab 'status' => $server->status, 'type' => $server->serverType->name, 'ipv4' => $server->publicNet->ipv4->ip, + 'docker_status' => 'pending', + 'domains' => [], ]; } @@ -232,32 +250,147 @@ class ServerListTab return ['error' => 'Exception: ' . $e->getMessage()]; } }, - function ($result) use ($serverListTab) { + function ($result) use (&$serverListTab, &$currentPrivateKeyPath) { if (is_array($result)) { if (isset($result['error'])) { $serverListTab->statusLabel->setText('Fehler: ' . $result['error']); echo "Error: {$result['error']}\n"; } elseif (isset($result['success'], $result['servers'])) { - $serverListTab->currentServerData = $result['servers']; - - $searchTerm = strtolower(trim($serverListTab->searchInput->getValue())); - if (empty($searchTerm)) { - $serverListTab->table->setData($serverListTab->currentServerData); - } else { - $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( - $searchTerm, - ) { - return str_contains(strtolower($row['name']), $searchTerm); - }); - $serverListTab->table->setData(array_values($filteredData)); - } + // Basisdaten setzen, Docker-Status initial auf "pending" + $serverListTab->currentServerData = array_map(static fn($row) => array_merge([ + 'docker_status' => 'pending', + 'docker' => null, + 'docker_error' => null, + ], $row), $result['servers']); + $serverListTab->table->setData($serverListTab->currentServerData); $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); echo "Success: {$result['count']} servers loaded\n"; + + // Danach: pro Server asynchron Docker-Infos nachladen + foreach ($serverListTab->currentServerData as $index => $row) { + $ip = $row['ipv4'] ?? ''; + + if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + $serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP'; + $serverListTab->currentServerData[$index]['docker_status'] = 'error'; + continue; + } + + $task = TaskManager::getInstance()->runAsync(function () use ( + $ip, + $currentPrivateKeyPath, + $index, + ) { + try { + $ssh = new \phpseclib3\Net\SSH2($ip); + $key = \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey(file_get_contents( + $currentPrivateKeyPath, + )); + + if (!$ssh->login('root', $key)) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'SSH Login fehlgeschlagen', + 'docker_status' => 'error', + ]; + } + + $output = $ssh->exec('docker inspect psc-web-1'); + + if (empty($output)) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'Leere Docker-Antwort', + 'docker_status' => 'error', + ]; + } + + $json = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'Ungültige Docker-JSON-Antwort', + 'docker_status' => 'error', + ]; + } + + return [ + 'index' => $index, + 'docker' => $json, + 'docker_error' => null, + 'docker_status' => 'ok', + ]; + } catch (\Throwable $e) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'SSH-Fehler: ' . $e->getMessage(), + 'docker_status' => 'error', + ]; + } + }); + + $task->onComplete(function ($dockerResult) use (&$serverListTab) { + if (!is_array($dockerResult) || !isset($dockerResult['index'])) { + return; + } + + $i = $dockerResult['index']; + if (!isset($serverListTab->currentServerData[$i])) { + return; + } + + if (array_key_exists('docker', $dockerResult)) { + if (isset($dockerResult['docker'][0]['Config']['Env'])) { + $hosts = array_filter( + $dockerResult['docker'][0]['Config']['Env'], + function ($item) { + if (str_starts_with($item, 'LETSENCRYPT_HOST')) { + return true; + } + return false; + }, + ); + $domains = explode( + ',', + substr(array_first($hosts), strlen('LETSENCRYPT_HOST=')), + ); + + $serverListTab->currentServerData[$i]['domains'] = $domains; + } + $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; + $serverListTab->table->setData($serverListTab->currentServerData); + } + + if (array_key_exists('docker_error', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_error'] = + $dockerResult['docker_error']; + } + + if (array_key_exists('docker_status', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_status'] = + $dockerResult['docker_status']; + } + }); + + $task->onError(function ($error) use ($serverListTab, $index) { + $errorMsg = is_object($error) && method_exists($error, 'getMessage') + ? $error->getMessage() + : ((string) $error); + if (isset($serverListTab->currentServerData[$index])) { + $serverListTab->currentServerData[$index]['docker_error'] = + 'Async Fehler: ' . $errorMsg; + } + }); + } } } }, - function ($error) use ($serverListTab) { + function ($error) use (&$serverListTab) { $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : ((string) $error); @@ -276,4 +409,39 @@ class ServerListTab { return $this->sftpButton; } + + private function updateDomainDetails(array $domains): void + { + $this->detailDomainsContainer->clearChildren(); + + if (empty($domains)) { + $this->detailDomainsContainer->addComponent(new Label( + 'Keine Domains gefunden', + 'text-xs text-gray-500 italic', + )); + return; + } + + foreach ($domains as $domain) { + if (!is_string($domain) || trim($domain) === '') { + continue; + } + + $domain = trim($domain); + $button = new Button($domain, 'text-sm text-blue-600 hover:text-blue-800 underline text-left'); + + $button->setOnClick(function () use ($domain) { + $url = $domain; + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://' . $url; + } + + // Versuche, die Domain im Standardbrowser zu öffnen (Linux-Umgebung) + $escapedUrl = escapeshellarg($url); + exec("xdg-open {$escapedUrl} > /dev/null 2>&1 &"); + }); + + $this->detailDomainsContainer->addComponent($button); + } + } } diff --git a/examples/ServerManager/UI/SftpManagerTab.php b/examples/ServerManager/UI/SftpManagerTab.php index 1e40f26..1d43526 100644 --- a/examples/ServerManager/UI/SftpManagerTab.php +++ b/examples/ServerManager/UI/SftpManagerTab.php @@ -29,6 +29,8 @@ class SftpManagerTab private Modal $deleteConfirmModal; private string $currentDeleteFilePath = ''; private Label $deleteConfirmLabel; + private null|array $currentLocalSelection = null; + private null|array $currentRemoteSelection = null; public function __construct( string &$apiKey, @@ -52,6 +54,15 @@ class SftpManagerTab ); $localBrowserContainer->addComponent($this->localFileBrowser); + // Track local file selection for uploads + $sftpTab = $this; + $this->localFileBrowser->setOnFileSelect(function ($path, $row) use ($sftpTab) { + $sftpTab->currentLocalSelection = [ + 'path' => $path, + 'row' => $row, + ]; + }); + // Right side: Remote file browser $remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); @@ -72,7 +83,22 @@ class SftpManagerTab $this->connectionStatusLabel = new Label('Nicht verbunden', 'text-sm text-gray-600 italic mb-2'); $remoteBrowserContainer->addComponent($this->connectionStatusLabel); + // Middle: Transfer buttons (Upload/Download) + $transferContainer = new Container('flex flex-col justify-center items-center gap-2'); + + $uploadButton = new Button( + 'Hochladen →', + 'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', + ); + $downloadButton = new Button( + '← Herunterladen', + 'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', + ); + $transferContainer->addComponent($uploadButton); + $transferContainer->addComponent($downloadButton); + $this->tab->addComponent($localBrowserContainer); + $this->tab->addComponent($transferContainer); $this->tab->addComponent($remoteBrowserContainer); // Setup remote navigation handler @@ -93,6 +119,15 @@ class SftpManagerTab // Create delete confirmation modal $this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + // Setup transfer button handlers + $uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $sftpTab->handleUpload($currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + + $downloadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $sftpTab->handleDownload($currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + // Setup file edit handler $this->remoteFileBrowser->setOnEditFile(function ($path, $row) use ( &$currentPrivateKeyPath, @@ -138,79 +173,88 @@ class SftpManagerTab // Create reference to selectedServer array outside of closure $selectedServerRef = &$serverListTab->selectedServer; - $this->remoteFileBrowser->setOnFileSelect(function ($path, $row) use ( - $sftpTab, - &$currentPrivateKeyPath, - &$selectedServerRef, - $statusLabel, - ) { - if (!isset($row['isDir']) || !$row['isDir']) { - return; - } + $this->remoteFileBrowser->setOnFileSelect( + function ($path, $row) use ( + $sftpTab, + &$currentPrivateKeyPath, + &$selectedServerRef, + $statusLabel, + ) { + // Track remote selection (for downloads) + $sftpTab->currentRemoteSelection = [ + 'path' => $path, + 'row' => $row, + ]; - $loadButton = new Button('Load', ''); - $loadButton->setOnClickAsync( - function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { - if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { - return ['error' => 'Not connected']; - } + // Only navigate when a directory is selected + if (!isset($row['isDir']) || !$row['isDir']) { + return; + } - // Copy to local variable for async context - $selectedServer = $selectedServerRef; - - try { - $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); - $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); - - if (!$sftp->login('root', $key)) { - return ['error' => 'SFTP Login failed']; + $loadButton = new Button('Load', ''); + $loadButton->setOnClickAsync( + function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { + if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { + return ['error' => 'Not connected']; } - $files = $sftp->nlist($path); - if ($files === false) { - return ['error' => 'Cannot read directory']; - } + // Copy to local variable for async context + $selectedServer = $selectedServerRef; - $fileList = []; - foreach ($files as $file) { - if ($file === '.' || $file === '..') { - continue; + try { + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login failed']; } - $fullPath = rtrim($path, '/') . '/' . $file; - $stat = $sftp->stat($fullPath); - $fileList[] = [ - 'name' => $file, - 'path' => $fullPath, - 'isDir' => ($stat['type'] ?? 0) === 2, - 'size' => $stat['size'] ?? 0, - 'mtime' => $stat['mtime'] ?? 0, - ]; + + $files = $sftp->nlist($path); + if ($files === false) { + return ['error' => 'Cannot read directory']; + } + + $fileList = []; + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $fullPath = rtrim($path, '/') . '/' . $file; + $stat = $sftp->stat($fullPath); + $fileList[] = [ + 'name' => $file, + 'path' => $fullPath, + 'isDir' => ($stat['type'] ?? 0) === 2, + 'size' => $stat['size'] ?? 0, + 'mtime' => $stat['mtime'] ?? 0, + ]; + } + + return ['success' => true, 'path' => $path, 'files' => $fileList]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel) { + if (isset($result['error'])) { + $statusLabel->setText('SFTP Fehler: ' . $result['error']); + return; } - return ['success' => true, 'path' => $path, 'files' => $fileList]; - } catch (\Exception $e) { - return ['error' => $e->getMessage()]; - } - }, - function ($result) use ($sftpTab, $statusLabel) { - if (isset($result['error'])) { - $statusLabel->setText('SFTP Fehler: ' . $result['error']); - return; - } + if (isset($result['success'])) { + $sftpTab->remoteFileBrowser->setPath($result['path']); + $sftpTab->remoteFileBrowser->setFileData($result['files']); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('SFTP Error: ' . $errorMsg); + }, + ); - if (isset($result['success'])) { - $sftpTab->remoteFileBrowser->setPath($result['path']); - $sftpTab->remoteFileBrowser->setFileData($result['files']); - } - }, - function ($error) use ($statusLabel) { - $errorMsg = is_string($error) ? $error : 'Unknown error'; - $statusLabel->setText('SFTP Error: ' . $errorMsg); - }, - ); - - $loadButton->handleMouseClick(0, 0, 0); - }); + $loadButton->handleMouseClick(0, 0, 0); + }, + ); } private function setupSftpConnectionHandler( @@ -689,6 +733,198 @@ class SftpManagerTab $loadButton->handleMouseClick(0, 0, 0); } + private function handleUpload( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($this->currentLocalSelection === null) { + $statusLabel->setText('Keine lokale Datei ausgewählt'); + return; + } + + $localRow = $this->currentLocalSelection['row'] ?? null; + $localPath = $this->currentLocalSelection['path'] ?? null; + + if ($localRow === null || $localPath === null) { + $statusLabel->setText('Ungültige lokale Auswahl'); + return; + } + + if (($localRow['isDir'] ?? false) === true) { + $statusLabel->setText('Ordner-Upload wird noch nicht unterstützt'); + return; + } + + if (!is_file($localPath) || !is_readable($localPath)) { + $statusLabel->setText('Lokale Datei ist nicht lesbar'); + return; + } + + if ($serverListTab->selectedServer === null) { + $statusLabel->setText('Kein Server ausgewählt'); + return; + } + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + $statusLabel->setText('Private Key Pfad nicht konfiguriert oder Datei nicht gefunden'); + return; + } + + $remoteDir = $this->remoteFileBrowser->getCurrentPath(); + if ($remoteDir === '') { + $remoteDir = '/'; + } + + $remotePath = rtrim($remoteDir, '/') . '/' . basename($localPath); + + // Use reference to selected server for async operation + $selectedServerRef = &$serverListTab->selectedServer; + $sftpTab = $this; + + $uploadAsyncButton = new Button('Upload', ''); + $uploadAsyncButton->setOnClickAsync( + function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) { + if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { + return ['error' => 'Not connected']; + } + + $selectedServer = $selectedServerRef; + + try { + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login failed']; + } + + $result = $sftp->put($remotePath, $localPath, \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE); + if ($result === false) { + return ['error' => 'Upload fehlgeschlagen']; + } + + return ['success' => true]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) { + if (isset($result['error'])) { + $statusLabel->setText('Fehler beim Hochladen: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $statusLabel->setText('Datei erfolgreich hochgeladen'); + $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler beim Hochladen: ' . $errorMsg); + }, + ); + + $uploadAsyncButton->handleMouseClick(0, 0, 0); + } + + private function handleDownload( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($this->currentRemoteSelection === null) { + $statusLabel->setText('Keine Remote-Datei ausgewählt'); + return; + } + + $remoteRow = $this->currentRemoteSelection['row'] ?? null; + $remotePath = $this->currentRemoteSelection['path'] ?? null; + + if ($remoteRow === null || $remotePath === null) { + $statusLabel->setText('Ungültige Remote-Auswahl'); + return; + } + + if (($remoteRow['isDir'] ?? false) === true) { + $statusLabel->setText('Ordner-Download wird noch nicht unterstützt'); + return; + } + + if ($serverListTab->selectedServer === null) { + $statusLabel->setText('Kein Server ausgewählt'); + return; + } + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + $statusLabel->setText('Private Key Pfad nicht konfiguriert oder Datei nicht gefunden'); + return; + } + + $localDir = $this->localFileBrowser->getCurrentPath(); + if ($localDir === '') { + $localDir = getcwd(); + } + + if (!is_dir($localDir) || !is_writable($localDir)) { + $statusLabel->setText('Lokales Verzeichnis ist nicht beschreibbar'); + return; + } + + $localPath = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath); + + // Use reference to selected server for async operation + $selectedServerRef = &$serverListTab->selectedServer; + $sftpTab = $this; + + $downloadAsyncButton = new Button('Download', ''); + $downloadAsyncButton->setOnClickAsync( + function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $localPath) { + if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { + return ['error' => 'Not connected']; + } + + $selectedServer = $selectedServerRef; + + try { + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login failed']; + } + + $result = $sftp->get($remotePath, $localPath); + if ($result === false) { + return ['error' => 'Download fehlgeschlagen']; + } + + return ['success' => true]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel, $localDir) { + if (isset($result['error'])) { + $statusLabel->setText('Fehler beim Herunterladen: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $statusLabel->setText('Datei erfolgreich heruntergeladen'); + $sftpTab->localFileBrowser->loadDirectory($localDir); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler beim Herunterladen: ' . $errorMsg); + }, + ); + + $downloadAsyncButton->handleMouseClick(0, 0, 0); + } + private function createRenameModal( string &$currentPrivateKeyPath, ServerListTab $serverListTab, @@ -922,4 +1158,3 @@ class SftpManagerTab $this->deleteConfirmModal->setVisible(true); } } - diff --git a/examples/kanban_data.json b/examples/kanban_data.json index f4608a1..e0958fe 100644 --- a/examples/kanban_data.json +++ b/examples/kanban_data.json @@ -2,13 +2,7 @@ { "id": "board_691661d89de624.46726087", "title": "Backlog", - "tasks": [ - { - "id": "task_691661d89de735.41071535", - "title": "Idee sammeln", - "note": "" - } - ] + "tasks": [] }, { "id": "board_691661d89de806.79123800", @@ -18,6 +12,11 @@ "id": "task_691661d89de858.46479539", "title": "API anbinden", "note": "" + }, + { + "id": "task_691661d89de735.41071535", + "title": "Idee sammeln", + "note": "" } ] }, diff --git a/src/Framework/TextRenderer.php b/src/Framework/TextRenderer.php index 4948916..8e234c2 100644 --- a/src/Framework/TextRenderer.php +++ b/src/Framework/TextRenderer.php @@ -75,7 +75,7 @@ class TextRenderer private function loadFont(int $size): mixed { - $size = max(1, (int) round($size)); + $size = max(1, (int) round($size)) * $this->pixelRatio; if (isset($this->fonts[$size])) { return $this->fonts[$size]; @@ -199,6 +199,7 @@ class TextRenderer 'w' => $textSize['w'], 'h' => $textSize['h'], ]); + // Note: Texture and surface are automatically cleaned up by PHP resource destructors } diff --git a/src/Ui/Component.php b/src/Ui/Component.php index 6e53cb4..8a9fdeb 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -344,19 +344,6 @@ abstract class Component } } - /** - * Optional: handle drag (mouse move with pressed button). - * Default: propagate to children. - */ - public function handleDrag(float $mouseX, float $mouseY): void - { - foreach ($this->children as $child) { - if (method_exists($child, 'handleDrag')) { - $child->handleDrag($mouseX, $mouseY); - } - } - } - public function layout(null|TextRenderer $textRenderer = null): void { $this->normalStylesCached = StyleParser::parse($this->style)->getValidStyles( @@ -415,11 +402,12 @@ abstract class Component Profiler::increment('texture_cache_hit'); Profiler::start('render_cached'); // Render cached texture + sdl_render_texture($renderer, $texture, [ - 'x' => $this->viewport->x * $this->viewport->uiScale, - 'y' => $this->viewport->y * $this->viewport->uiScale, - 'w' => $this->viewport->width * $this->viewport->uiScale, - 'h' => $this->viewport->height * $this->viewport->uiScale, + 'x' => $this->viewport->x, + 'y' => $this->viewport->y, + 'w' => $this->viewport->width, + 'h' => $this->viewport->height, ]); Profiler::end('render_cached'); Profiler::end('render'); @@ -458,12 +446,13 @@ abstract class Component // SDL3: sdl_rounded_box_ex uses (x1, y1, x2, y2) instead of (x, y, w, h) $x2 = $this->viewport->x + $this->viewport->width; $y2 = $this->viewport->y + $this->viewport->height; + sdl_rounded_box_ex( $renderer, - ((int) $this->viewport->x) * $this->viewport->uiScale, - ((int) $this->viewport->y) * $this->viewport->uiScale, - ((int) $x2) * $this->viewport->uiScale, - ((int) $y2) * $this->viewport->uiScale, + (int) $this->viewport->x, + (int) $this->viewport->y, + (int) $x2, + (int) $y2, $border->roundTopLeft ?? 0, $border->roundTopRight ?? 0, $border->roundBottomRight ?? 0, @@ -475,20 +464,20 @@ abstract class Component ); } else { sdl_render_fill_rect($renderer, [ - 'x' => $this->viewport->x * $this->viewport->uiScale, - 'y' => $this->viewport->y * $this->viewport->uiScale, - 'w' => $this->viewport->width * $this->viewport->uiScale, - 'h' => $this->viewport->height * $this->viewport->uiScale, + 'x' => $this->viewport->x, + 'y' => $this->viewport->y, + 'w' => $this->viewport->width, + 'h' => $this->viewport->height, ]); } } if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) { sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10); sdl_render_rect($renderer, [ - 'x' => $this->viewport->x * $this->viewport->uiScale, - 'y' => $this->viewport->y * $this->viewport->uiScale, - 'w' => $this->viewport->width * $this->viewport->uiScale, - 'h' => $this->viewport->height * $this->viewport->uiScale, + 'x' => $this->viewport->x, + 'y' => $this->viewport->y, + 'w' => $this->viewport->width, + 'h' => $this->viewport->height, ]); } diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index 7ac70cc..a3b6b3f 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -116,6 +116,7 @@ class Container extends Component height: $this->contentViewport->height, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, ); $child->setViewport($childViewport); @@ -138,6 +139,7 @@ class Container extends Component height: $this->contentViewport->windowHeight, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, ); $child->setViewport($overlayViewport); $child->setContentViewport(clone $overlayViewport); @@ -272,6 +274,7 @@ class Container extends Component height: $isRow ? $this->contentViewport->height : 9999, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, ); $child->setViewport($tempViewport); $child->layout($textRenderer); @@ -329,6 +332,7 @@ class Container extends Component height: $childHeight, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, ); $currentPosition += $size; } else { @@ -344,6 +348,7 @@ class Container extends Component height: $size, windowWidth: $this->contentViewport->windowWidth, windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, ); $currentPosition += $size; } @@ -357,7 +362,7 @@ class Container extends Component private function calculateSize(Width|Height|Basis $style, float $availableSpace): float { return match ($style->unit) { - Unit::Pixel => (float) $style->value, + Unit::Pixel => ((float) $style->value) * $this->contentViewport->uiScale, Unit::Point => (float) $style->value, Unit::Percent => ($availableSpace * $style->value) / 100, }; diff --git a/src/Ui/Widget/Icon.php b/src/Ui/Widget/Icon.php index dc8d4a2..bf859df 100644 --- a/src/Ui/Widget/Icon.php +++ b/src/Ui/Widget/Icon.php @@ -75,8 +75,8 @@ class Icon extends Component if ($font) { $dimensions = ttf_size_text($font, $this->glyph); if ($dimensions !== false) { - $width = (int) $dimensions['w']; - $height = (int) $dimensions['h']; + $width = ((int) $dimensions['w']) * $this->viewport->uiScale; + $height = ((int) $dimensions['h']) * $this->viewport->uiScale; } } @@ -130,7 +130,7 @@ class Icon extends Component { $this->clearTexture(); - $font = IconFontRegistry::getFont($this->size, $this->fontPath); + $font = IconFontRegistry::getFont($this->size * $this->viewport->uiScale, $this->fontPath); if (!$font) { $this->renderDirty = false; return; diff --git a/src/Ui/Window.php b/src/Ui/Window.php index bd9253e..1aa735b 100644 --- a/src/Ui/Window.php +++ b/src/Ui/Window.php @@ -16,7 +16,6 @@ class Window private float $mouseY = 0; private Viewport $viewport; private bool $shouldBeReLayouted = true; - private float $pixelRatio = 1.0; private float $uiScale = 1.0; private bool $shouldClose = false; private $onResize = null; @@ -24,6 +23,7 @@ class Window private float $lastFpsUpdate = 0.0; private int $frameCounter = 0; private float $currentFps = 0.0; + private bool $leftButtonDown = false; public function __construct( private string $title, @@ -58,11 +58,10 @@ class Window // Get window ID for event routing $this->windowId = sdl_get_window_id($this->window); // Use display scale as UI scale (e.g. 2.0 on HiDPI) - if (function_exists('sdl_get_window_display_scale')) { - $scale = sdl_get_window_display_scale($this->window); - if ($scale > 0.1 && $scale <= 4.0) { - $this->uiScale = (float) $scale; - } + $scale = sdl_get_window_display_scale($this->window); + + if ($scale > 0.1 && $scale <= 4.0) { + $this->uiScale = (float) $scale; } // Enable text input for this window sdl_start_text_input($this->window); @@ -78,77 +77,21 @@ class Window if (!$this->textRenderer->init()) { error_log('Warning: Failed to initialize text renderer. Text rendering will not be available.'); } - + $this->textRenderer->setPixelRatio($this->uiScale); // Get actual window size $size = sdl_get_window_size($this->window); $this->width = $size[0]; $this->height = $size[1]; $this->viewport = new Viewport( - windowWidth: $this->width, - windowHeight: $this->height, - width: $this->width, - height: $this->height, + windowWidth: $this->width * $this->uiScale, + windowHeight: $this->height * $this->uiScale, + width: $this->width * $this->uiScale, + height: $this->height * $this->uiScale, uiScale: $this->uiScale, ); - $this->updatePixelRatio(); - $this->lastFpsUpdate = microtime(true); } - private function updatePixelRatio(): void - { - $this->pixelRatio = 1.0; - - // HiDPI‑Scaling ist optional und wird nur aktiviert, - // wenn die Umgebungsvariable PHPNATIVE_ENABLE_HIDPI=1 gesetzt ist. - $enableHiDpi = getenv('PHPNATIVE_ENABLE_HIDPI'); - if ($enableHiDpi !== '1') { - if ($this->textRenderer) { - $this->textRenderer->setPixelRatio($this->pixelRatio); - } - return; - } - - if (!function_exists('sdl_get_window_size') || !$this->window) { - return; - } - - $windowSize = sdl_get_window_size($this->window); - $pixelSize = null; - - if (function_exists('sdl_get_window_size_in_pixels')) { - $pixelSize = sdl_get_window_size_in_pixels($this->window); - } - - if ((!is_array($pixelSize) || count($pixelSize) < 2) && function_exists('sdl_get_renderer_output_size')) { - $pixelSize = sdl_get_renderer_output_size($this->renderer); - } - - if (is_array($windowSize) && is_array($pixelSize)) { - $logicalWidth = max(1, (int) ($windowSize[0] ?? 1)); - $logicalHeight = max(1, (int) ($windowSize[1] ?? 1)); - $pixelWidth = max(1, (int) ($pixelSize[0] ?? 1)); - $pixelHeight = max(1, (int) ($pixelSize[1] ?? 1)); - - $ratioX = $pixelWidth / $logicalWidth; - $ratioY = $pixelHeight / $logicalHeight; - - $computed = max($ratioX, $ratioY); - if ($computed > 0) { - $this->pixelRatio = max(1.0, $computed); - } - } - - if ($this->textRenderer) { - $this->textRenderer->setPixelRatio($this->pixelRatio); - } - } - - public function getPixelRatio(): float - { - return $this->pixelRatio; - } - public function getUiScale(): float { return $this->uiScale; @@ -277,13 +220,12 @@ class Window if ($this->textRenderer && $this->textRenderer->isInitialized()) { $this->textRenderer->updateFramebuffer($newWidth, $newHeight); } - $this->updatePixelRatio(); $this->viewport->x = 0; $this->viewport->y = 0; - $this->viewport->windowWidth = $newWidth; - $this->viewport->width = $newWidth; - $this->viewport->height = $newHeight; - $this->viewport->windowHeight = $newHeight; + $this->viewport->windowWidth = $newWidth * $this->viewport->uiScale; + $this->viewport->width = $newWidth * $this->viewport->uiScale; + $this->viewport->height = $newHeight * $this->viewport->uiScale; + $this->viewport->windowHeight = $newHeight * $this->viewport->uiScale; $this->shouldBeReLayouted = true; if ($this->onResize) { ($this->onResize)($this); @@ -292,8 +234,8 @@ class Window case SDL_EVENT_MOUSE_MOTION: // Convert physical pixels to logical coordinates using uiScale - $newMouseX = (float) ($event['x'] ?? 0) / $this->uiScale; - $newMouseY = (float) ($event['y'] ?? 0) / $this->uiScale; + $newMouseX = ((float) ($event['x'] ?? 0)) * $this->uiScale; + $newMouseY = ((float) ($event['y'] ?? 0)) * $this->uiScale; $this->mouseX = $newMouseX; $this->mouseY = $newMouseY;