From bf986acb499b8a3290eca31eeba3f75408f1526c Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Sat, 29 Nov 2025 22:10:58 +0100 Subject: [PATCH] Backup --- examples/ServerManager/UI/ServerListTab.php | 74 +++++--- examples/ServerManager/UI/SftpManagerTab.php | 27 ++- examples/textarea_scroll_test.php | 33 ++++ src/Framework/TextRenderer.php | 76 +++++++- src/Ui/Widget/Container.php | 2 +- src/Ui/Widget/FileBrowser.php | 51 +++++- src/Ui/Widget/Table.php | 17 +- src/Ui/Widget/TextArea.php | 174 +++++++++++++++++++ 8 files changed, 415 insertions(+), 39 deletions(-) create mode 100644 examples/textarea_scroll_test.php diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php index ff86b54..9b43ce8 100644 --- a/examples/ServerManager/UI/ServerListTab.php +++ b/examples/ServerManager/UI/ServerListTab.php @@ -530,7 +530,25 @@ class ServerListTab $serverListTab->currentServerData[$i]['domains'] = $domains; } $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; - $serverListTab->table->setData($serverListTab->currentServerData, true); + $searchTerm = $serverListTab->searchInput->getValue(); + if (empty($searchTerm)) { + $serverListTab->table->setData($serverListTab->currentServerData, true); + } else { + $filteredData = array_filter( + $serverListTab->currentServerData, + function ($row) use ($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), true); + } } // Map system status into human-readable table fields @@ -664,20 +682,18 @@ class ServerListTab if ($result['success']) { $serverListTab->statusLabel->setText('Reboot ausgelöst für ' . $name); if (function_exists('desktop_notify')) { - desktop_notify( - 'Reboot gestartet', - 'Reboot ausgelöst für ' . $name, - ['timeout' => 4000, 'urgency' => 'normal'], - ); + desktop_notify('Reboot gestartet', 'Reboot ausgelöst für ' . $name, [ + 'timeout' => 4000, + 'urgency' => 'normal', + ]); } } elseif (isset($result['error'])) { $serverListTab->statusLabel->setText('Reboot Fehler bei ' . $name . ': ' . $result['error']); if (function_exists('desktop_notify')) { - desktop_notify( - 'Reboot fehlgeschlagen', - 'Fehler bei ' . $name . ': ' . $result['error'], - ['timeout' => 6000, 'urgency' => 'critical'], - ); + desktop_notify('Reboot fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [ + 'timeout' => 6000, + 'urgency' => 'critical', + ]); } } }); @@ -773,27 +789,39 @@ class ServerListTab $needsReboot = (bool) $result['needs_reboot']; $serverListTab->currentServerData[$index]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; } - - $serverListTab->table->setData($serverListTab->currentServerData, true); + $searchTerm = $serverListTab->searchInput->getValue(); + if (empty($searchTerm)) { + $serverListTab->table->setData($serverListTab->currentServerData, true); + } else { + $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( + $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), false); + } } if ($result['success']) { $serverListTab->statusLabel->setText('Updates ausgeführt für ' . $name); if (function_exists('desktop_notify')) { - desktop_notify( - 'Update erfolgreich', - 'Updates ausgeführt für ' . $name, - ['timeout' => 5000, 'urgency' => 'normal'], - ); + desktop_notify('Update erfolgreich', 'Updates ausgeführt für ' . $name, [ + 'timeout' => 5000, + 'urgency' => 'normal', + ]); } } elseif (isset($result['error'])) { $serverListTab->statusLabel->setText('Update Fehler bei ' . $name . ': ' . $result['error']); if (function_exists('desktop_notify')) { - desktop_notify( - 'Update fehlgeschlagen', - 'Fehler bei ' . $name . ': ' . $result['error'], - ['timeout' => 6000, 'urgency' => 'critical'], - ); + desktop_notify('Update fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [ + 'timeout' => 6000, + 'urgency' => 'critical', + ]); } } }); diff --git a/examples/ServerManager/UI/SftpManagerTab.php b/examples/ServerManager/UI/SftpManagerTab.php index 1d43526..df5d560 100644 --- a/examples/ServerManager/UI/SftpManagerTab.php +++ b/examples/ServerManager/UI/SftpManagerTab.php @@ -31,6 +31,8 @@ class SftpManagerTab private Label $deleteConfirmLabel; private null|array $currentLocalSelection = null; private null|array $currentRemoteSelection = null; + private ?string $lastRemoteClickPath = null; + private float $lastRemoteClickTime = 0.0; public function __construct( string &$apiKey, @@ -45,6 +47,7 @@ class SftpManagerTab $currentRemoteStartDir = &$remoteStartDir; // Left side: Local file browser + // Lokaler Browser: Spalte, Scrollen übernimmt der FileBrowser selbst $localBrowserContainer = new Container('flex flex-col flex-1 gap-2'); $localBrowserContainer->addComponent(new Label('Lokal', 'text-lg font-bold text-black mb-2')); $this->localFileBrowser = new FileBrowser( @@ -64,6 +67,7 @@ class SftpManagerTab }); // Right side: Remote file browser + // Remote-Browser: Spalte, Scrollen übernimmt der FileBrowser selbst $remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); // Header with title and new file button @@ -191,6 +195,25 @@ class SftpManagerTab return; } + // Require double click for navigation: + // first click selektiert nur, zweiter Klick (innerhalb kurzer Zeit) + // öffnet das Verzeichnis. + $now = microtime(true); + $doubleClickThreshold = 0.4; // Sekunden + + if ( + $sftpTab->lastRemoteClickPath !== $path || + ($now - $sftpTab->lastRemoteClickTime) > $doubleClickThreshold + ) { + $sftpTab->lastRemoteClickPath = $path; + $sftpTab->lastRemoteClickTime = $now; + return; + } + + // Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten + $sftpTab->lastRemoteClickPath = null; + $sftpTab->lastRemoteClickTime = 0.0; + $loadButton = new Button('Load', ''); $loadButton->setOnClickAsync( function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { @@ -343,7 +366,9 @@ class SftpManagerTab if (isset($result['success']) && $result['success']) { // Switch to SFTP tab on successful connection - $tabContainer->setActiveTab(1); + // Tab-Reihenfolge in App.php: + // 0 = Server, 1 = Kanban, 2 = SFTP Manager + $tabContainer->setActiveTab(2); $sftpTab->connectionStatusLabel->setText( 'Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')', diff --git a/examples/textarea_scroll_test.php b/examples/textarea_scroll_test.php new file mode 100644 index 0000000..8405a1c --- /dev/null +++ b/examples/textarea_scroll_test.php @@ -0,0 +1,33 @@ +setUseTextureCache(false); + +$root->addComponent($textArea); + +$window->setRoot($root); +$app->addWindow($window); +$app->run(); diff --git a/src/Framework/TextRenderer.php b/src/Framework/TextRenderer.php index 8e234c2..237b5e7 100644 --- a/src/Framework/TextRenderer.php +++ b/src/Framework/TextRenderer.php @@ -8,6 +8,8 @@ namespace PHPNative\Framework; class TextRenderer { + private const MAX_TEXTURE_SIZE = 16000; + private $renderer; private bool $initialized = false; private string $fontPath = ''; @@ -103,6 +105,58 @@ class TextRenderer return $this->loadFont($size); } + /** + * Ensure that rendered text does not exceed the maximum supported + * texture size by truncating very long strings. + */ + private function truncateToMaxTextureSize(string $text, $font): string + { + if ($text === '') { + return $text; + } + + $dimensions = ttf_size_text($font, $text); + $width = (int) ($dimensions['w'] ?? 0); + $height = (int) ($dimensions['h'] ?? 0); + + if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) { + return $text; + } + + $length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text); + if ($length <= 1) { + return $text; + } + + // Estimate how much we can keep based on width ratio + $scale = self::MAX_TEXTURE_SIZE / max($width, 1); + $targetLength = max(1, (int) floor($length * $scale)); + + $substr = \function_exists('mb_substr') ? 'mb_substr' : 'substr'; + $text = $substr($text, 0, $targetLength); + + // Safety: if still too large, iteratively shrink + for ($i = 0; $i < 3; $i++) { + $dimensions = ttf_size_text($font, $text); + $width = (int) ($dimensions['w'] ?? 0); + $height = (int) ($dimensions['h'] ?? 0); + + if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) { + break; + } + + $length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text); + if ($length <= 1) { + break; + } + + $targetLength = max(1, (int) floor($length * 0.7)); + $text = $substr($text, 0, $targetLength); + } + + return $text; + } + private function getScaledFontSize(int $size): int { if ($this->pixelRatio <= 1.0) { @@ -178,7 +232,14 @@ class TextRenderer if (strlen($text) < 1) { return; } - $surface = ttf_render_text_blended($renderFont, $text, $r, $g, $b); + // Truncate extremely long text so that the resulting texture + // stays within the GPU's max texture size. + $safeText = $this->truncateToMaxTextureSize($text, $renderFont); + if ($safeText === '') { + return; + } + + $surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b); if (!$surface) { return; } @@ -190,7 +251,7 @@ class TextRenderer } // Get text size - $textSize = ttf_size_text($baseFont, $text); + $textSize = ttf_size_text($baseFont, $safeText); // Render texture sdl_render_texture($this->renderer, $texture, [ @@ -230,7 +291,14 @@ class TextRenderer if (strlen($text) < 1) { return null; } - $surface = ttf_render_text_blended($renderFont, $text, $r, $g, $b); + // Truncate extremely long text so that the resulting texture + // stays within the GPU's max texture size. + $safeText = $this->truncateToMaxTextureSize($text, $renderFont); + if ($safeText === '') { + return null; + } + + $surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b); if (!$surface) { return null; } @@ -240,7 +308,7 @@ class TextRenderer return null; } - $dimensions = ttf_size_text($baseFont, $text); + $dimensions = ttf_size_text($baseFont, $safeText); sdl_set_texture_blend_mode($texture, SDL_BLENDMODE_BLEND); if (\function_exists('sdl_set_texture_alpha_mod')) { diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index d473055..2a6024d 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -30,7 +30,7 @@ class Container extends Component private float $scrollStartY = 0; // Scrollbar dimensions - private const SCROLLBAR_WIDTH = 12; + private const SCROLLBAR_WIDTH = 16; private const SCROLLBAR_MIN_SIZE = 20; public function __construct(string $style = '') diff --git a/src/Ui/Widget/FileBrowser.php b/src/Ui/Widget/FileBrowser.php index c8808ba..64ad59e 100644 --- a/src/Ui/Widget/FileBrowser.php +++ b/src/Ui/Widget/FileBrowser.php @@ -12,10 +12,15 @@ class FileBrowser extends Container private $onRenameFile = null; private $onDeleteFile = null; private bool $isRemote = false; + private ?string $lastClickPath = null; + private float $lastClickTime = 0.0; public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '') { - parent::__construct('w-full flex flex-col gap-2 ' . $style); + // Root-Container füllt die verfügbare Höhe im Eltern-Layout (flex-1), + // damit die Tabelle unten eine klar begrenzte Höhe bekommt und + // ihr Body sinnvoll scrollen kann. + parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style); $this->currentPath = $initialPath; $this->isRemote = $isRemote; @@ -24,8 +29,10 @@ 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); - // File table with explicit flex-1 for scrolling - $this->fileTable = new Table(''); + // File table, die den verbleibenden Platz im FileBrowser nutzt. + // Das eigentliche Scrollen passiert im Body-Container der Table + // (overflow-auto dort). + $this->fileTable = new Table(' flex-1'); $this->fileTable->setColumns([ ['key' => 'type', 'title' => 'Typ', 'width' => 60], ['key' => 'name', 'title' => 'Name'], @@ -106,13 +113,39 @@ class FileBrowser extends Container // Handle row selection $fileBrowser = $this; $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) { - if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { - // Navigate to directory - $fileBrowser->loadDirectory($row['path']); - } elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) { - // File selected - ($fileBrowser->onFileSelect)($row['path'], $row); + if (!$row || empty($row['path'])) { + return; } + + $path = $row['path']; + + // Immer Auswahl-Callback auslösen (z.B. für Upload) + if ($fileBrowser->onFileSelect !== null) { + ($fileBrowser->onFileSelect)($path, $row); + } + + // Nur Verzeichnisse navigieren – per Doppelklick + if (!($row['isDir'] ?? false)) { + return; + } + + $now = microtime(true); + $doubleClickThreshold = 0.4; // Sekunden + + if ( + $fileBrowser->lastClickPath !== $path || + ($now - $fileBrowser->lastClickTime) > $doubleClickThreshold + ) { + // Erster Klick: nur merken + $fileBrowser->lastClickPath = $path; + $fileBrowser->lastClickTime = $now; + return; + } + + // Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten + $fileBrowser->lastClickPath = null; + $fileBrowser->lastClickTime = 0.0; + $fileBrowser->loadDirectory($path); }); } diff --git a/src/Ui/Widget/Table.php b/src/Ui/Widget/Table.php index 30affa9..86f6db5 100644 --- a/src/Ui/Widget/Table.php +++ b/src/Ui/Widget/Table.php @@ -17,7 +17,10 @@ class Table extends Container public function __construct(string $style = '') { - parent::__construct('flex flex-col w-full' . $style); + // Table selbst ist kein Flex-Container; sie wird als Kind + // in einem flex-Layout benutzt (z.B. flex-1), aber intern + // werden Header und Body ganz normal vertikal gestapelt. + parent::__construct('w-full' . $style); // Create header container $this->headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400'); @@ -88,6 +91,18 @@ class Table extends Container if ($preserveScroll && $scrollPosition !== null) { $this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']); } + + // Debug-Ausgabe: Größe des Tabellen-Body und Viewport + $bodySize = $this->bodyContainer->getContentSize(); + $bodyViewport = $this->bodyContainer->getContentViewport(); + error_log( + sprintf( + 'Table body debug: rows=%d, contentHeight=%d, viewportHeight=%d', + count($data), + (int) ($bodySize['height'] ?? 0), + (int) $bodyViewport->height, + ), + ); } /** diff --git a/src/Ui/Widget/TextArea.php b/src/Ui/Widget/TextArea.php index 7f3ee84..1b1db1f 100644 --- a/src/Ui/Widget/TextArea.php +++ b/src/Ui/Widget/TextArea.php @@ -10,6 +10,9 @@ use PHPNative\Tailwind\Style\Text; class TextArea extends Container { + private const SCROLLBAR_WIDTH = 12; + private const SCROLLBAR_MIN_SIZE = 20; + private array $lines = ['']; private int $cursorLine = 0; private int $cursorCol = 0; @@ -23,6 +26,10 @@ class TextArea extends Container private int $selectionEndLine = -1; private int $selectionEndCol = -1; + private bool $isDraggingScrollbar = false; + private float $dragStartY = 0.0; + private int $scrollStartOffsetY = 0; + public function __construct( public string $value = '', public string $placeholder = '', @@ -173,6 +180,48 @@ class TextArea extends Container return false; } + // Handle click on scrollbar (if present) + $contentHeight = count($this->lines) * $this->lineHeight; + $viewportHeight = $this->contentViewport->height; + + if ($contentHeight > $viewportHeight) { + $scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH; + $scrollbarY = $this->contentViewport->y; + + if ( + $mouseX >= $scrollbarX && + $mouseX <= ($scrollbarX + self::SCROLLBAR_WIDTH) && + $mouseY >= $scrollbarY && + $mouseY <= ($scrollbarY + $viewportHeight) + ) { + $scrollbarHeight = $viewportHeight; + $thumbHeight = max( + self::SCROLLBAR_MIN_SIZE, + ($viewportHeight / $contentHeight) * $scrollbarHeight, + ); + $maxScroll = $contentHeight - $viewportHeight; + + if ($maxScroll > 0) { + $thumbRange = $scrollbarHeight - $thumbHeight; + + // Position scroll to where user clicked (center thumb on click) + $clickPos = $mouseY - $scrollbarY - ($thumbHeight / 2); + $clickPos = max(0, min($thumbRange, $clickPos)); + $scrollRatio = $thumbRange > 0 ? ($clickPos / $thumbRange) : 0; + $this->scrollOffsetY = (int) max(0, min($maxScroll, $scrollRatio * $maxScroll)); + // Nur neu rendern, kein neues Layout nötig + $this->markDirty(false, false); + + // Start drag + $this->isDraggingScrollbar = true; + $this->dragStartY = $mouseY; + $this->scrollStartOffsetY = $this->scrollOffsetY; + } + + return true; + } + } + if ( $mouseX >= $this->viewport->x && $mouseX <= ($this->viewport->x + $this->viewport->width) && @@ -205,6 +254,95 @@ class TextArea extends Container return false; } + public function handleMouseMove(float $mouseX, float $mouseY): void + { + parent::handleMouseMove($mouseX, $mouseY); + + if (!$this->visible || !$this->isDraggingScrollbar) { + return; + } + + $contentHeight = count($this->lines) * $this->lineHeight; + $viewportHeight = $this->contentViewport->height; + + if ($contentHeight <= $viewportHeight) { + return; + } + + $scrollbarHeight = $viewportHeight; + $thumbHeight = max( + self::SCROLLBAR_MIN_SIZE, + ($viewportHeight / $contentHeight) * $scrollbarHeight, + ); + + $maxScroll = $contentHeight - $viewportHeight; + $thumbRange = $scrollbarHeight - $thumbHeight; + + if ($thumbRange <= 0 || $maxScroll <= 0) { + return; + } + + $deltaY = $mouseY - $this->dragStartY; + $scrollRatioDelta = $deltaY / $thumbRange; + $newScroll = $this->scrollStartOffsetY + ($scrollRatioDelta * $maxScroll); + + $newOffset = (int) max(0, min($maxScroll, $newScroll)); + + if ($newOffset !== $this->scrollOffsetY) { + $this->scrollOffsetY = $newOffset; + // Nur neu rendern, kein neues Layout nötig + $this->markDirty(false, false); + } + } + + public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void + { + parent::handleMouseRelease($mouseX, $mouseY, $button); + $this->isDraggingScrollbar = false; + } + + 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 false; + } + + $contentHeight = count($this->lines) * $this->lineHeight; + $maxScroll = max(0, $contentHeight - $this->contentViewport->height); + + if ($maxScroll <= 0) { + return false; + } + + // Schneller scrollen: mehrere Zeilen pro Tick + $scrollStep = max($this->lineHeight * 3, 20); + + $oldOffset = $this->scrollOffsetY; + $this->scrollOffsetY = (int) max( + 0, + min($maxScroll, $this->scrollOffsetY + ($deltaY * $scrollStep)), + ); + + if ($this->scrollOffsetY === $oldOffset) { + // Keine tatsächliche Bewegung – parent darf evtl. weiter scrollen + return false; + } + + // Nur neu rendern, kein neues Layout nötig + $this->markDirty(false, false); + + return true; + } + public function layout(null|TextRenderer $textRenderer = null): void { parent::layout($textRenderer); @@ -274,6 +412,42 @@ class TextArea extends Container $this->renderCursor($window, $textRenderer); } + // Draw simple vertical scrollbar if content is higher than viewport + $contentHeight = count($this->lines) * $this->lineHeight; + $viewportHeight = $this->contentViewport->height; + + if ($contentHeight > $viewportHeight) { + $scrollbarWidth = self::SCROLLBAR_WIDTH; + $scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - $scrollbarWidth; + $scrollbarY = $this->contentViewport->y; + + // Track + sdl_set_render_draw_color($window, 220, 220, 220, 255); + sdl_render_fill_rect($window, [ + 'x' => (int) $scrollbarX, + 'y' => (int) $scrollbarY, + 'w' => (int) $scrollbarWidth, + 'h' => (int) $viewportHeight, + ]); + + // Thumb + $thumbHeight = max( + 20, + ($viewportHeight / $contentHeight) * $viewportHeight, + ); + $maxScroll = $contentHeight - $viewportHeight; + $scrollRatio = $maxScroll > 0 ? ($this->scrollOffsetY / $maxScroll) : 0; + $thumbY = $scrollbarY + ($scrollRatio * ($viewportHeight - $thumbHeight)); + + sdl_set_render_draw_color($window, 120, 120, 120, 230); + sdl_render_fill_rect($window, [ + 'x' => (int) ($scrollbarX + 1), + 'y' => (int) $thumbY, + 'w' => (int) ($scrollbarWidth - 2), + 'h' => (int) $thumbHeight, + ]); + } + // Update cursor blink $this->cursorBlinkTimer++; if ($this->cursorBlinkTimer >= 30) {