From d4a926ddad8eb9a77dfe8178adcaba9c56239332 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Mon, 1 Dec 2025 11:06:53 +0100 Subject: [PATCH] Backup --- examples/ServerManager/UI/SftpManagerTab.php | 461 +++++++++++++++++-- src/Ui/Widget/FileBrowser.php | 44 +- src/Ui/Widget/ProgressBar.php | 84 ++++ src/Ui/Widget/VirtualListView.php | 351 ++++++++++++++ 4 files changed, 891 insertions(+), 49 deletions(-) create mode 100644 src/Ui/Widget/ProgressBar.php create mode 100644 src/Ui/Widget/VirtualListView.php diff --git a/examples/ServerManager/UI/SftpManagerTab.php b/examples/ServerManager/UI/SftpManagerTab.php index df5d560..261629a 100644 --- a/examples/ServerManager/UI/SftpManagerTab.php +++ b/examples/ServerManager/UI/SftpManagerTab.php @@ -10,6 +10,7 @@ use PHPNative\Ui\Widget\Label; use PHPNative\Ui\Widget\Modal; use PHPNative\Ui\Widget\TextArea; use PHPNative\Ui\Widget\TextInput; +use PHPNative\Ui\Widget\ProgressBar; class SftpManagerTab { @@ -33,6 +34,14 @@ class SftpManagerTab private null|array $currentRemoteSelection = null; private ?string $lastRemoteClickPath = null; private float $lastRemoteClickTime = 0.0; + private ProgressBar $transferProgressBar; + private Label $transferInfoLabel; + private array $pendingUploadQueue = []; + private int $totalUploadFiles = 0; + private int $completedUploadFiles = 0; + private array $pendingDownloadQueue = []; + private int $totalDownloadFiles = 0; + private int $completedDownloadFiles = 0; public function __construct( string &$apiKey, @@ -88,19 +97,26 @@ class SftpManagerTab $remoteBrowserContainer->addComponent($this->connectionStatusLabel); // Middle: Transfer buttons (Upload/Download) - $transferContainer = new Container('flex flex-col justify-center items-center gap-2'); + $transferContainer = new Container('flex flex-col justify-center items-center gap-2 w-[180]'); $uploadButton = new Button( 'Hochladen →', - 'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', + 'w-full px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700', ); $downloadButton = new Button( '← Herunterladen', - 'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', + 'w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', ); $transferContainer->addComponent($uploadButton); $transferContainer->addComponent($downloadButton); + // Transfer-Info + ProgressBar + $this->transferInfoLabel = new Label('Kein Transfer aktiv', 'text-xs text-gray-600 mt-2'); + $this->transferProgressBar = new ProgressBar('mt-1'); + $this->transferProgressBar->setValue(0.0); + $transferContainer->addComponent($this->transferInfoLabel); + $transferContainer->addComponent($this->transferProgressBar); + $this->tab->addComponent($localBrowserContainer); $this->tab->addComponent($transferContainer); $this->tab->addComponent($remoteBrowserContainer); @@ -776,16 +792,6 @@ class SftpManagerTab 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; @@ -801,13 +807,177 @@ class SftpManagerTab $remoteDir = '/'; } - $remotePath = rtrim($remoteDir, '/') . '/' . basename($localPath); + // Upload-Größe vorab bestimmen + $stats = $this->calculateLocalUploadStats($localPath, $localRow); + $totalBytes = $stats['bytes']; + $fileCount = $stats['files']; + $dirCount = $stats['dirs']; + + $this->transferProgressBar->setValue(0.0); + $this->transferInfoLabel->setText( + sprintf( + 'Upload: %d Dateien, %d Ordner (%.2f MB)', + $fileCount, + $dirCount, + $totalBytes > 0 ? ($totalBytes / (1024 * 1024)) : 0, + ), + ); + + // Upload-Queue für virtuelle ProgressBar vorbereiten + $this->pendingUploadQueue = $this->buildUploadQueue($localPath, $localRow, $remoteDir); + $this->totalUploadFiles = count($this->pendingUploadQueue); + $this->completedUploadFiles = 0; + + if ($this->totalUploadFiles <= 0) { + $this->transferProgressBar->setValue(1.0); + $this->transferInfoLabel->setText('Keine Dateien zum Hochladen'); + return; + } + + // Referenz auf ausgewählten Server für spätere Reloads + $this->startNextUploadTask($currentPrivateKeyPath, $serverListTab, $statusLabel); + } + + /** + * Lokale Upload-Statistik (Dateien/Ordner/Bytes) rekursiv berechnen. + */ + private function calculateLocalUploadStats(string $path, array $row): array + { + $files = 0; + $dirs = 0; + $bytes = 0; + + $isDir = (bool) ($row['isDir'] ?? false); + + if (!$isDir) { + if (is_file($path) && is_readable($path)) { + $files = 1; + $bytes = (int) @filesize($path); + } + return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes]; + } + + // Ordner: rekursiv zählen + $dirs++; + + $iterator = @scandir($path); + if ($iterator === false) { + return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes]; + } + + foreach ($iterator as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $childPath = $path . DIRECTORY_SEPARATOR . $entry; + + if (is_dir($childPath)) { + $childStats = $this->calculateLocalUploadStats($childPath, ['isDir' => true]); + $files += $childStats['files']; + $dirs += $childStats['dirs']; + $bytes += $childStats['bytes']; + } elseif (is_file($childPath) && is_readable($childPath)) { + $files++; + $bytes += (int) @filesize($childPath); + } + } + + return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes]; + } + + /** + * Queue für Datei-Uploads aufbauen (Datei- oder Ordner-Auswahl). + * + * @return array + */ + private function buildUploadQueue(string $localPath, array $row, string $remoteDir): array + { + $queue = []; + $remoteBase = rtrim($remoteDir, '/'); + if ($remoteBase === '') { + $remoteBase = '/'; + } + + $isDir = (bool) ($row['isDir'] ?? false); + if (!$isDir) { + $queue[] = [ + 'local' => $localPath, + 'remote' => $remoteBase . '/' . basename($localPath), + ]; + return $queue; + } + + // Wurzelordner auf Remote + $rootRemote = $remoteBase . '/' . basename($localPath); + $this->collectUploadFilesRecursive($localPath, $rootRemote, $queue); + + return $queue; + } + + /** + * Rekursive Sammlung aller Dateien eines Ordners. + * + * @param array $queue + */ + private function collectUploadFilesRecursive(string $localDir, string $remoteDir, array &$queue): void + { + $entries = @scandir($localDir); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $localChild = $localDir . DIRECTORY_SEPARATOR . $entry; + $remoteChild = rtrim($remoteDir, '/') . '/' . $entry; + + if (is_dir($localChild)) { + $this->collectUploadFilesRecursive($localChild, $remoteChild, $queue); + } elseif (is_file($localChild) && is_readable($localChild)) { + $queue[] = [ + 'local' => $localChild, + 'remote' => $remoteChild, + ]; + } + } + } + + /** + * Nächste Datei aus der Upload-Queue hochladen und ProgressBar aktualisieren. + */ + private function startNextUploadTask( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($this->totalUploadFiles <= 0) { + $this->transferProgressBar->setValue(1.0); + $this->transferInfoLabel->setText('Keine Dateien zum Hochladen'); + return; + } + + if (empty($this->pendingUploadQueue)) { + // Alle Dateien hochgeladen + $this->transferProgressBar->setValue(1.0); + $this->transferInfoLabel->setText('Upload abgeschlossen'); + + $selectedServerRef = &$serverListTab->selectedServer; + $this->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + return; + } + + $item = array_shift($this->pendingUploadQueue); + $localPath = $item['local']; + $remotePath = $item['remote']; - // Use reference to selected server for async operation $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; - $uploadAsyncButton = new Button('Upload', ''); + $uploadAsyncButton = new Button('UploadChunk', ''); $uploadAsyncButton->setOnClickAsync( function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) { if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { @@ -824,7 +994,23 @@ class SftpManagerTab return ['error' => 'SFTP Login failed']; } - $result = $sftp->put($remotePath, $localPath, \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE); + // Zielverzeichnis sicherstellen + $remoteDir = dirname($remotePath); + if (!$sftp->is_dir($remoteDir)) { + if (!$sftp->mkdir($remoteDir, -1, true)) { + return ['error' => 'Kann Remote-Verzeichnis nicht anlegen']; + } + } + + if (!is_file($localPath) || !is_readable($localPath)) { + return ['error' => 'Lokale Datei ist nicht lesbar']; + } + + $result = $sftp->put( + $remotePath, + $localPath, + \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE, + ); if ($result === false) { return ['error' => 'Upload fehlgeschlagen']; } @@ -834,19 +1020,34 @@ class SftpManagerTab return ['error' => $e->getMessage()]; } }, - function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) { + function ($result) use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { if (isset($result['error'])) { + $sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $result['error']); $statusLabel->setText('Fehler beim Hochladen: ' . $result['error']); return; } if (isset($result['success'])) { - $statusLabel->setText('Datei erfolgreich hochgeladen'); - $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + $sftpTab->completedUploadFiles++; + $progress = $sftpTab->totalUploadFiles > 0 + ? ($sftpTab->completedUploadFiles / $sftpTab->totalUploadFiles) + : 1.0; + $sftpTab->transferProgressBar->setValue($progress); + $sftpTab->transferInfoLabel->setText( + sprintf( + 'Upload: %d / %d Dateien', + $sftpTab->completedUploadFiles, + $sftpTab->totalUploadFiles, + ), + ); + + // Nächste Datei starten + $sftpTab->startNextUploadTask($currentPrivateKeyPath, $serverListTab, $statusLabel); } }, - function ($error) use ($statusLabel) { + function ($error) use ($sftpTab, $statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; + $sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $errorMsg); $statusLabel->setText('Fehler beim Hochladen: ' . $errorMsg); }, ); @@ -854,6 +1055,105 @@ class SftpManagerTab $uploadAsyncButton->handleMouseClick(0, 0, 0); } + /** + * Nächste Datei aus der Download-Queue holen und Fortschritt aktualisieren. + */ + private function startNextDownloadTask( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + string $localRootDir, + ): void { + if ($this->totalDownloadFiles <= 0) { + $this->transferProgressBar->setValue(1.0); + $this->transferInfoLabel->setText('Keine Dateien zum Herunterladen'); + return; + } + + if (empty($this->pendingDownloadQueue)) { + // Alle Dateien heruntergeladen + $this->transferProgressBar->setValue(1.0); + $this->transferInfoLabel->setText('Download abgeschlossen'); + $this->localFileBrowser->loadDirectory($localRootDir); + return; + } + + $item = array_shift($this->pendingDownloadQueue); + $remotePath = $item['remote']; + $localPath = $item['local']; + + $selectedServerRef = &$serverListTab->selectedServer; + $sftpTab = $this; + + $downloadAsyncButton = new Button('DownloadChunk', ''); + $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']; + } + + $localDir = dirname($localPath); + if (!is_dir($localDir)) { + if (!mkdir($localDir, 0777, true) && !is_dir($localDir)) { + return ['error' => 'Lokales Zielverzeichnis kann nicht erstellt werden']; + } + } + + $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, &$currentPrivateKeyPath, $serverListTab, $statusLabel, $localRootDir) { + if (isset($result['error'])) { + $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen: ' . $result['error']); + $statusLabel->setText('Fehler beim Herunterladen: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $sftpTab->completedDownloadFiles++; + $progress = $sftpTab->totalDownloadFiles > 0 + ? ($sftpTab->completedDownloadFiles / $sftpTab->totalDownloadFiles) + : 1.0; + $sftpTab->transferProgressBar->setValue($progress); + $sftpTab->transferInfoLabel->setText( + sprintf( + 'Download: %d / %d Dateien', + $sftpTab->completedDownloadFiles, + $sftpTab->totalDownloadFiles, + ), + ); + + // Nächste Datei herunterladen + $sftpTab->startNextDownloadTask($currentPrivateKeyPath, $serverListTab, $statusLabel, $localRootDir); + } + }, + function ($error) use ($sftpTab, $statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen: ' . $errorMsg); + $statusLabel->setText('Fehler beim Herunterladen: ' . $errorMsg); + }, + ); + + $downloadAsyncButton->handleMouseClick(0, 0, 0); + } + private function handleDownload( string &$currentPrivateKeyPath, ServerListTab $serverListTab, @@ -872,11 +1172,6 @@ class SftpManagerTab 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; @@ -897,15 +1192,16 @@ class SftpManagerTab return; } - $localPath = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath); + $this->transferProgressBar->setValue(0.0); + $this->transferInfoLabel->setText('Download wird vorbereitet …'); - // Use reference to selected server for async operation + // Async-Scan der Remote-Struktur, um eine Download-Queue aufzubauen $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; - $downloadAsyncButton = new Button('Download', ''); - $downloadAsyncButton->setOnClickAsync( - function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $localPath) { + $scanButton = new Button('ScanDownload', ''); + $scanButton->setOnClickAsync( + function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $remoteRow, $localDir) { if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { return ['error' => 'Not connected']; } @@ -920,34 +1216,113 @@ class SftpManagerTab return ['error' => 'SFTP Login failed']; } - $result = $sftp->get($remotePath, $localPath); - if ($result === false) { - return ['error' => 'Download fehlgeschlagen']; + $queue = []; + $files = 0; + $dirs = 0; + + $isDir = (bool) ($remoteRow['isDir'] ?? false); + if (!$isDir) { + $queue[] = [ + 'remote' => $remotePath, + 'local' => rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath), + ]; + $files = 1; + } else { + $rootLocal = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath); + + $collect = null; + $collect = function (string $srcDir, string $dstDir) use (&$collect, $sftp, &$queue, &$files, &$dirs) { + $dirs++; + + $entries = $sftp->nlist($srcDir); + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $remoteChild = rtrim($srcDir, '/') . '/' . $entry; + $localChild = rtrim($dstDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $entry; + + $stat = $sftp->stat($remoteChild); + $isDirChild = ($stat['type'] ?? 0) === 2; + + if ($isDirChild) { + $collect($remoteChild, $localChild); + } else { + $queue[] = [ + 'remote' => $remoteChild, + 'local' => $localChild, + ]; + $files++; + } + } + }; + + $collect($remotePath, $rootLocal); } - return ['success' => true]; + return [ + 'success' => true, + 'queue' => $queue, + 'files' => $files, + 'dirs' => $dirs, + 'localDir' => $localDir, + ]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, - function ($result) use ($sftpTab, $statusLabel, $localDir) { + function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, $serverListTab) { if (isset($result['error'])) { - $statusLabel->setText('Fehler beim Herunterladen: ' . $result['error']); + $statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $result['error']); + $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen'); return; } - if (isset($result['success'])) { - $statusLabel->setText('Datei erfolgreich heruntergeladen'); - $sftpTab->localFileBrowser->loadDirectory($localDir); + if (!isset($result['success']) || !$result['success']) { + $statusLabel->setText('Unbekannter Fehler beim Vorbereiten des Downloads'); + $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen'); + return; } + + $queue = $result['queue'] ?? []; + $files = (int) ($result['files'] ?? 0); + $dirs = (int) ($result['dirs'] ?? 0); + + $sftpTab->pendingDownloadQueue = $queue; + $sftpTab->totalDownloadFiles = count($queue); + $sftpTab->completedDownloadFiles = 0; + + if ($sftpTab->totalDownloadFiles <= 0) { + $sftpTab->transferProgressBar->setValue(1.0); + $sftpTab->transferInfoLabel->setText('Keine Dateien zum Herunterladen'); + return; + } + + $sftpTab->transferProgressBar->setValue(0.0); + $sftpTab->transferInfoLabel->setText( + sprintf( + 'Download: %d Dateien, %d Ordner', + $files, + $dirs, + ), + ); + + $localDir = $result['localDir'] ?? ''; + $sftpTab->startNextDownloadTask($currentPrivateKeyPath, $serverListTab, $statusLabel, $localDir); }, - function ($error) use ($statusLabel) { + function ($error) use ($sftpTab, $statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; - $statusLabel->setText('Fehler beim Herunterladen: ' . $errorMsg); + $statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $errorMsg); + $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen'); }, ); - $downloadAsyncButton->handleMouseClick(0, 0, 0); + $scanButton->handleMouseClick(0, 0, 0); } private function createRenameModal( diff --git a/src/Ui/Widget/FileBrowser.php b/src/Ui/Widget/FileBrowser.php index 225d934..61e2c6b 100644 --- a/src/Ui/Widget/FileBrowser.php +++ b/src/Ui/Widget/FileBrowser.php @@ -4,8 +4,9 @@ namespace PHPNative\Ui\Widget; class FileBrowser extends Container { - private VirtualTable $fileTable; + private VirtualListView $fileTable; private Label $pathLabel; + private Label $summaryLabel; private string $currentPath; private $onFileSelect = null; private $onEditFile = null; @@ -18,7 +19,7 @@ class FileBrowser extends Container public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '') { // Root-Container füllt die verfügbare Höhe im Eltern-Layout (flex-1) - // und enthält eine VirtualTable für performantes Scrollen. + // und enthält eine VirtualListView für performantes Scrollen. parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style); $this->currentPath = $initialPath; @@ -28,10 +29,9 @@ class FileBrowser extends Container $this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono'); $this->addComponent($this->pathLabel); - // VirtualTable nutzt Paging, um bei vielen Einträgen nur - // einen Teil der Zeilen im UI zu halten – das macht Scrollen - // deutlich flüssiger bei großen Verzeichnissen. - $this->fileTable = new VirtualTable(' flex-1'); + // VirtualListView rendert nur die sichtbaren Zeilen und + // bleibt damit auch bei großen Verzeichnissen flüssig. + $this->fileTable = new VirtualListView(' flex-1'); $this->fileTable->setColumns([ ['key' => 'type', 'title' => 'Typ', 'width' => 60], ['key' => 'name', 'title' => 'Name'], @@ -42,6 +42,10 @@ class FileBrowser extends Container $this->addComponent($this->fileTable); + // Summary label: zeigt Anzahl Ordner/Dateien im aktuellen Verzeichnis + $this->summaryLabel = new Label('0 Ordner, 0 Dateien', 'text-xs text-gray-600'); + $this->addComponent($this->summaryLabel); + // Load initial directory (only if local) if (!$isRemote) { $this->loadDirectory($initialPath); @@ -108,6 +112,7 @@ class FileBrowser extends Container } $this->fileTable->setData($files); + $this->updateSummaryFromFiles($files); // Handle row selection $fileBrowser = $this; @@ -216,6 +221,7 @@ class FileBrowser extends Container } $this->fileTable->setData($files); + $this->updateSummaryFromFiles($files); // Handle row selection $fileBrowser = $this; @@ -381,6 +387,9 @@ class FileBrowser extends Container $this->fileTable->setData($tableData); + // Update summary from original file list (without '..' Eintrag) + $this->updateSummaryFromFiles($files); + // Set up row selection handler AFTER data is set (for remote browsers) // This needs to be done every time because setData might reset handlers $this->setupRemoteNavigationHandler(); @@ -410,4 +419,27 @@ class FileBrowser extends Container } }); } + + /** + * Update summary label based on list of files (with isDir flag). + */ + private function updateSummaryFromFiles(array $files): void + { + $dirCount = 0; + $fileCount = 0; + + foreach ($files as $file) { + if (!isset($file['isDir'])) { + continue; + } + + if ($file['isDir']) { + $dirCount++; + } else { + $fileCount++; + } + } + + $this->summaryLabel->setText(sprintf('%d Ordner, %d Dateien', $dirCount, $fileCount)); + } } diff --git a/src/Ui/Widget/ProgressBar.php b/src/Ui/Widget/ProgressBar.php new file mode 100644 index 0000000..476d2da --- /dev/null +++ b/src/Ui/Widget/ProgressBar.php @@ -0,0 +1,84 @@ +value === $clamped) { + return; + } + $this->value = $clamped; + $this->markDirty(false); + } + + public function getValue(): float + { + return $this->value; + } + + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + // Mindesthöhe sicherstellen + if ($this->viewport->height < 4) { + $this->viewport->height = 4; + $this->contentViewport->height = 4; + } + } + + public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void + { + if (!$this->visible) { + return; + } + + $x = (int) $this->contentViewport->x; + $y = (int) $this->contentViewport->y; + $w = (int) $this->contentViewport->width; + $h = (int) $this->contentViewport->height; + + if ($w <= 0 || $h <= 0) { + return; + } + + // Hintergrund (Track) + sdl_set_render_draw_color($renderer, 220, 220, 220, 255); + sdl_render_fill_rect($renderer, [ + 'x' => $x, + 'y' => $y, + 'w' => $w, + 'h' => $h, + ]); + + // Fortschrittsbalken + $fillWidth = (int) floor($w * $this->value); + if ($fillWidth <= 0) { + return; + } + + sdl_set_render_draw_color($renderer, 59, 130, 246, 255); // Tailwind blue-500 + sdl_render_fill_rect($renderer, [ + 'x' => $x, + 'y' => $y, + 'w' => $fillWidth, + 'h' => $h, + ]); + } +} + diff --git a/src/Ui/Widget/VirtualListView.php b/src/Ui/Widget/VirtualListView.php new file mode 100644 index 0000000..58d57b2 --- /dev/null +++ b/src/Ui/Widget/VirtualListView.php @@ -0,0 +1,351 @@ +columns = $columns; + $this->markDirty(true); + } + + public function setData(array $rows): void + { + $this->rows = array_values($rows); + $this->scrollY = 0.0; + $this->firstVisibleRow = 0; + $this->lastVisibleRow = -1; + $this->selectedRowIndex = null; + $this->markDirty(true); + } + + public function setOnRowSelect(callable $callback): void + { + $this->onRowSelect = $callback; + } + + public function getSelectedRow(): ?array + { + if ($this->selectedRowIndex === null) { + return null; + } + + return $this->rows[$this->selectedRowIndex] ?? null; + } + + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + if (empty($this->rows) || $textRenderer === null || !$textRenderer->isInitialized()) { + $this->clearChildren(); + return; + } + + if ($this->rowHeight <= 0) { + $this->rowHeight = $this->measureRowHeight($textRenderer); + } + + $maxScroll = $this->getMaxScroll(); + $this->scrollY = max(0.0, min($this->scrollY, $maxScroll)); + + $this->updateVisibleRows($textRenderer); + } + + private function measureRowHeight(TextRenderer $textRenderer): float + { + if (empty($this->rows)) { + return 24.0; + } + + $testRow = $this->buildRowContainer($this->rows[0], 0); + + $viewport = new Viewport( + x: $this->contentViewport->x, + y: $this->contentViewport->y, + width: $this->contentViewport->width, + height: $this->contentViewport->height, + windowWidth: $this->contentViewport->windowWidth, + windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, + ); + $testRow->setViewport($viewport); + $testRow->setContentViewport(clone $viewport); + $testRow->layout($textRenderer); + + $rowViewport = $testRow->getViewport(); + $height = max(1.0, (float) $rowViewport->height); + + return $height; + } + + private function updateVisibleRows(?TextRenderer $textRenderer): void + { + $this->clearChildren(); + + if ($this->rowHeight <= 0 || empty($this->rows) || $textRenderer === null) { + return; + } + + $viewportHeight = $this->contentViewport->height; + if ($viewportHeight <= 0) { + return; + } + + $rowCount = count($this->rows); + $first = (int) floor($this->scrollY / $this->rowHeight); + $visibleCount = (int) ceil($viewportHeight / $this->rowHeight) + self::VISIBLE_BUFFER; + $last = min($rowCount - 1, $first + $visibleCount - 1); + + $this->firstVisibleRow = $first; + $this->lastVisibleRow = $last; + + for ($i = $first; $i <= $last; $i++) { + $rowData = $this->rows[$i]; + $rowContainer = $this->buildRowContainer($rowData, $i); + + $rowY = $this->contentViewport->y + ($i * $this->rowHeight) - $this->scrollY; + + $rowViewport = new Viewport( + x: $this->contentViewport->x, + y: (int) $rowY, + width: $this->contentViewport->width, + height: $this->rowHeight, + windowWidth: $this->contentViewport->windowWidth, + windowHeight: $this->contentViewport->windowHeight, + uiScale: $this->contentViewport->uiScale, + ); + + $rowContainer->setViewport($rowViewport); + $rowContainer->setContentViewport(clone $rowViewport); + $rowContainer->layout($textRenderer); + + $this->addComponent($rowContainer); + } + } + + private function buildRowContainer(array $rowData, int $rowIndex): Container + { + $isSelected = $this->selectedRowIndex !== null && $rowIndex === $this->selectedRowIndex; + $rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-400'; + if ($isSelected) { + $rowStyle .= ' bg-blue-100'; + } + + $rowContainer = new VirtualListRow($this, $rowIndex, $rowStyle); + + foreach ($this->columns as $column) { + $key = $column['key']; + $value = $rowData[$key] ?? ''; + $width = $column['width'] ?? null; + + $cellStyle = 'px-4 py-2 text-black border-r border-gray-300'; + if ($width) { + $cellStyle .= ' w-' . ((int) ($width / 4)); + } else { + $cellStyle .= ' flex-1'; + } + + if (isset($column['render']) && is_callable($column['render'])) { + $cellContent = $column['render']($rowData, $rowIndex); + $cellContainer = new Container($cellStyle); + $cellContainer->addComponent($cellContent); + $rowContainer->addComponent($cellContainer); + } else { + $cellLabel = new Label((string) $value, $cellStyle); + $rowContainer->addComponent($cellLabel); + } + } + + return $rowContainer; + } + + private function getMaxScroll(): float + { + if ($this->rowHeight <= 0) { + return 0.0; + } + + $totalHeight = count($this->rows) * $this->rowHeight; + $viewportHeight = $this->contentViewport->height; + + return max(0.0, $totalHeight - $viewportHeight); + } + + public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool + { + if (!$this->visible) { + return false; + } + + if ( + $mouseX < $this->contentViewport->x || + $mouseX > ($this->contentViewport->x + $this->contentViewport->width) || + $mouseY < $this->contentViewport->y || + $mouseY > ($this->contentViewport->y + $this->contentViewport->height) + ) { + return parent::handleMouseWheel($mouseX, $mouseY, $deltaY); + } + + $maxScroll = $this->getMaxScroll(); + if ($maxScroll <= 0.0) { + return false; + } + + $scrollStep = max($this->rowHeight * 3, 20.0); + $oldScroll = $this->scrollY; + + $this->scrollY = max(0.0, min($maxScroll, $this->scrollY + ($deltaY * $scrollStep))); + + if ($this->scrollY === $oldScroll) { + return false; + } + + $this->markDirty(true, false); + + return true; + } + + public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void + { + if (!$this->visible || $textRenderer === null || !$textRenderer->isInitialized()) { + return; + } + + $clipRect = [ + 'x' => (int) $this->contentViewport->x, + 'y' => (int) $this->contentViewport->y, + 'w' => (int) $this->contentViewport->width, + 'h' => (int) $this->contentViewport->height, + ]; + + sdl_set_render_clip_rect($renderer, $clipRect); + + foreach ($this->children as $child) { + $child->render($renderer, $textRenderer); + $child->renderContent($renderer, $textRenderer); + } + + sdl_set_render_clip_rect($renderer, null); + + $this->renderScrollbar($renderer); + } + + private function renderScrollbar(&$renderer): void + { + if ($this->rowHeight <= 0 || empty($this->rows)) { + return; + } + + $totalHeight = count($this->rows) * $this->rowHeight; + $viewportHeight = $this->contentViewport->height; + + if ($totalHeight <= $viewportHeight) { + return; + } + + $scrollbarHeight = $viewportHeight; + $thumbHeight = max( + self::SCROLLBAR_MIN_SIZE, + ($viewportHeight / $totalHeight) * $scrollbarHeight, + ); + + $maxScroll = $this->getMaxScroll(); + if ($maxScroll <= 0.0) { + return; + } + + $thumbRange = $scrollbarHeight - $thumbHeight; + $scrollRatio = $this->scrollY / $maxScroll; + $thumbY = $this->contentViewport->y + ($scrollRatio * $thumbRange); + + $scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH; + + sdl_set_render_draw_color($renderer, 220, 220, 220, 255); + sdl_render_fill_rect($renderer, [ + 'x' => (int) $scrollbarX, + 'y' => (int) $this->contentViewport->y, + 'w' => (int) self::SCROLLBAR_WIDTH, + 'h' => (int) $scrollbarHeight, + ]); + + sdl_set_render_draw_color($renderer, 120, 120, 120, 230); + sdl_render_fill_rect($renderer, [ + 'x' => (int) ($scrollbarX + 2), + 'y' => (int) $thumbY, + 'w' => (int) (self::SCROLLBAR_WIDTH - 4), + 'h' => (int) $thumbHeight, + ]); + } + + public function selectRow(int $rowIndex): void + { + if ($rowIndex < 0 || $rowIndex >= count($this->rows)) { + return; + } + + $this->selectedRowIndex = $rowIndex; + + if ($this->onRowSelect !== null) { + ($this->onRowSelect)($rowIndex, $this->rows[$rowIndex]); + } + + $this->markDirty(true); + } +} + +class VirtualListRow extends Container +{ + private int $rowIndex; + private VirtualListView $table; + + public function __construct(VirtualListView $table, int $rowIndex, string $style = '') + { + $this->rowIndex = $rowIndex; + $this->table = $table; + parent::__construct($style); + } + + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + $handled = parent::handleMouseClick($mouseX, $mouseY, $button); + if ($handled) { + return true; + } + + if ( + $mouseX >= $this->viewport->x && + $mouseX <= ($this->viewport->x + $this->viewport->width) && + $mouseY >= $this->viewport->y && + $mouseY <= ($this->viewport->y + $this->viewport->height) + ) { + $this->table->selectRow($this->rowIndex); + return true; + } + + return false; + } +} +