This commit is contained in:
Thomas Peterson 2025-12-01 11:06:53 +01:00
parent 3fa5276aba
commit d4a926ddad
4 changed files with 891 additions and 49 deletions

View File

@ -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<int,array{local:string,remote:string}>
*/
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<int,array{local:string,remote:string}> $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(

View File

@ -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));
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Ui\Component;
class ProgressBar extends Component
{
private float $value = 0.0; // 0.0 - 1.0
public function __construct(string $style = '')
{
// Basis: volle Breite, kleine Höhe
$defaultStyle = 'w-full h-[8]';
parent::__construct(trim($defaultStyle . ' ' . $style));
}
public function setValue(float $value): void
{
$clamped = max(0.0, min(1.0, $value));
if ($this->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,
]);
}
}

View File

@ -0,0 +1,351 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Ui\Viewport;
class VirtualListView extends Container
{
private array $columns = [];
private array $rows = [];
private float $rowHeight = 0.0;
private float $scrollY = 0.0;
private int $firstVisibleRow = 0;
private int $lastVisibleRow = -1;
private ?int $selectedRowIndex = null;
private $onRowSelect = null;
private const SCROLLBAR_WIDTH = 16;
private const SCROLLBAR_MIN_SIZE = 20;
private const VISIBLE_BUFFER = 5;
public function __construct(string $style = '')
{
parent::__construct('w-full ' . $style);
}
public function setColumns(array $columns): void
{
$this->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;
}
}