This commit is contained in:
Thomas Peterson 2025-11-17 09:09:25 +01:00
parent 37c2a1db67
commit 312eb2c4bd
9 changed files with 543 additions and 202 deletions

View File

@ -53,6 +53,8 @@ class HetznerService
'status' => ($i % 3) === 0 ? 'stopped' : 'running', 'status' => ($i % 3) === 0 ? 'stopped' : 'running',
'type' => 'cx' . (11 + (($i % 4) * 10)), 'type' => 'cx' . (11 + (($i % 4) * 10)),
'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255), 'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255),
'docker_status' => 'pending',
'domains' => [],
]; ];
} }
return $testData; return $testData;

View File

@ -2,6 +2,7 @@
namespace ServerManager\UI; namespace ServerManager\UI;
use PHPNative\Async\TaskManager;
use PHPNative\Tailwind\Data\Icon as IconName; use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container; use PHPNative\Ui\Widget\Container;
@ -30,6 +31,7 @@ class ServerListTab
private Label $detailStatus; private Label $detailStatus;
private Label $detailType; private Label $detailType;
private Label $detailIpv4; private Label $detailIpv4;
private Container $detailDomainsContainer;
public function __construct( public function __construct(
string &$apiKey, string &$apiKey,
@ -66,11 +68,12 @@ class ServerListTab
// Table // Table
$this->table = new Table(style: ' flex-1'); $this->table = new Table(style: ' flex-1');
$this->table->setColumns([ $this->table->setColumns([
['key' => 'id', 'title' => 'ID', 'width' => 100], ['key' => 'id', 'title' => 'ID', 'width' => 80],
['key' => 'name', 'title' => 'Name', 'width' => 400], ['key' => 'name', 'title' => 'Name'],
['key' => 'status', 'title' => 'Status', 'width' => 120], ['key' => 'status', 'title' => 'Status', 'width' => 100],
['key' => 'type', 'title' => 'Typ', 'width' => 120], ['key' => 'type', 'title' => 'Typ', 'width' => 80],
['key' => 'ipv4', 'title' => 'IPv4'], ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 160],
['key' => 'docker_status', 'title' => 'Docker', 'width' => 100],
]); ]);
// Load initial test data // Load initial test data
@ -101,6 +104,11 @@ class ServerListTab
$detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2'));
$detailPanel->addComponent($this->detailIpv4); $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) // SFTP Manager Button (handler will be set by SftpManagerTab)
$this->sftpButton = new Button( $this->sftpButton = new Button(
'SFTP Manager öffnen', 'SFTP Manager öffnen',
@ -184,6 +192,9 @@ class ServerListTab
$serverListTab->detailType->setText($row['type']); $serverListTab->detailType->setText($row['type']);
$serverListTab->detailIpv4->setText($row['ipv4']); $serverListTab->detailIpv4->setText($row['ipv4']);
$domains = $row['domains'] ?? [];
$serverListTab->updateDomainDetails(is_array($domains) ? $domains : []);
$serverListTab->selectedServer = $row; $serverListTab->selectedServer = $row;
} }
}); });
@ -196,13 +207,18 @@ class ServerListTab
$serverListTab->table->setData($serverListTab->currentServerData); $serverListTab->table->setData($serverListTab->currentServerData);
} else { } else {
$filteredData = array_filter($serverListTab->currentServerData, function ($row) use ($searchTerm) { $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)); $serverListTab->table->setData(array_values($filteredData));
} }
}); });
// Refresh button - use reference to apiKey variable // Refresh button - use reference to apiKey & privateKey variable
$this->refreshButton->setOnClickAsync( $this->refreshButton->setOnClickAsync(
function () use (&$currentApiKey) { function () use (&$currentApiKey) {
try { try {
@ -220,6 +236,8 @@ class ServerListTab
'status' => $server->status, 'status' => $server->status,
'type' => $server->serverType->name, 'type' => $server->serverType->name,
'ipv4' => $server->publicNet->ipv4->ip, 'ipv4' => $server->publicNet->ipv4->ip,
'docker_status' => 'pending',
'domains' => [],
]; ];
} }
@ -232,32 +250,147 @@ class ServerListTab
return ['error' => 'Exception: ' . $e->getMessage()]; return ['error' => 'Exception: ' . $e->getMessage()];
} }
}, },
function ($result) use ($serverListTab) { function ($result) use (&$serverListTab, &$currentPrivateKeyPath) {
if (is_array($result)) { if (is_array($result)) {
if (isset($result['error'])) { if (isset($result['error'])) {
$serverListTab->statusLabel->setText('Fehler: ' . $result['error']); $serverListTab->statusLabel->setText('Fehler: ' . $result['error']);
echo "Error: {$result['error']}\n"; echo "Error: {$result['error']}\n";
} elseif (isset($result['success'], $result['servers'])) { } elseif (isset($result['success'], $result['servers'])) {
$serverListTab->currentServerData = $result['servers']; // 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']);
$searchTerm = strtolower(trim($serverListTab->searchInput->getValue()));
if (empty($searchTerm)) {
$serverListTab->table->setData($serverListTab->currentServerData); $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));
}
$serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden');
echo "Success: {$result['count']} servers loaded\n"; 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') $errorMsg = is_object($error) && method_exists($error, 'getMessage')
? $error->getMessage() ? $error->getMessage()
: ((string) $error); : ((string) $error);
@ -276,4 +409,39 @@ class ServerListTab
{ {
return $this->sftpButton; 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);
}
}
} }

View File

@ -29,6 +29,8 @@ class SftpManagerTab
private Modal $deleteConfirmModal; private Modal $deleteConfirmModal;
private string $currentDeleteFilePath = ''; private string $currentDeleteFilePath = '';
private Label $deleteConfirmLabel; private Label $deleteConfirmLabel;
private null|array $currentLocalSelection = null;
private null|array $currentRemoteSelection = null;
public function __construct( public function __construct(
string &$apiKey, string &$apiKey,
@ -52,6 +54,15 @@ class SftpManagerTab
); );
$localBrowserContainer->addComponent($this->localFileBrowser); $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 // Right side: Remote file browser
$remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); $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'); $this->connectionStatusLabel = new Label('Nicht verbunden', 'text-sm text-gray-600 italic mb-2');
$remoteBrowserContainer->addComponent($this->connectionStatusLabel); $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($localBrowserContainer);
$this->tab->addComponent($transferContainer);
$this->tab->addComponent($remoteBrowserContainer); $this->tab->addComponent($remoteBrowserContainer);
// Setup remote navigation handler // Setup remote navigation handler
@ -93,6 +119,15 @@ class SftpManagerTab
// Create delete confirmation modal // Create delete confirmation modal
$this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); $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 // Setup file edit handler
$this->remoteFileBrowser->setOnEditFile(function ($path, $row) use ( $this->remoteFileBrowser->setOnEditFile(function ($path, $row) use (
&$currentPrivateKeyPath, &$currentPrivateKeyPath,
@ -138,12 +173,20 @@ class SftpManagerTab
// Create reference to selectedServer array outside of closure // Create reference to selectedServer array outside of closure
$selectedServerRef = &$serverListTab->selectedServer; $selectedServerRef = &$serverListTab->selectedServer;
$this->remoteFileBrowser->setOnFileSelect(function ($path, $row) use ( $this->remoteFileBrowser->setOnFileSelect(
function ($path, $row) use (
$sftpTab, $sftpTab,
&$currentPrivateKeyPath, &$currentPrivateKeyPath,
&$selectedServerRef, &$selectedServerRef,
$statusLabel, $statusLabel,
) { ) {
// Track remote selection (for downloads)
$sftpTab->currentRemoteSelection = [
'path' => $path,
'row' => $row,
];
// Only navigate when a directory is selected
if (!isset($row['isDir']) || !$row['isDir']) { if (!isset($row['isDir']) || !$row['isDir']) {
return; return;
} }
@ -210,7 +253,8 @@ class SftpManagerTab
); );
$loadButton->handleMouseClick(0, 0, 0); $loadButton->handleMouseClick(0, 0, 0);
}); },
);
} }
private function setupSftpConnectionHandler( private function setupSftpConnectionHandler(
@ -689,6 +733,198 @@ class SftpManagerTab
$loadButton->handleMouseClick(0, 0, 0); $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( private function createRenameModal(
string &$currentPrivateKeyPath, string &$currentPrivateKeyPath,
ServerListTab $serverListTab, ServerListTab $serverListTab,
@ -922,4 +1158,3 @@ class SftpManagerTab
$this->deleteConfirmModal->setVisible(true); $this->deleteConfirmModal->setVisible(true);
} }
} }

View File

@ -2,13 +2,7 @@
{ {
"id": "board_691661d89de624.46726087", "id": "board_691661d89de624.46726087",
"title": "Backlog", "title": "Backlog",
"tasks": [ "tasks": []
{
"id": "task_691661d89de735.41071535",
"title": "Idee sammeln",
"note": ""
}
]
}, },
{ {
"id": "board_691661d89de806.79123800", "id": "board_691661d89de806.79123800",
@ -18,6 +12,11 @@
"id": "task_691661d89de858.46479539", "id": "task_691661d89de858.46479539",
"title": "API anbinden", "title": "API anbinden",
"note": "" "note": ""
},
{
"id": "task_691661d89de735.41071535",
"title": "Idee sammeln",
"note": ""
} }
] ]
}, },

View File

@ -75,7 +75,7 @@ class TextRenderer
private function loadFont(int $size): mixed 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])) { if (isset($this->fonts[$size])) {
return $this->fonts[$size]; return $this->fonts[$size];
@ -199,6 +199,7 @@ class TextRenderer
'w' => $textSize['w'], 'w' => $textSize['w'],
'h' => $textSize['h'], 'h' => $textSize['h'],
]); ]);
// Note: Texture and surface are automatically cleaned up by PHP resource destructors // Note: Texture and surface are automatically cleaned up by PHP resource destructors
} }

View File

@ -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 public function layout(null|TextRenderer $textRenderer = null): void
{ {
$this->normalStylesCached = StyleParser::parse($this->style)->getValidStyles( $this->normalStylesCached = StyleParser::parse($this->style)->getValidStyles(
@ -415,11 +402,12 @@ abstract class Component
Profiler::increment('texture_cache_hit'); Profiler::increment('texture_cache_hit');
Profiler::start('render_cached'); Profiler::start('render_cached');
// Render cached texture // Render cached texture
sdl_render_texture($renderer, $texture, [ sdl_render_texture($renderer, $texture, [
'x' => $this->viewport->x * $this->viewport->uiScale, 'x' => $this->viewport->x,
'y' => $this->viewport->y * $this->viewport->uiScale, 'y' => $this->viewport->y,
'w' => $this->viewport->width * $this->viewport->uiScale, 'w' => $this->viewport->width,
'h' => $this->viewport->height * $this->viewport->uiScale, 'h' => $this->viewport->height,
]); ]);
Profiler::end('render_cached'); Profiler::end('render_cached');
Profiler::end('render'); 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) // SDL3: sdl_rounded_box_ex uses (x1, y1, x2, y2) instead of (x, y, w, h)
$x2 = $this->viewport->x + $this->viewport->width; $x2 = $this->viewport->x + $this->viewport->width;
$y2 = $this->viewport->y + $this->viewport->height; $y2 = $this->viewport->y + $this->viewport->height;
sdl_rounded_box_ex( sdl_rounded_box_ex(
$renderer, $renderer,
((int) $this->viewport->x) * $this->viewport->uiScale, (int) $this->viewport->x,
((int) $this->viewport->y) * $this->viewport->uiScale, (int) $this->viewport->y,
((int) $x2) * $this->viewport->uiScale, (int) $x2,
((int) $y2) * $this->viewport->uiScale, (int) $y2,
$border->roundTopLeft ?? 0, $border->roundTopLeft ?? 0,
$border->roundTopRight ?? 0, $border->roundTopRight ?? 0,
$border->roundBottomRight ?? 0, $border->roundBottomRight ?? 0,
@ -475,20 +464,20 @@ abstract class Component
); );
} else { } else {
sdl_render_fill_rect($renderer, [ sdl_render_fill_rect($renderer, [
'x' => $this->viewport->x * $this->viewport->uiScale, 'x' => $this->viewport->x,
'y' => $this->viewport->y * $this->viewport->uiScale, 'y' => $this->viewport->y,
'w' => $this->viewport->width * $this->viewport->uiScale, 'w' => $this->viewport->width,
'h' => $this->viewport->height * $this->viewport->uiScale, 'h' => $this->viewport->height,
]); ]);
} }
} }
if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) { if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) {
sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10); sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10);
sdl_render_rect($renderer, [ sdl_render_rect($renderer, [
'x' => $this->viewport->x * $this->viewport->uiScale, 'x' => $this->viewport->x,
'y' => $this->viewport->y * $this->viewport->uiScale, 'y' => $this->viewport->y,
'w' => $this->viewport->width * $this->viewport->uiScale, 'w' => $this->viewport->width,
'h' => $this->viewport->height * $this->viewport->uiScale, 'h' => $this->viewport->height,
]); ]);
} }

View File

@ -116,6 +116,7 @@ class Container extends Component
height: $this->contentViewport->height, height: $this->contentViewport->height,
windowWidth: $this->contentViewport->windowWidth, windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight, windowHeight: $this->contentViewport->windowHeight,
uiScale: $this->contentViewport->uiScale,
); );
$child->setViewport($childViewport); $child->setViewport($childViewport);
@ -138,6 +139,7 @@ class Container extends Component
height: $this->contentViewport->windowHeight, height: $this->contentViewport->windowHeight,
windowWidth: $this->contentViewport->windowWidth, windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight, windowHeight: $this->contentViewport->windowHeight,
uiScale: $this->contentViewport->uiScale,
); );
$child->setViewport($overlayViewport); $child->setViewport($overlayViewport);
$child->setContentViewport(clone $overlayViewport); $child->setContentViewport(clone $overlayViewport);
@ -272,6 +274,7 @@ class Container extends Component
height: $isRow ? $this->contentViewport->height : 9999, height: $isRow ? $this->contentViewport->height : 9999,
windowWidth: $this->contentViewport->windowWidth, windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight, windowHeight: $this->contentViewport->windowHeight,
uiScale: $this->contentViewport->uiScale,
); );
$child->setViewport($tempViewport); $child->setViewport($tempViewport);
$child->layout($textRenderer); $child->layout($textRenderer);
@ -329,6 +332,7 @@ class Container extends Component
height: $childHeight, height: $childHeight,
windowWidth: $this->contentViewport->windowWidth, windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight, windowHeight: $this->contentViewport->windowHeight,
uiScale: $this->contentViewport->uiScale,
); );
$currentPosition += $size; $currentPosition += $size;
} else { } else {
@ -344,6 +348,7 @@ class Container extends Component
height: $size, height: $size,
windowWidth: $this->contentViewport->windowWidth, windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight, windowHeight: $this->contentViewport->windowHeight,
uiScale: $this->contentViewport->uiScale,
); );
$currentPosition += $size; $currentPosition += $size;
} }
@ -357,7 +362,7 @@ class Container extends Component
private function calculateSize(Width|Height|Basis $style, float $availableSpace): float private function calculateSize(Width|Height|Basis $style, float $availableSpace): float
{ {
return match ($style->unit) { return match ($style->unit) {
Unit::Pixel => (float) $style->value, Unit::Pixel => ((float) $style->value) * $this->contentViewport->uiScale,
Unit::Point => (float) $style->value, Unit::Point => (float) $style->value,
Unit::Percent => ($availableSpace * $style->value) / 100, Unit::Percent => ($availableSpace * $style->value) / 100,
}; };

View File

@ -75,8 +75,8 @@ class Icon extends Component
if ($font) { if ($font) {
$dimensions = ttf_size_text($font, $this->glyph); $dimensions = ttf_size_text($font, $this->glyph);
if ($dimensions !== false) { if ($dimensions !== false) {
$width = (int) $dimensions['w']; $width = ((int) $dimensions['w']) * $this->viewport->uiScale;
$height = (int) $dimensions['h']; $height = ((int) $dimensions['h']) * $this->viewport->uiScale;
} }
} }
@ -130,7 +130,7 @@ class Icon extends Component
{ {
$this->clearTexture(); $this->clearTexture();
$font = IconFontRegistry::getFont($this->size, $this->fontPath); $font = IconFontRegistry::getFont($this->size * $this->viewport->uiScale, $this->fontPath);
if (!$font) { if (!$font) {
$this->renderDirty = false; $this->renderDirty = false;
return; return;

View File

@ -16,7 +16,6 @@ class Window
private float $mouseY = 0; private float $mouseY = 0;
private Viewport $viewport; private Viewport $viewport;
private bool $shouldBeReLayouted = true; private bool $shouldBeReLayouted = true;
private float $pixelRatio = 1.0;
private float $uiScale = 1.0; private float $uiScale = 1.0;
private bool $shouldClose = false; private bool $shouldClose = false;
private $onResize = null; private $onResize = null;
@ -24,6 +23,7 @@ class Window
private float $lastFpsUpdate = 0.0; private float $lastFpsUpdate = 0.0;
private int $frameCounter = 0; private int $frameCounter = 0;
private float $currentFps = 0.0; private float $currentFps = 0.0;
private bool $leftButtonDown = false;
public function __construct( public function __construct(
private string $title, private string $title,
@ -58,12 +58,11 @@ class Window
// Get window ID for event routing // Get window ID for event routing
$this->windowId = sdl_get_window_id($this->window); $this->windowId = sdl_get_window_id($this->window);
// Use display scale as UI scale (e.g. 2.0 on HiDPI) // 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); $scale = sdl_get_window_display_scale($this->window);
if ($scale > 0.1 && $scale <= 4.0) { if ($scale > 0.1 && $scale <= 4.0) {
$this->uiScale = (float) $scale; $this->uiScale = (float) $scale;
} }
}
// Enable text input for this window // Enable text input for this window
sdl_start_text_input($this->window); sdl_start_text_input($this->window);
@ -78,77 +77,21 @@ class Window
if (!$this->textRenderer->init()) { if (!$this->textRenderer->init()) {
error_log('Warning: Failed to initialize text renderer. Text rendering will not be available.'); error_log('Warning: Failed to initialize text renderer. Text rendering will not be available.');
} }
$this->textRenderer->setPixelRatio($this->uiScale);
// Get actual window size // Get actual window size
$size = sdl_get_window_size($this->window); $size = sdl_get_window_size($this->window);
$this->width = $size[0]; $this->width = $size[0];
$this->height = $size[1]; $this->height = $size[1];
$this->viewport = new Viewport( $this->viewport = new Viewport(
windowWidth: $this->width, windowWidth: $this->width * $this->uiScale,
windowHeight: $this->height, windowHeight: $this->height * $this->uiScale,
width: $this->width, width: $this->width * $this->uiScale,
height: $this->height, height: $this->height * $this->uiScale,
uiScale: $this->uiScale, uiScale: $this->uiScale,
); );
$this->updatePixelRatio();
$this->lastFpsUpdate = microtime(true); $this->lastFpsUpdate = microtime(true);
} }
private function updatePixelRatio(): void
{
$this->pixelRatio = 1.0;
// HiDPIScaling 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 public function getUiScale(): float
{ {
return $this->uiScale; return $this->uiScale;
@ -277,13 +220,12 @@ class Window
if ($this->textRenderer && $this->textRenderer->isInitialized()) { if ($this->textRenderer && $this->textRenderer->isInitialized()) {
$this->textRenderer->updateFramebuffer($newWidth, $newHeight); $this->textRenderer->updateFramebuffer($newWidth, $newHeight);
} }
$this->updatePixelRatio();
$this->viewport->x = 0; $this->viewport->x = 0;
$this->viewport->y = 0; $this->viewport->y = 0;
$this->viewport->windowWidth = $newWidth; $this->viewport->windowWidth = $newWidth * $this->viewport->uiScale;
$this->viewport->width = $newWidth; $this->viewport->width = $newWidth * $this->viewport->uiScale;
$this->viewport->height = $newHeight; $this->viewport->height = $newHeight * $this->viewport->uiScale;
$this->viewport->windowHeight = $newHeight; $this->viewport->windowHeight = $newHeight * $this->viewport->uiScale;
$this->shouldBeReLayouted = true; $this->shouldBeReLayouted = true;
if ($this->onResize) { if ($this->onResize) {
($this->onResize)($this); ($this->onResize)($this);
@ -292,8 +234,8 @@ class Window
case SDL_EVENT_MOUSE_MOTION: case SDL_EVENT_MOUSE_MOTION:
// Convert physical pixels to logical coordinates using uiScale // Convert physical pixels to logical coordinates using uiScale
$newMouseX = (float) ($event['x'] ?? 0) / $this->uiScale; $newMouseX = ((float) ($event['x'] ?? 0)) * $this->uiScale;
$newMouseY = (float) ($event['y'] ?? 0) / $this->uiScale; $newMouseY = ((float) ($event['y'] ?? 0)) * $this->uiScale;
$this->mouseX = $newMouseX; $this->mouseX = $newMouseX;
$this->mouseY = $newMouseY; $this->mouseY = $newMouseY;