From d7e3a95f9bdc2f20d880121b63dad0648cf0c296 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Wed, 12 Nov 2025 11:56:45 +0100 Subject: [PATCH] Backup --- examples/ServerManager/App.php | 92 ++ examples/ServerManager/REFACTORING_PLAN.md | 61 ++ .../ServerManager/Services/HetznerService.php | 60 ++ examples/ServerManager/UI/MenuBarBuilder.php | 28 + examples/ServerManager/UI/ServerListTab.php | 277 ++++++ examples/ServerManager/UI/SettingsModal.php | 118 +++ examples/ServerManager/UI/SftpManagerTab.php | 925 ++++++++++++++++++ examples/server_manager.php | 51 + examples/shadow_debug.php | 54 + examples/shadow_test.php | 74 ++ examples/windows_app_example.php | 32 +- src/Framework/TextRenderer.php | 1 - src/Tailwind/Data/Icon.php | 4 + src/Tailwind/Parser/Shadow.php | 30 + src/Tailwind/Style/Color.php | 10 +- src/Tailwind/Style/Shadow.php | 13 + src/Tailwind/StyleParser.php | 3 + src/Ui/Component.php | 173 +++- src/Ui/Widget/Button.php | 10 +- src/Ui/Widget/FileBrowser.php | 85 +- src/Ui/Widget/Label.php | 7 +- src/Ui/Widget/Menu.php | 2 +- src/Ui/Widget/MenuBar.php | 14 + src/Ui/Widget/Modal.php | 6 +- src/Ui/Widget/Table.php | 37 +- src/Ui/Widget/TextArea.php | 694 +++++++++++++ 26 files changed, 2781 insertions(+), 80 deletions(-) create mode 100644 examples/ServerManager/App.php create mode 100644 examples/ServerManager/REFACTORING_PLAN.md create mode 100644 examples/ServerManager/Services/HetznerService.php create mode 100644 examples/ServerManager/UI/MenuBarBuilder.php create mode 100644 examples/ServerManager/UI/ServerListTab.php create mode 100644 examples/ServerManager/UI/SettingsModal.php create mode 100644 examples/ServerManager/UI/SftpManagerTab.php create mode 100644 examples/server_manager.php create mode 100644 examples/shadow_debug.php create mode 100644 examples/shadow_test.php create mode 100644 src/Tailwind/Parser/Shadow.php create mode 100644 src/Tailwind/Style/Shadow.php create mode 100644 src/Ui/Widget/TextArea.php diff --git a/examples/ServerManager/App.php b/examples/ServerManager/App.php new file mode 100644 index 0000000..32f6b2c --- /dev/null +++ b/examples/ServerManager/App.php @@ -0,0 +1,92 @@ +app = new Application(); + $this->window = new Window('Server Manager', 800, 600); + $this->settings = new Settings('ServerManager'); + } + + public function run(): void + { + // Main container + $mainContainer = new Container('flex flex-col bg-gray-100'); + + // Status label (referenced by tabs) + $statusLabel = new Label( + text: 'Fenster: ' . $this->window->getViewport()->windowWidth . 'x' . $this->window->getViewport()->windowHeight, + style: 'basis-4/8 text-black' + ); + + // Settings variables (simple variables work better with async than object properties) + $currentApiKey = $this->settings->get('api_key', ''); + $currentPrivateKeyPath = $this->settings->get('private_key_path', ''); + $currentRemoteStartDir = $this->settings->get('remote_start_dir', '/'); + + // Create menu bar first + $menuBar = new \PHPNative\Ui\Widget\MenuBar(); + $mainContainer->addComponent($menuBar); + + // Create settings modal with the real menu bar + $settingsModal = new SettingsModal($this->settings, $menuBar, $currentApiKey, $currentPrivateKeyPath, $currentRemoteStartDir); + $mainContainer->addComponent($settingsModal->getModal()); + + // Build menu bar menus after modal is created + MenuBarBuilder::buildMenus($this->app, $menuBar, $settingsModal); + + // Tab container + $tabContainer = new TabContainer('flex-1'); + + // Create tabs + $serverListTab = new ServerListTab($currentApiKey, $currentPrivateKeyPath, $tabContainer, $statusLabel); + $sftpManagerTab = new SftpManagerTab($currentApiKey, $currentPrivateKeyPath, $currentRemoteStartDir, $serverListTab, $tabContainer, $statusLabel); + + // Add tabs + $tabContainer->addTab('Server', $serverListTab->getContainer()); + $tabContainer->addTab('SFTP Manager', $sftpManagerTab->getContainer()); + + $mainContainer->addComponent($tabContainer); + + // Add modals to main container (must be at top level for overlay to work) + $mainContainer->addComponent($sftpManagerTab->getEditModal()); + $mainContainer->addComponent($sftpManagerTab->getFilenameModal()); + $mainContainer->addComponent($sftpManagerTab->getRenameModal()); + $mainContainer->addComponent($sftpManagerTab->getDeleteConfirmModal()); + + // Status bar + $statusBar = new StatusBar(); + $statusBar->addSegment($statusLabel); + $statusBar->addSegment(new Label('v1.0', 'basis-1/8 text-center text-black border-l border-gray-300')); + $statusBar->addSegment(new Label( + 'PHPNative Framework', + 'basis-3/8 text-right text-black pr-2 border-l border-gray-300' + )); + $mainContainer->addComponent($statusBar); + + // Set window content and run + $this->window->setRoot($mainContainer); + $this->app->addWindow($this->window); + $this->app->run(); + } +} diff --git a/examples/ServerManager/REFACTORING_PLAN.md b/examples/ServerManager/REFACTORING_PLAN.md new file mode 100644 index 0000000..7270a94 --- /dev/null +++ b/examples/ServerManager/REFACTORING_PLAN.md @@ -0,0 +1,61 @@ +# Server Manager Refactoring Plan + +## Aktuelle Struktur +- `windows_app_example.php` - 657 Zeilen, alles in einer Datei + +## Geplante Struktur + +### 1. Services (Backend-Logik) +**ServerManager/Services/HetznerService.php** ✓ ERSTELLT +- `loadServersAsync()` - API Aufruf +- `generateTestData()` - Test-Daten generieren + +### 2. UI Components (Wiederverwendbare UI-Module) + +**ServerManager/UI/SettingsModal.php** +- Erstellt Modal-Dialog für Einstellungen +- Verwaltet API Key und Private Key Pfad +- Speichert Einstellungen + +**ServerManager/UI/MenuBarBuilder.php** +- Erstellt Menüleiste +- Datei-Menü (Neu, Öffnen, Beenden) +- Einstellungen-Menü + +**ServerManager/UI/ServerListTab.php** +- Server-Tabelle mit Suche +- Refresh-Button +- Detail-Panel mit Server-Info +- SFTP und SSH Buttons + +**ServerManager/UI/SftpManagerTab.php** +- Lokaler File-Browser (links) +- Remote File-Browser (rechts) +- Connection Status +- Navigation Handler + +### 3. Main Application + +**ServerManager/App.php** +- Initialisiert Application und Window +- Lädt alle Module +- Koordiniert zwischen Komponenten +- Main event loop + +**server_manager.php** (neuer Entry Point) +- Einfacher Bootstrap +- Lädt Autoloader +- Startet App + +## Vorteile +- Bessere Wartbarkeit +- Wiederverwendbare Komponenten +- Klare Verantwortlichkeiten +- Einfacher zu testen +- Bessere Übersicht + +## Nächste Schritte +1. ✓ HetznerService erstellen +2. Weitere Module nacheinander erstellen +3. Neue server_manager.php als Entry Point +4. Alte windows_app_example.php als Backup behalten diff --git a/examples/ServerManager/Services/HetznerService.php b/examples/ServerManager/Services/HetznerService.php new file mode 100644 index 0000000..2fca374 --- /dev/null +++ b/examples/ServerManager/Services/HetznerService.php @@ -0,0 +1,60 @@ + 'Kein API-Key konfiguriert']; + } + + $hetznerClient = new HetznerAPIClient($apiKey); + $servers = []; + + foreach ($hetznerClient->servers()->all() as $server) { + $servers[] = [ + 'id' => $server->id, + 'name' => $server->name, + 'status' => $server->status, + 'type' => $server->serverType->name, + 'ipv4' => $server->publicNet->ipv4->ip, + ]; + } + + return [ + 'success' => true, + 'servers' => $servers, + 'count' => count($servers), + ]; + } catch (\Exception $e) { + return ['error' => 'Exception: ' . $e->getMessage()]; + } + } + + /** + * Generate test server data for development + */ + public static function generateTestData(int $count = 63): array + { + $testData = []; + for ($i = 1; $i <= $count; $i++) { + $testData[] = [ + 'id' => $i, + 'name' => "Server-{$i}", + 'status' => ($i % 3) === 0 ? 'stopped' : 'running', + 'type' => 'cx' . (11 + (($i % 4) * 10)), + 'ipv4' => sprintf('192.168.%d.%d', floor($i / 255), $i % 255), + ]; + } + return $testData; + } +} diff --git a/examples/ServerManager/UI/MenuBarBuilder.php b/examples/ServerManager/UI/MenuBarBuilder.php new file mode 100644 index 0000000..e54cf24 --- /dev/null +++ b/examples/ServerManager/UI/MenuBarBuilder.php @@ -0,0 +1,28 @@ +addItem('Beenden', function () use ($app) { + exit(0); + }); + + // Settings Menu + $settingsMenu = new Menu(title: 'Einstellungen'); + $settingsMenu->addItem('Optionen', function () use ($settingsModal) { + $settingsModal->show(); + }); + + $menuBar->addMenu($fileMenu); + $menuBar->addMenu($settingsMenu); + } +} diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php new file mode 100644 index 0000000..9ce9e26 --- /dev/null +++ b/examples/ServerManager/UI/ServerListTab.php @@ -0,0 +1,277 @@ +statusLabel = $statusLabel; + $currentApiKey = &$apiKey; + $currentPrivateKeyPath = &$privateKeyPath; + + // Create main tab container + $this->tab = new Container('flex flex-row p-4 gap-4'); + + // Left side: Table with search and refresh + $leftSide = new Container('flex flex-col gap-2 flex-1'); + + // Refresh button + $this->refreshButton = new Button( + 'Server aktualisieren', + 'flex flex-row gap-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700', + ); + $refreshIcon = new Icon(IconName::sync, 16, 'text-white'); + $this->refreshButton->setIcon($refreshIcon); + $leftSide->addComponent($this->refreshButton); + + // Search input + $this->searchInput = new TextInput( + 'Suche...', + 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black mb-2', + ); + $leftSide->addComponent($this->searchInput); + + // Table + $this->table = new Table(style: ' flex-1'); + $this->table->setColumns([ + ['key' => 'id', 'title' => 'ID', 'width' => 100], + ['key' => 'name', 'title' => 'Name', 'width' => 400], + ['key' => 'status', 'title' => 'Status', 'width' => 120], + ['key' => 'type', 'title' => 'Typ', 'width' => 120], + ['key' => 'ipv4', 'title' => 'IPv4'], + ]); + + // Load initial test data + $this->currentServerData = HetznerService::generateTestData(); + $this->table->setData($this->currentServerData); + + $leftSide->addComponent($this->table); + + // Right side: Detail panel + $detailPanel = new Container('flex flex-col gap-3 w-120 bg-white border-2 border-gray-300 rounded p-4'); + $detailTitle = new Label('Server Details', 'text-xl font-bold text-black mb-2'); + $detailPanel->addComponent($detailTitle); + + $this->detailId = new Label('-', 'text-sm text-gray-900 font-mono'); + $this->detailName = new Label('-', 'text-lg font-semibold text-black'); + $this->detailStatus = new Label('-', 'text-sm text-gray-700'); + $this->detailType = new Label('-', 'text-sm text-gray-700'); + $this->detailIpv4 = new Label('-', 'text-sm text-gray-700 font-mono'); + + $detailPanel->addComponent(new Label('ID:', 'text-xs text-gray-500')); + $detailPanel->addComponent($this->detailId); + $detailPanel->addComponent(new Label('Name:', 'text-xs text-gray-500 mt-2')); + $detailPanel->addComponent($this->detailName); + $detailPanel->addComponent(new Label('Status:', 'text-xs text-gray-500 mt-2')); + $detailPanel->addComponent($this->detailStatus); + $detailPanel->addComponent(new Label('Typ:', 'text-xs text-gray-500 mt-2')); + $detailPanel->addComponent($this->detailType); + $detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2')); + $detailPanel->addComponent($this->detailIpv4); + + // SFTP Manager Button (handler will be set by SftpManagerTab) + $this->sftpButton = new Button( + 'SFTP Manager öffnen', + 'w-full border border-gray-300 rounded px-3 py-2 flex shadow-lg flex-row gap-2 bg-green-300 text-black mb-2', + ); + $sftpIcon = new Icon(IconName::folder, 16, 'text-white'); + $this->sftpButton->setIcon($sftpIcon); + + $detailPanel->addComponent($this->sftpButton); + + // SSH Terminal Button + $this->sshTerminalButton = new Button( + 'SSH Terminal öffnen', + 'w-full border border-gray-300 rounded px-3 py-2 flex flex-row gap-2 bg-lime-300 text-black mb-2', + ); + $sshTerminalIcon = new Icon(IconName::terminal, 16, 'text-white'); + $this->sshTerminalButton->setIcon($sshTerminalIcon); + + $serverListTab = $this; + $this->sshTerminalButton->setOnClick(function () use ($serverListTab, &$currentPrivateKeyPath) { + if ($serverListTab->selectedServer === null) { + $serverListTab->statusLabel->setText('Kein Server ausgewählt'); + return; + } + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + $serverListTab->statusLabel->setText('Private Key Pfad nicht konfiguriert'); + return; + } + + $host = $serverListTab->selectedServer['ipv4']; + $keyPath = escapeshellarg($currentPrivateKeyPath); + $sshCommand = "ssh -i {$keyPath} root@{$host}"; + + $terminals = [ + 'gnome-terminal -- ' . $sshCommand, + 'konsole -e ' . $sshCommand, + 'xterm -e ' . $sshCommand, + 'x-terminal-emulator -e ' . $sshCommand, + ]; + + $opened = false; + foreach ($terminals as $terminalCmd) { + exec($terminalCmd . ' > /dev/null 2>&1 &', $output, $returnCode); + if ($returnCode === 0) { + $opened = true; + $serverListTab->statusLabel->setText( + 'SSH Terminal geöffnet für ' . $serverListTab->selectedServer['name'], + ); + break; + } + } + + if (!$opened) { + $serverListTab->statusLabel->setText('Konnte kein Terminal öffnen. SSH Befehl: ' . $sshCommand); + } + }); + + $detailPanel->addComponent($this->sshTerminalButton); + + $this->tab->addComponent($leftSide); + $this->tab->addComponent($detailPanel); + + // Setup event handlers + $this->setupEventHandlers($currentApiKey, $currentPrivateKeyPath); + } + + private function setupEventHandlers(string &$currentApiKey, string &$currentPrivateKeyPath): void + { + // Table row selection + $serverListTab = $this; + $this->table->setOnRowSelect(function ($index, $row) use ($serverListTab) { + if ($row) { + $serverListTab->statusLabel->setText("Server: {$row['name']} - {$row['status']} ({$row['ipv4']})"); + + $serverListTab->detailId->setText("#{$row['id']}"); + $serverListTab->detailName->setText($row['name']); + $serverListTab->detailStatus->setText($row['status']); + $serverListTab->detailType->setText($row['type']); + $serverListTab->detailIpv4->setText($row['ipv4']); + + $serverListTab->selectedServer = $row; + } + }); + + // Search functionality + $this->searchInput->setOnChange(function ($value) use ($serverListTab) { + $searchTerm = strtolower(trim($value)); + + if (empty($searchTerm)) { + $serverListTab->table->setData($serverListTab->currentServerData); + } else { + $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ($searchTerm) { + return str_contains(strtolower($row['name']), $searchTerm); + }); + $serverListTab->table->setData(array_values($filteredData)); + } + }); + + // Refresh button - use reference to apiKey variable + $this->refreshButton->setOnClickAsync( + function () use (&$currentApiKey) { + try { + if (empty($currentApiKey)) { + return ['error' => 'Kein API-Key konfiguriert']; + } + + $hetznerClient = new \LKDev\HetznerCloud\HetznerAPIClient($currentApiKey); + $servers = []; + + foreach ($hetznerClient->servers()->all() as $server) { + $servers[] = [ + 'id' => $server->id, + 'name' => $server->name, + 'status' => $server->status, + 'type' => $server->serverType->name, + 'ipv4' => $server->publicNet->ipv4->ip, + ]; + } + + return [ + 'success' => true, + 'servers' => $servers, + 'count' => count($servers), + ]; + } catch (\Exception $e) { + return ['error' => 'Exception: ' . $e->getMessage()]; + } + }, + function ($result) use ($serverListTab) { + if (is_array($result)) { + if (isset($result['error'])) { + $serverListTab->statusLabel->setText('Fehler: ' . $result['error']); + echo "Error: {$result['error']}\n"; + } elseif (isset($result['success'], $result['servers'])) { + $serverListTab->currentServerData = $result['servers']; + + $searchTerm = strtolower(trim($serverListTab->searchInput->getValue())); + if (empty($searchTerm)) { + $serverListTab->table->setData($serverListTab->currentServerData); + } else { + $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( + $searchTerm, + ) { + return str_contains(strtolower($row['name']), $searchTerm); + }); + $serverListTab->table->setData(array_values($filteredData)); + } + + $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); + echo "Success: {$result['count']} servers loaded\n"; + } + } + }, + function ($error) use ($serverListTab) { + $errorMsg = is_object($error) && method_exists($error, 'getMessage') + ? $error->getMessage() + : ((string) $error); + $serverListTab->statusLabel->setText('Async Fehler: ' . $errorMsg); + echo "Async error: {$errorMsg}\n"; + }, + ); + } + + public function getContainer(): Container + { + return $this->tab; + } + + public function getSftpButton(): Button + { + return $this->sftpButton; + } +} diff --git a/examples/ServerManager/UI/SettingsModal.php b/examples/ServerManager/UI/SettingsModal.php new file mode 100644 index 0000000..455ae54 --- /dev/null +++ b/examples/ServerManager/UI/SettingsModal.php @@ -0,0 +1,118 @@ +apiKeyInput = new TextInput('API Key', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black'); + $this->privateKeyPathInput = new TextInput( + 'Private Key Path', + 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black' + ); + $this->remoteStartDirInput = new TextInput( + 'Remote Start Directory', + 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black' + ); + + // Set initial values + $this->apiKeyInput->setValue($apiKey); + $this->privateKeyPathInput->setValue($privateKeyPath); + $this->remoteStartDirInput->setValue($remoteStartDir); + + // Create modal dialog + $modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3'); + $modalDialog->addComponent(new Label('API Einstellungen', 'text-xl font-bold text-black')); + $modalDialog->addComponent(new Label( + 'Bitte gib deinen API Key und den Pfad zum Private Key für SSH-Verbindungen ein.', + 'text-sm text-gray-700' + )); + + // API Key field + $fieldContainer = new Container('flex flex-col gap-1'); + $fieldContainer->addComponent(new Label('API Key', 'text-sm text-gray-600')); + $fieldContainer->addComponent($this->apiKeyInput); + $modalDialog->addComponent($fieldContainer); + + // Private Key field + $privateKeyFieldContainer = new Container('flex flex-col gap-1'); + $privateKeyFieldContainer->addComponent(new Label('Private Key Pfad', 'text-sm text-gray-600')); + $privateKeyFieldContainer->addComponent($this->privateKeyPathInput); + $modalDialog->addComponent($privateKeyFieldContainer); + + // Remote Start Directory field + $remoteStartDirFieldContainer = new Container('flex flex-col gap-1'); + $remoteStartDirFieldContainer->addComponent(new Label('Remote Start Verzeichnis (z.B. /var/www)', 'text-sm text-gray-600')); + $remoteStartDirFieldContainer->addComponent($this->remoteStartDirInput); + $modalDialog->addComponent($remoteStartDirFieldContainer); + + // Buttons + $buttonRow = new Container('flex flex-row gap-2 justify-end'); + $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300'); + $saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center'); + $saveIcon = new Icon(IconName::save, 18, 'text-white mr-2'); + $saveButton->setIcon($saveIcon); + $buttonRow->addComponent($cancelButton); + $buttonRow->addComponent($saveButton); + $modalDialog->addComponent($buttonRow); + + $this->modal = new Modal($modalDialog, 'flex items-center justify-center bg-black', 200); + + // Setup button handlers - use references directly in closures + $settingsModal = $this; + $apiKeyInputRef = $this->apiKeyInput; + $privateKeyPathInputRef = $this->privateKeyPathInput; + $remoteStartDirInputRef = $this->remoteStartDirInput; + + $cancelButton->setOnClick(function () use ($menuBar, $settingsModal) { + $menuBar->closeAllMenus(); + $settingsModal->hide(); + }); + + $saveButton->setOnClick(function () use ($menuBar, $settingsModal, &$apiKey, &$privateKeyPath, &$remoteStartDir, $settings, $apiKeyInputRef, $privateKeyPathInputRef, $remoteStartDirInputRef) { + $apiKey = trim($apiKeyInputRef->getValue()); + $privateKeyPath = trim($privateKeyPathInputRef->getValue()); + $remoteStartDir = trim($remoteStartDirInputRef->getValue()); + + $settings->set('api_key', $apiKey); + $settings->set('private_key_path', $privateKeyPath); + $settings->set('remote_start_dir', $remoteStartDir); + $settings->save(); + + $menuBar->closeAllMenus(); + $settingsModal->hide(); + }); + } + + public function getModal(): Modal + { + return $this->modal; + } + + public function show(): void + { + $this->modal->setVisible(true); + } + + public function hide(): void + { + $this->modal->setVisible(false); + } +} diff --git a/examples/ServerManager/UI/SftpManagerTab.php b/examples/ServerManager/UI/SftpManagerTab.php new file mode 100644 index 0000000..1e40f26 --- /dev/null +++ b/examples/ServerManager/UI/SftpManagerTab.php @@ -0,0 +1,925 @@ +tab = new Container('flex flex-row p-4 gap-4 bg-gray-50'); + $currentPrivateKeyPath = &$privateKeyPath; + $currentRemoteStartDir = &$remoteStartDir; + + // Left side: Local file browser + $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( + getcwd(), + false, + 'flex-1 bg-white border-2 border-gray-300 rounded p-2', + ); + $localBrowserContainer->addComponent($this->localFileBrowser); + + // Right side: Remote file browser + $remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); + + // Header with title and new file button + $remoteHeader = new Container('flex flex-row items-center gap-2 mb-2'); + $remoteHeader->addComponent(new Label('Remote', 'text-lg font-bold text-black flex-1')); + + $newFileButton = new Button('', 'px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center'); + $newFileIcon = new Icon(\PHPNative\Tailwind\Data\Icon::plus, 16, 'text-white'); + $newFileButton->setIcon($newFileIcon); + $remoteHeader->addComponent($newFileButton); + $remoteBrowserContainer->addComponent($remoteHeader); + + $this->remoteFileBrowser = new FileBrowser('/', true, 'flex-1 bg-white border-2 border-gray-300 rounded p-2'); + $remoteBrowserContainer->addComponent($this->remoteFileBrowser); + + // Connection status label + $this->connectionStatusLabel = new Label('Nicht verbunden', 'text-sm text-gray-600 italic mb-2'); + $remoteBrowserContainer->addComponent($this->connectionStatusLabel); + + $this->tab->addComponent($localBrowserContainer); + $this->tab->addComponent($remoteBrowserContainer); + + // Setup remote navigation handler + $this->setupRemoteNavigationHandler($currentPrivateKeyPath, $serverListTab, $statusLabel); + + // Setup SFTP connection handler + $this->setupSftpConnectionHandler($currentPrivateKeyPath, $currentRemoteStartDir, $serverListTab, $tabContainer, $statusLabel); + + // Create edit modal + $this->createEditModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + + // Create filename input modal + $this->createFilenameModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + + // Create rename modal + $this->createRenameModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + + // Create delete confirmation modal + $this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); + + // Setup file edit handler + $this->remoteFileBrowser->setOnEditFile(function ($path, $row) use ( + &$currentPrivateKeyPath, + $serverListTab, + $statusLabel, + ) { + $this->handleFileEdit($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + + // Setup new file button handler + $sftpTab = $this; + $newFileButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $sftpTab->handleNewFile($currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + + // Setup rename handler + $this->remoteFileBrowser->setOnRenameFile(function ($path, $row) use ( + $sftpTab, + &$currentPrivateKeyPath, + $serverListTab, + $statusLabel, + ) { + $sftpTab->handleFileRename($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + + // Setup delete handler + $this->remoteFileBrowser->setOnDeleteFile(function ($path, $row) use ( + $sftpTab, + &$currentPrivateKeyPath, + $serverListTab, + $statusLabel, + ) { + $sftpTab->handleFileDelete($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + } + + private function setupRemoteNavigationHandler( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + $sftpTab = $this; + // Create reference to selectedServer array outside of closure + $selectedServerRef = &$serverListTab->selectedServer; + + $this->remoteFileBrowser->setOnFileSelect(function ($path, $row) use ( + $sftpTab, + &$currentPrivateKeyPath, + &$selectedServerRef, + $statusLabel, + ) { + if (!isset($row['isDir']) || !$row['isDir']) { + return; + } + + $loadButton = new Button('Load', ''); + $loadButton->setOnClickAsync( + function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { + if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { + return ['error' => 'Not connected']; + } + + // Copy to local variable for async context + $selectedServer = $selectedServerRef; + + try { + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login failed']; + } + + $files = $sftp->nlist($path); + if ($files === false) { + return ['error' => 'Cannot read directory']; + } + + $fileList = []; + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $fullPath = rtrim($path, '/') . '/' . $file; + $stat = $sftp->stat($fullPath); + $fileList[] = [ + 'name' => $file, + 'path' => $fullPath, + 'isDir' => ($stat['type'] ?? 0) === 2, + 'size' => $stat['size'] ?? 0, + 'mtime' => $stat['mtime'] ?? 0, + ]; + } + + return ['success' => true, 'path' => $path, 'files' => $fileList]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel) { + if (isset($result['error'])) { + $statusLabel->setText('SFTP Fehler: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $sftpTab->remoteFileBrowser->setPath($result['path']); + $sftpTab->remoteFileBrowser->setFileData($result['files']); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('SFTP Error: ' . $errorMsg); + }, + ); + + $loadButton->handleMouseClick(0, 0, 0); + }); + } + + private function setupSftpConnectionHandler( + string &$currentPrivateKeyPath, + string &$currentRemoteStartDir, + ServerListTab $serverListTab, + \PHPNative\Ui\Widget\TabContainer $tabContainer, + Label $statusLabel, + ): void { + $sftpTab = $this; + + // Create a reference to selectedServer that we can update + $selectedServerRef = &$serverListTab->selectedServer; + + // Set up async handler for SFTP connection (tab switch happens in success callback) + $serverListTab->getSftpButton()->setOnClickAsync( + function () use (&$currentPrivateKeyPath, &$currentRemoteStartDir, &$selectedServerRef) { + if ($selectedServerRef === null) { + return ['error' => 'Kein Server ausgewählt']; + } + + // Copy the selected server data to a local variable for use in async context + $selectedServer = $selectedServerRef; + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + return ['error' => 'Private Key Pfad nicht konfiguriert oder Datei nicht gefunden']; + } + + try { + $ssh = new \phpseclib3\Net\SSH2($selectedServer['ipv4']); + $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); + + if (!$ssh->login('root', $key)) { + return ['error' => 'SSH Login fehlgeschlagen']; + } + + $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); + if (!$sftp->login('root', $key)) { + return ['error' => 'SFTP Login fehlgeschlagen']; + } + + // Use configured start directory or fallback to root + $startDir = !empty($currentRemoteStartDir) ? $currentRemoteStartDir : '/'; + + // Check if start directory exists, fallback to root if not + if (!$sftp->is_dir($startDir)) { + $startDir = '/'; + } + + $files = $sftp->nlist($startDir); + if ($files === false) { + return ['error' => 'Kann Verzeichnis nicht lesen: ' . $startDir]; + } + + $fileList = []; + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $fullPath = rtrim($startDir, '/') . '/' . $file; + $stat = $sftp->stat($fullPath); + $fileList[] = [ + 'name' => $file, + 'path' => $fullPath, + 'isDir' => ($stat['type'] ?? 0) === 2, + 'size' => $stat['size'] ?? 0, + 'mtime' => $stat['mtime'] ?? 0, + ]; + } + + return [ + 'success' => true, + 'server' => $selectedServer, + 'files' => $fileList, + 'path' => $startDir, + ]; + } catch (\Exception $e) { + return ['error' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $tabContainer, $statusLabel) { + if (isset($result['error'])) { + $statusLabel->setText('SFTP Fehler: ' . $result['error']); + return; + } + + if (isset($result['success']) && $result['success']) { + // Switch to SFTP tab on successful connection + $tabContainer->setActiveTab(1); + + $sftpTab->connectionStatusLabel->setText( + 'Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')', + ); + $statusLabel->setText('SFTP Verbindung erfolgreich zu ' . $result['server']['name']); + + $sftpTab->remoteFileBrowser->setPath($result['path'] ?? '/'); + $sftpTab->remoteFileBrowser->setFileData($result['files']); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) + ? $error + : ( + is_object($error) && method_exists($error, 'getMessage') + ? $error->getMessage() + : 'Unbekannter Fehler' + ); + $statusLabel->setText('SFTP Async Fehler: ' . $errorMsg); + }, + ); + } + + public function getContainer(): Container + { + return $this->tab; + } + + public function getEditModal(): Modal + { + return $this->editModal; + } + + public function getFilenameModal(): Modal + { + return $this->filenameModal; + } + + public function getRenameModal(): Modal + { + return $this->renameModal; + } + + public function getDeleteConfirmModal(): Modal + { + return $this->deleteConfirmModal; + } + + private function createEditModal( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + // Create modal content container + $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-[600] h-[400]'); + // Disable texture caching for modal content to allow dynamic content updates + $modalContent->setUseTextureCache(false); + + // File path label + $this->filePathLabel = new Label('', 'text-sm text-gray-600 font-mono'); + $modalContent->addComponent($this->filePathLabel); + + // Text editor + $this->fileEditor = new TextArea( + '', + 'Lade Datei...', + 'flex-1 border-2 border-gray-300 rounded p-2 bg-white text-black font-mono text-sm', + ); + // Disable texture caching for TextArea to ensure text updates are visible + $this->fileEditor->setUseTextureCache(false); + $modalContent->addComponent($this->fileEditor); + + // Button container + $buttonContainer = new Container('flex flex-row justify-end gap-2'); + + // Cancel button + $cancelButton = new Button( + 'Abbrechen', + 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400', + ); + $sftpTab = $this; + $cancelButton->setOnClick(function () use ($sftpTab) { + $sftpTab->editModal->setVisible(false); + }); + $buttonContainer->addComponent($cancelButton); + + // Save button + $saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600'); + $saveButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $sftpTab->handleFileSave($currentPrivateKeyPath, $serverListTab, $statusLabel); + }); + $buttonContainer->addComponent($saveButton); + + $modalContent->addComponent($buttonContainer); + + // Create modal (will be added to main container by App.php) + $this->editModal = new Modal($modalContent); + } + + private function handleFileEdit( + string $path, + array $row, + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + $this->currentEditFilePath = $path; + $this->filePathLabel->setText('Bearbeite: ' . $path); + $this->fileEditor->setValue(''); + $this->fileEditor->setFocused(false); + $this->editModal->setVisible(true); + + // Reference to components for async callback + $selectedServerRef = &$serverListTab->selectedServer; + $sftpTab = $this; + + // Load file content asynchronously + $loadButton = new Button('Load', ''); + $loadButton->setOnClickAsync( + function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { + 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']; + } + + $fileContent = $sftp->get($path); + if ($fileContent === false) { + return ['error' => 'Could not read file']; + } + + return ['success' => true, 'content' => $fileContent]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel) { + if (isset($result['error'])) { + $statusLabel->setText('Fehler beim Laden: ' . $result['error']); + $sftpTab->editModal->setVisible(false); + return; + } + + if (isset($result['success'])) { + $sftpTab->fileEditor->setValue($result['content']); + $sftpTab->fileEditor->setFocused(true); + $statusLabel->setText('Datei geladen'); + } + }, + function ($error) use ($statusLabel, $sftpTab) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler: ' . $errorMsg); + $sftpTab->editModal->setVisible(false); + }, + ); + + $loadButton->handleMouseClick(0, 0, 0); + } + + private function handleFileSave( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + $content = $this->fileEditor->getValue(); + $path = $this->currentEditFilePath; + + if (empty($path)) { + $statusLabel->setText('Fehler: Kein Dateipfad'); + return; + } + + // Reference to components for async callback + $selectedServerRef = &$serverListTab->selectedServer; + $sftpTab = $this; + + // Save file content asynchronously + $saveButton = new Button('Save', ''); + $saveButton->setOnClickAsync( + function () use ($path, $content, &$currentPrivateKeyPath, &$selectedServerRef) { + 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($path, $content); + if ($result === false) { + return ['error' => 'Could not write file']; + } + + 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 Speichern: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $statusLabel->setText('Datei erfolgreich gespeichert'); + $sftpTab->editModal->setVisible(false); + + // Reload the current directory to show the new/updated file + $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler: ' . $errorMsg); + }, + ); + + $saveButton->handleMouseClick(0, 0, 0); + } + + private function handleNewFile( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($serverListTab->selectedServer === null) { + $statusLabel->setText('Kein Server ausgewählt'); + return; + } + + // Show filename input modal + $this->filenameInput->setValue('neue_datei.txt'); + $this->filenameModal->setVisible(true); + } + + private function createFilenameModal( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + // Create modal content + $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); + + // Title + $modalContent->addComponent(new Label('Neue Datei', 'text-lg font-bold text-black')); + + // Filename input + $this->filenameInput = new TextInput( + 'Dateiname', + 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black', + ); + $modalContent->addComponent($this->filenameInput); + + // Button container + $buttonContainer = new Container('flex flex-row justify-end gap-2'); + + // Cancel button + $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); + $sftpTab = $this; + $cancelButton->setOnClick(function () use ($sftpTab) { + $sftpTab->filenameModal->setVisible(false); + }); + $buttonContainer->addComponent($cancelButton); + + // Create button + $createButton = new Button('Erstellen', 'px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700'); + $createButton->setOnClick(function () use ($sftpTab, $statusLabel) { + $filename = trim($sftpTab->filenameInput->getValue()); + + if (empty($filename)) { + $statusLabel->setText('Dateiname darf nicht leer sein'); + return; + } + + // Close filename modal + $sftpTab->filenameModal->setVisible(false); + + // Create the new file with the given name + $currentPath = $sftpTab->remoteFileBrowser->getCurrentPath(); + $newFilePath = rtrim($currentPath, '/') . '/' . $filename; + + $sftpTab->currentEditFilePath = $newFilePath; + $sftpTab->filePathLabel->setText('Neue Datei: ' . $newFilePath); + $sftpTab->fileEditor->setValue(''); + $sftpTab->fileEditor->setFocused(true); + $sftpTab->editModal->setVisible(true); + + $statusLabel->setText('Neue Datei wird erstellt: ' . $filename); + }); + $buttonContainer->addComponent($createButton); + + $modalContent->addComponent($buttonContainer); + + // Create modal + $this->filenameModal = new Modal($modalContent); + } + + private function reloadCurrentDirectory( + string &$currentPrivateKeyPath, + ?array &$selectedServerRef, + Label $statusLabel, + ): void { + $path = $this->remoteFileBrowser->getCurrentPath(); + + if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { + return; + } + + $sftpTab = $this; + + // Load directory asynchronously + $loadButton = new Button('Load', ''); + $loadButton->setOnClickAsync( + function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { + 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']; + } + + $files = $sftp->nlist($path); + if ($files === false) { + return ['error' => 'Cannot read directory']; + } + + $fileList = []; + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + $fullPath = rtrim($path, '/') . '/' . $file; + $stat = $sftp->stat($fullPath); + $fileList[] = [ + 'name' => $file, + 'path' => $fullPath, + 'isDir' => ($stat['type'] ?? 0) === 2, + 'size' => $stat['size'] ?? 0, + 'mtime' => $stat['mtime'] ?? 0, + ]; + } + + return ['success' => true, 'path' => $path, 'files' => $fileList]; + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + }, + function ($result) use ($sftpTab, $statusLabel) { + if (isset($result['error'])) { + $statusLabel->setText('Fehler beim Aktualisieren: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $sftpTab->remoteFileBrowser->setPath($result['path']); + $sftpTab->remoteFileBrowser->setFileData($result['files']); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler beim Aktualisieren: ' . $errorMsg); + }, + ); + + $loadButton->handleMouseClick(0, 0, 0); + } + + private function createRenameModal( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + // Create modal content + $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); + + // Title + $modalContent->addComponent(new Label('Datei umbenennen', 'text-lg font-bold text-black')); + + // Filename input + $this->renameInput = new TextInput( + 'Neuer Dateiname', + 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black', + ); + $modalContent->addComponent($this->renameInput); + + // Button container + $buttonContainer = new Container('flex flex-row justify-end gap-2'); + + // Cancel button + $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); + $sftpTab = $this; + $cancelButton->setOnClick(function () use ($sftpTab) { + $sftpTab->renameModal->setVisible(false); + }); + $buttonContainer->addComponent($cancelButton); + + // Rename button + $renameButton = new Button('Umbenennen', 'px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700'); + $renameButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $newFilename = trim($sftpTab->renameInput->getValue()); + + if (empty($newFilename)) { + $statusLabel->setText('Dateiname darf nicht leer sein'); + return; + } + + $oldPath = $sftpTab->currentRenameFilePath; + $directory = dirname($oldPath); + $newPath = $directory . '/' . $newFilename; + + // Close rename modal + $sftpTab->renameModal->setVisible(false); + + // Perform rename via SFTP + $selectedServerRef = &$serverListTab->selectedServer; + + $renameAsyncButton = new Button('Rename', ''); + $renameAsyncButton->setOnClickAsync( + function () use ($oldPath, $newPath, &$currentPrivateKeyPath, &$selectedServerRef) { + 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->rename($oldPath, $newPath); + if ($result === false) { + return ['error' => 'Could not rename file']; + } + + 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 Umbenennen: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $statusLabel->setText('Datei erfolgreich umbenannt'); + $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler beim Umbenennen: ' . $errorMsg); + }, + ); + + $renameAsyncButton->handleMouseClick(0, 0, 0); + }); + $buttonContainer->addComponent($renameButton); + + $modalContent->addComponent($buttonContainer); + + // Create modal + $this->renameModal = new Modal($modalContent); + } + + private function createDeleteConfirmModal( + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + // Create modal content + $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); + + // Title + $modalContent->addComponent(new Label('Datei löschen', 'text-lg font-bold text-black')); + + // Confirmation message + $this->deleteConfirmLabel = new Label( + 'Möchten Sie diese Datei wirklich löschen?', + 'text-sm text-gray-700', + ); + $modalContent->addComponent($this->deleteConfirmLabel); + + // Button container + $buttonContainer = new Container('flex flex-row justify-end gap-2'); + + // Cancel button + $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); + $sftpTab = $this; + $cancelButton->setOnClick(function () use ($sftpTab) { + $sftpTab->deleteConfirmModal->setVisible(false); + }); + $buttonContainer->addComponent($cancelButton); + + // Delete button + $deleteButton = new Button('Löschen', 'px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700'); + $deleteButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { + $filePath = $sftpTab->currentDeleteFilePath; + + // Close delete modal + $sftpTab->deleteConfirmModal->setVisible(false); + + // Perform delete via SFTP + $selectedServerRef = &$serverListTab->selectedServer; + + $deleteAsyncButton = new Button('Delete', ''); + $deleteAsyncButton->setOnClickAsync( + function () use ($filePath, &$currentPrivateKeyPath, &$selectedServerRef) { + 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->delete($filePath); + if ($result === false) { + return ['error' => 'Could not delete file']; + } + + 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 Löschen: ' . $result['error']); + return; + } + + if (isset($result['success'])) { + $statusLabel->setText('Datei erfolgreich gelöscht'); + $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); + } + }, + function ($error) use ($statusLabel) { + $errorMsg = is_string($error) ? $error : 'Unknown error'; + $statusLabel->setText('Fehler beim Löschen: ' . $errorMsg); + }, + ); + + $deleteAsyncButton->handleMouseClick(0, 0, 0); + }); + $buttonContainer->addComponent($deleteButton); + + $modalContent->addComponent($buttonContainer); + + // Create modal + $this->deleteConfirmModal = new Modal($modalContent); + } + + private function handleFileRename( + string $path, + array $row, + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($serverListTab->selectedServer === null) { + $statusLabel->setText('Kein Server ausgewählt'); + return; + } + + $this->currentRenameFilePath = $path; + $filename = basename($path); + $this->renameInput->setValue($filename); + $this->renameModal->setVisible(true); + } + + private function handleFileDelete( + string $path, + array $row, + string &$currentPrivateKeyPath, + ServerListTab $serverListTab, + Label $statusLabel, + ): void { + if ($serverListTab->selectedServer === null) { + $statusLabel->setText('Kein Server ausgewählt'); + return; + } + + $this->currentDeleteFilePath = $path; + $filename = basename($path); + $this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?'); + $this->deleteConfirmModal->setVisible(true); + } +} + diff --git a/examples/server_manager.php b/examples/server_manager.php new file mode 100644 index 0000000..f50a10d --- /dev/null +++ b/examples/server_manager.php @@ -0,0 +1,51 @@ +run(); diff --git a/examples/shadow_debug.php b/examples/shadow_debug.php new file mode 100644 index 0000000..a3e8639 --- /dev/null +++ b/examples/shadow_debug.php @@ -0,0 +1,54 @@ +count() . "\n"; + + foreach ($styles as $style) { + echo ' - Style type: ' . get_class($style->style) . "\n"; + if ($style->style instanceof \PHPNative\Tailwind\Style\Shadow) { + echo ' Shadow size: ' . $style->style->size . "\n"; + } + } + + echo "\n"; +} + +// Test combined styles +echo "\nTesting combined style:\n"; +echo str_repeat('=', 50) . "\n"; +$combined = 'px-6 py-3 bg-blue-500 text-white rounded-lg shadow-lg'; +echo "Testing: '{$combined}'\n"; +$styles = StyleParser::parse($combined); +echo 'Number of styles: ' . $styles->count() . "\n"; +foreach ($styles as $style) { + echo ' - ' . get_class($style->style); + if ($style->style instanceof \PHPNative\Tailwind\Style\Shadow) { + echo ' (size: ' . $style->style->size . ')'; + } + echo "\n"; +} diff --git a/examples/shadow_test.php b/examples/shadow_test.php new file mode 100644 index 0000000..d69b237 --- /dev/null +++ b/examples/shadow_test.php @@ -0,0 +1,74 @@ +addComponent($title); + +// Container for buttons +$buttonContainer = new Container('flex flex-col gap-6 items-center'); + +// Test different shadow sizes +$shadows = [ + 'shadow-sm' => 'Small Shadow', + 'shadow' => 'Base Shadow', + 'shadow-md' => 'Medium Shadow', + 'shadow-lg' => 'Large Shadow', + 'shadow-xl' => 'Extra Large Shadow', + 'shadow-2xl' => '2X Large Shadow', + 'shadow-inner' => 'Inner Shadow', +]; + +foreach ($shadows as $shadowClass => $label) { + // Container for each example + $exampleContainer = new Container('flex flex-row gap-4 items-center w-full'); + + // Label + $labelWidget = new Label($label, 'w-48 text-black text-sm'); + $exampleContainer->addComponent($labelWidget); + + // Button with shadow + $button = new Button('Button with ' . $shadowClass, 'px-6 py-3 text-white ' . $shadowClass); + $exampleContainer->addComponent($button); + + // Card with shadow + $card = new Container('px-6 py-4 ' . $shadowClass); + $cardLabel = new Label('Card', 'text-black text-sm'); + $card->addComponent($cardLabel); + $exampleContainer->addComponent($card); + + $buttonContainer->addComponent($exampleContainer); +} + +// Add no shadow example +$noShadowContainer = new Container('flex flex-row gap-4 items-center w-full'); +$noShadowLabel = new Label('No Shadow', 'w-48 text-black text-sm'); +$noShadowContainer->addComponent($noShadowLabel); +$noShadowButton = new Button('Button without shadow', 'px-6 py-3 bg-blue-500 text-white rounded-lg shadow-none'); +$noShadowContainer->addComponent($noShadowButton); +$noShadowCard = new Container('px-6 py-4 bg-white rounded-lg shadow-none'); +$noShadowCardLabel = new Label('Card', 'text-black text-sm'); +$noShadowCard->addComponent($noShadowCardLabel); +$noShadowContainer->addComponent($noShadowCard); +$buttonContainer->addComponent($noShadowContainer); + +$mainContainer->addComponent($buttonContainer); + +// Set window content and run +$window->setRoot($mainContainer); +$app->addWindow($window); +$app->run(); diff --git a/examples/windows_app_example.php b/examples/windows_app_example.php index 900b1da..187c773 100644 --- a/examples/windows_app_example.php +++ b/examples/windows_app_example.php @@ -104,36 +104,8 @@ $menuBar = new MenuBar(); // File Menu $fileMenu = new Menu(title: 'Datei'); -$fileMenu->addItem('Neu', function () { - echo "Neu clicked\n"; -}); -$fileMenu->addItem('Öffnen', function () { - echo "Öffnen clicked\n"; -}); -$fileMenu->addSeparator(); -$fileMenu->addItem('Beenden', function () use ($app) { - echo "Beenden clicked\n"; - exit(0); -}); - // Settings Menu $settingsMenu = new Menu(title: 'Einstellungen'); -$settingsMenu->addItem('Optionen', function () use ( - $menuBar, - $modal, - $apiKeyInput, - $privateKeyPathInput, - &$currentApiKey, - &$currentPrivateKeyPath, -) { - $menuBar->closeAllMenus(); - $apiKeyInput->setValue($currentApiKey); - $privateKeyPathInput->setValue($currentPrivateKeyPath); - $modal->setVisible(true); -}); -$settingsMenu->addItem('Sprache', function () { - echo "Sprache clicked\n"; -}); $menuBar->addMenu($fileMenu); $menuBar->addMenu($settingsMenu); $mainContainer->addComponent($menuBar); @@ -581,7 +553,7 @@ $sftpButton->setOnClickAsync( }, ); -$mainContainer->addComponent($tabContainer); +//$mainContainer->addComponent($tabContainer); // === 3. StatusBar === $statusBar = new StatusBar(); @@ -599,7 +571,7 @@ $statusBar->addSegment(new Label( text: 'Version 1.0', style: 'border-l text-black basis-2/8', )); -$mainContainer->addComponent($statusBar); +//$mainContainer->addComponent($statusBar); $cancelButton->setOnClick(function () use ($menuBar, $modal) { $menuBar->closeAllMenus(); diff --git a/src/Framework/TextRenderer.php b/src/Framework/TextRenderer.php index a575b0b..c83eb0e 100644 --- a/src/Framework/TextRenderer.php +++ b/src/Framework/TextRenderer.php @@ -149,7 +149,6 @@ class TextRenderer 'w' => $textSize['w'], 'h' => $textSize['h'], ]); - // Note: Texture and surface are automatically cleaned up by PHP resource destructors } diff --git a/src/Tailwind/Data/Icon.php b/src/Tailwind/Data/Icon.php index ea2dd99..9aafa54 100644 --- a/src/Tailwind/Data/Icon.php +++ b/src/Tailwind/Data/Icon.php @@ -15,4 +15,8 @@ enum Icon:int case user = 0xf007; // f007 - user case cog = 0xf013; // f013 - gear (settings) case home = 0xf015; // f015 - house (home) + case sync = 0xf021; // f021 - arrows-rotate (refresh/sync) + case folder = 0xf07b; // f07b - folder + case terminal = 0xf120; // f120 - terminal + case pen = 0xf304; // f304 - pen (rename/edit name) } diff --git a/src/Tailwind/Parser/Shadow.php b/src/Tailwind/Parser/Shadow.php new file mode 100644 index 0000000..dae444e --- /dev/null +++ b/src/Tailwind/Parser/Shadow.php @@ -0,0 +1,30 @@ +size !== 'none') { + $class->size = $style->size; + } + } +} diff --git a/src/Tailwind/Style/Color.php b/src/Tailwind/Style/Color.php index 3be334b..d6465eb 100644 --- a/src/Tailwind/Style/Color.php +++ b/src/Tailwind/Style/Color.php @@ -6,7 +6,15 @@ namespace PHPNative\Tailwind\Style; class Color implements Style { - public function __construct(public int $red = -1, public int $green = -1, public int $blue = -1, public int $alpha = 255) + public function __construct( + public int $red = -1, + public int $green = -1, + public int $blue = -1, + public int $alpha = 255, + ) {} + + public function isNotSet(): bool { + return $this->red != -1 || $this->green != -1 || $this->blue != -1; } } diff --git a/src/Tailwind/Style/Shadow.php b/src/Tailwind/Style/Shadow.php new file mode 100644 index 0000000..71e2257 --- /dev/null +++ b/src/Tailwind/Style/Shadow.php @@ -0,0 +1,13 @@ +viewport->width, - (int)$this->viewport->height + (int) $this->viewport->width, + (int) $this->viewport->height, ); if ($normalTexture) { @@ -235,8 +235,8 @@ abstract class Component $renderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, - (int)$this->viewport->width, - (int)$this->viewport->height + (int) $this->viewport->width, + (int) $this->viewport->height, ); if ($hoverTexture) { @@ -378,7 +378,7 @@ abstract class Component Profiler::increment('uses_cache'); if ($this->textureCacheValid) { Profiler::increment('cache_valid'); - $texture = ($this->currentState == StateEnum::hover) ? $this->cachedHoverTexture : $this->cachedTexture; + $texture = $this->currentState == StateEnum::hover ? $this->cachedHoverTexture : $this->cachedTexture; if ($texture !== null) { Profiler::increment('texture_cache_hit'); @@ -403,6 +403,14 @@ abstract class Component Profiler::increment('render_normal'); + // Render shadow if present (before background) + if ( + isset($this->computedStyles[\PHPNative\Tailwind\Style\Shadow::class]) && + ($shadow = $this->computedStyles[\PHPNative\Tailwind\Style\Shadow::class]) + ) { + $this->renderShadow($renderer, $shadow); + } + // Render normally if no cache or cache building if ( isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) && @@ -430,10 +438,10 @@ abstract class Component sdl_rounded_box_ex( $renderer, - $this->viewport->x, - $this->viewport->y, - $x2, - $y2, + (int) $this->viewport->x, + (int) $this->viewport->y, + (int) $x2, + (int) $y2, $border->roundTopLeft ?? 0, $border->roundTopRight ?? 0, $border->roundBottomRight ?? 0, @@ -478,15 +486,17 @@ abstract class Component if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class])) { $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; // Enable clipping for hidden, clip, scroll, or auto overflow - if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || - $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { + if ( + $overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || + $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden + ) { // Actually, let's clip for anything that's not the default $needsClipping = true; $clipRect = [ - 'x' => (int)$this->contentViewport->x, - 'y' => (int)$this->contentViewport->y, - 'w' => (int)$this->contentViewport->width, - 'h' => (int)$this->contentViewport->height, + 'x' => (int) $this->contentViewport->x, + 'y' => (int) $this->contentViewport->y, + 'w' => (int) $this->contentViewport->width, + 'h' => (int) $this->contentViewport->height, ]; } } @@ -503,10 +513,12 @@ abstract class Component if ($needsClipping && $clipRect) { $childViewport = $child->getViewport(); // Check if child is visible within clip rect - if ($childViewport->x + $childViewport->width < $clipRect['x'] || - $childViewport->x > $clipRect['x'] + $clipRect['w'] || - $childViewport->y + $childViewport->height < $clipRect['y'] || - $childViewport->y > $clipRect['y'] + $clipRect['h']) { + if ( + ($childViewport->x + $childViewport->width) < $clipRect['x'] || + $childViewport->x > ($clipRect['x'] + $clipRect['w']) || + ($childViewport->y + $childViewport->height) < $clipRect['y'] || + $childViewport->y > ($clipRect['y'] + $clipRect['h']) + ) { continue; // Child is completely outside clip rect, skip rendering } } @@ -631,8 +643,10 @@ abstract class Component if ($hasClipping) { $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; - if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || - $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { + if ( + $overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || + $overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden + ) { $clipRect = [ 'x' => $this->contentViewport->x, 'y' => $this->contentViewport->y, @@ -661,10 +675,12 @@ abstract class Component // If we have clipping, check if child is visible within clip rect if ($clipRect !== null) { // Skip children that are completely outside the clip rect - if ($childViewport->x + $childViewport->width < $clipRect['x'] || - $childViewport->x > $clipRect['x'] + $clipRect['w'] || - $childViewport->y + $childViewport->height < $clipRect['y'] || - $childViewport->y > $clipRect['y'] + $clipRect['h']) { + if ( + ($childViewport->x + $childViewport->width) < $clipRect['x'] || + $childViewport->x > ($clipRect['x'] + $clipRect['w']) || + ($childViewport->y + $childViewport->height) < $clipRect['y'] || + $childViewport->y > ($clipRect['y'] + $clipRect['h']) + ) { // Child is outside visible area, send fake event to clear hover $child->handleMouseMove(-1000, -1000); continue; @@ -830,4 +846,111 @@ abstract class Component { return $this->children; } + + /** + * Render shadow effect for the component + */ + private function renderShadow(&$renderer, \PHPNative\Tailwind\Style\Shadow $shadow): void + { + if ($shadow->size === 'none') { + return; + } + + // Define shadow properties based on size + $shadowProps = match ($shadow->size) { + 'sm' => ['blur' => 1, 'offsetX' => 0, 'offsetY' => 1, 'alpha' => 15], + 'base' => ['blur' => 2, 'offsetX' => 0, 'offsetY' => 2, 'alpha' => 25], + 'md' => ['blur' => 3, 'offsetX' => 0, 'offsetY' => 4, 'alpha' => 30], + 'lg' => ['blur' => 4, 'offsetX' => 0, 'offsetY' => 6, 'alpha' => 35], + 'xl' => ['blur' => 5, 'offsetX' => 0, 'offsetY' => 10, 'alpha' => 40], + '2xl' => ['blur' => 7, 'offsetX' => 0, 'offsetY' => 15, 'alpha' => 50], + 'inner' => ['blur' => 2, 'offsetX' => 0, 'offsetY' => 0, 'alpha' => 20, 'inner' => true], + default => ['blur' => 2, 'offsetX' => 0, 'offsetY' => 2, 'alpha' => 25], + }; + + // Get shadow color (default to black/gray if not specified) + $shadowColor = $shadow->color->isNotSet() + ? $shadow->color + : new \PHPNative\Tailwind\Style\Color(0, 0, 0, $shadowProps['alpha']); + + // Override alpha with shadow-specific alpha if color alpha is default + if ($shadowColor->alpha === 255) { + $shadowColor = new \PHPNative\Tailwind\Style\Color( + $shadowColor->red, + $shadowColor->green, + $shadowColor->blue, + $shadowProps['alpha'], + ); + } + + // Get border radius if present for rounded shadows + $borderRadius = 0; + $border = null; + if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Border::class])) { + $border = $this->computedStyles[\PHPNative\Tailwind\Style\Border::class]; + $borderRadius = $border->roundTopLeft ?? 0; + } + + // Render shadow layers (simulate blur with multiple semi-transparent layers) + for ($i = 0; $i < $shadowProps['blur']; $i++) { + $offset = $i; + $layerAlpha = (int) ($shadowColor->alpha / $shadowProps['blur']); + + if (isset($shadowProps['inner']) && $shadowProps['inner']) { + // Inner shadow - render inside the element + $shadowX = (int) ($this->viewport->x + $offset); + $shadowY = (int) ($this->viewport->y + $offset); + $shadowWidth = (int) max(0, $this->viewport->width - ($offset * 2)); + $shadowHeight = (int) max(0, $this->viewport->height - ($offset * 2)); + } else { + // Outer shadow - render behind the element + $shadowX = (int) ($this->viewport->x + $shadowProps['offsetX'] + $offset); + $shadowY = (int) ($this->viewport->y + $shadowProps['offsetY'] + $offset); + $shadowWidth = (int) $this->viewport->width; + $shadowHeight = (int) $this->viewport->height; + } + + // Skip if shadow is too small + if ($shadowWidth <= 0 || $shadowHeight <= 0) { + continue; + } + sdl_set_render_draw_color( + $renderer, + $shadowColor->red, + $shadowColor->green, + $shadowColor->blue, + $layerAlpha, + ); + + if ($borderRadius > 0 && $border !== null) { + // Render rounded shadow + $x2 = $shadowX + $shadowWidth; + $y2 = $shadowY + $shadowHeight; + sdl_rounded_box_ex( + $renderer, + $shadowX, + $shadowY, + $x2, + $y2, + $border->roundTopLeft ?? 0, + $border->roundTopRight ?? 0, + $border->roundBottomRight ?? 0, + $border->roundBottomLeft ?? 0, + $shadowColor->red, + $shadowColor->green, + $shadowColor->blue, + $layerAlpha, + ); + } else { + // Render rectangular shadow + error_log(sprintf('%s,%s,%s,%s', $shadowX, $shadowY, $shadowWidth, $shadowHeight)); + sdl_render_fill_rect($renderer, [ + 'x' => $shadowX, + 'y' => $shadowY, + 'w' => $shadowWidth, + 'h' => $shadowHeight, + ]); + } + } + } } diff --git a/src/Ui/Widget/Button.php b/src/Ui/Widget/Button.php index 751379d..9efc0e9 100644 --- a/src/Ui/Widget/Button.php +++ b/src/Ui/Widget/Button.php @@ -22,8 +22,9 @@ class Button extends Container ) { parent::__construct($style); - // Enable texture caching for buttons (huge performance boost!) - $this->setUseTextureCache(true); + // Texture caching disabled for buttons - causes issues with variable width buttons + // Text is already cached by TextRenderer, so performance impact is minimal + // $this->setUseTextureCache(true); // Create label inside button $this->label = new Label( @@ -144,8 +145,7 @@ class Button extends Container if ($this->onClickAsync['onError'] !== null) { $task->onError($this->onClickAsync['onError']); } - } // Call sync onClick callback if set - elseif ($this->onClick !== null) { + } elseif ($this->onClick !== null) { // Call sync onClick callback if set ($this->onClick)(); } @@ -195,7 +195,7 @@ class Button extends Container $childIndex = array_search($child, $this->children, true); $referenceIndex = array_search($reference, $this->children, true); - if ($childIndex === false || $referenceIndex === false || $childIndex === $referenceIndex + 1) { + if ($childIndex === false || $referenceIndex === false || $childIndex === ($referenceIndex + 1)) { return; } diff --git a/src/Ui/Widget/FileBrowser.php b/src/Ui/Widget/FileBrowser.php index 5e241b4..c8808ba 100644 --- a/src/Ui/Widget/FileBrowser.php +++ b/src/Ui/Widget/FileBrowser.php @@ -8,11 +8,14 @@ class FileBrowser extends Container private Label $pathLabel; private string $currentPath; private $onFileSelect = null; + private $onEditFile = null; + private $onRenameFile = null; + private $onDeleteFile = null; private bool $isRemote = false; public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '') { - parent::__construct('flex flex-col gap-2 ' . $style); + parent::__construct('w-full flex flex-col gap-2 ' . $style); $this->currentPath = $initialPath; $this->isRemote = $isRemote; @@ -22,12 +25,13 @@ class FileBrowser extends Container $this->addComponent($this->pathLabel); // File table with explicit flex-1 for scrolling - $this->fileTable = new Table(' flex-1'); + $this->fileTable = new Table(''); $this->fileTable->setColumns([ ['key' => 'type', 'title' => 'Typ', 'width' => 60], ['key' => 'name', 'title' => 'Name'], ['key' => 'size', 'title' => 'Größe', 'width' => 100], - ['key' => 'modified', 'title' => 'Geändert', 'width' => 160], + ['key' => 'modified', 'title' => 'Geändert', 'width' => 200], + ['key' => 'actions', 'title' => 'Action', 'width' => 100, 'render' => [$this, 'renderActionsCell']], ]); $this->addComponent($this->fileTable); @@ -216,6 +220,81 @@ class FileBrowser extends Container $this->onFileSelect = $callback; } + /** + * Set edit file callback + */ + public function setOnEditFile(callable $callback): void + { + $this->onEditFile = $callback; + } + + /** + * Set rename file callback + */ + public function setOnRenameFile(callable $callback): void + { + $this->onRenameFile = $callback; + } + + /** + * Set delete file callback + */ + public function setOnDeleteFile(callable $callback): void + { + $this->onDeleteFile = $callback; + } + + /** + * Render actions cell with edit, rename, and delete buttons for files + */ + public function renderActionsCell(array $rowData, int $rowIndex): Container + { + // Match the cell style from Table (100px width for icon buttons) + $container = new Container( + 'w-25 py-1 border-r border-gray-300 flex flex-row items-center justify-center gap-1', + ); + + // Only show action buttons for files (not directories) + if (!($rowData['isDir'] ?? false) && !empty($rowData['path'])) { + $fileBrowser = $this; + + // Edit button + $editButton = new Button('', 'p-1 text-blue-500 hover:text-blue-600 flex items-center justify-center'); + $editIcon = new Icon(\PHPNative\Tailwind\Data\Icon::edit, 16, 'text-blue-500'); + $editButton->setIcon($editIcon); + $editButton->setOnClick(function () use ($fileBrowser, $rowData) { + if ($fileBrowser->onEditFile !== null) { + ($fileBrowser->onEditFile)($rowData['path'], $rowData); + } + }); + $container->addComponent($editButton); + + // Rename button + $renameButton = new Button('', 'p-1 text-amber-500 hover:text-amber-600 flex items-center justify-center'); + $renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500'); + $renameButton->setIcon($renameIcon); + $renameButton->setOnClick(function () use ($fileBrowser, $rowData) { + if ($fileBrowser->onRenameFile !== null) { + ($fileBrowser->onRenameFile)($rowData['path'], $rowData); + } + }); + $container->addComponent($renameButton); + + // Delete button + $deleteButton = new Button('', 'p-1 text-red-500 hover:text-red-600 flex items-center justify-center'); + $deleteIcon = new Icon(\PHPNative\Tailwind\Data\Icon::trash, 16, 'text-red-500'); + $deleteButton->setIcon($deleteIcon); + $deleteButton->setOnClick(function () use ($fileBrowser, $rowData) { + if ($fileBrowser->onDeleteFile !== null) { + ($fileBrowser->onDeleteFile)($rowData['path'], $rowData); + } + }); + $container->addComponent($deleteButton); + } + + return $container; + } + /** * Get current path */ diff --git a/src/Ui/Widget/Label.php b/src/Ui/Widget/Label.php index a15dd05..93f664e 100644 --- a/src/Ui/Widget/Label.php +++ b/src/Ui/Widget/Label.php @@ -30,15 +30,13 @@ class Label extends Component $this->text = $text; $this->clearTextTexture(); - - // $this->markDirty(true); + $this->markDirty(true); } public function layout(null|TextRenderer $textRenderer = null): void { // Call parent to compute styles and setup viewports parent::layout($textRenderer); - // Measure text to get intrinsic size if ($textRenderer !== null && $textRenderer->isInitialized()) { $textStyle = $this->computedStyles[Text::class] ?? new Text(); @@ -114,9 +112,6 @@ class Label extends Component } $this->renderDirty = false; - - // Call parent to render children if any - parent::renderContent($window, $textRenderer); } public function __destruct() diff --git a/src/Ui/Widget/Menu.php b/src/Ui/Widget/Menu.php index 48acda3..2e08e7e 100644 --- a/src/Ui/Widget/Menu.php +++ b/src/Ui/Widget/Menu.php @@ -13,7 +13,7 @@ class Menu extends Container public function __construct(string $title) { - parent::__construct('relative'); + parent::__construct(''); // Create menu button $this->menuButton = new Button($title, 'px-4 py-2 hover:bg-gray-200', fn() => $this->toggle()); diff --git a/src/Ui/Widget/MenuBar.php b/src/Ui/Widget/MenuBar.php index d76715d..1cfa6b1 100644 --- a/src/Ui/Widget/MenuBar.php +++ b/src/Ui/Widget/MenuBar.php @@ -46,4 +46,18 @@ class MenuBar extends Container $this->openMenu = null; } } + + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + // First, let children handle the click (menus and their items) + $handled = parent::handleMouseClick($mouseX, $mouseY, $button); + + // If no child handled the click and we have an open menu, close all menus + if (!$handled && $this->openMenu !== null) { + $this->closeAllMenus(); + return true; + } + + return $handled; + } } diff --git a/src/Ui/Widget/Modal.php b/src/Ui/Widget/Modal.php index b19da98..4209d5a 100644 --- a/src/Ui/Widget/Modal.php +++ b/src/Ui/Widget/Modal.php @@ -162,8 +162,10 @@ class Modal extends Container return; } - // Always handle mouse move in modal to prevent background hover states - parent::handleMouseMove($mouseX, $mouseY); + // Pass mouse move to children but don't mark the modal itself as dirty + foreach ($this->children as $child) { + $child->handleMouseMove($mouseX, $mouseY); + } // Don't propagate to components below the modal } diff --git a/src/Ui/Widget/Table.php b/src/Ui/Widget/Table.php index f396297..67dc4b5 100644 --- a/src/Ui/Widget/Table.php +++ b/src/Ui/Widget/Table.php @@ -92,8 +92,20 @@ class Table extends Container $rowContainer = new Container($rowStyle); - // Enable texture caching for table rows (huge performance boost!) - $rowContainer->setUseTextureCache(true); + // Check if any column has custom render (interactive components) + $hasCustomRender = false; + foreach ($this->columns as $column) { + if (isset($column['render']) && is_callable($column['render'])) { + $hasCustomRender = true; + break; + } + } + + // Enable texture caching for table rows only if no custom render + // Custom renders may contain interactive components (buttons) that need event handling + if (!$hasCustomRender) { + $rowContainer->setUseTextureCache(true); + } foreach ($this->columns as $column) { $key = $column['key']; @@ -107,8 +119,14 @@ class Table extends Container $cellStyle .= ' flex-1'; } - $cellLabel = new Label((string) $value, $cellStyle); - $rowContainer->addComponent($cellLabel); + // Check if column has custom render function + if (isset($column['render']) && is_callable($column['render'])) { + $cellComponent = $column['render']($rowData, $rowIndex); + $rowContainer->addComponent($cellComponent); + } else { + $cellLabel = new Label((string) $value, $cellStyle); + $rowContainer->addComponent($cellLabel); + } } // Make row clickable @@ -126,7 +144,13 @@ class Table extends Container public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool { - // Check if click is within row bounds + // First, let children (like buttons) handle the click + $handled = parent::handleMouseClick($mouseX, $mouseY, $button); + if ($handled) { + return true; + } + + // If no child handled it, check if click is within row bounds if ( $mouseX >= $this->viewport->x && $mouseX <= ($this->viewport->x + $this->viewport->width) && @@ -136,7 +160,8 @@ class Table extends Container $this->table->selectRow($this->rowIndex); return true; } - return parent::handleMouseClick($mouseX, $mouseY, $button); + + return false; } }; diff --git a/src/Ui/Widget/TextArea.php b/src/Ui/Widget/TextArea.php new file mode 100644 index 0000000..7f3ee84 --- /dev/null +++ b/src/Ui/Widget/TextArea.php @@ -0,0 +1,694 @@ +lines = explode("\n", $value); + } + } + + public function setValue(string $value): void + { + $this->value = $value; + $this->lines = empty($value) ? [''] : explode("\n", $value); + $this->cursorLine = 0; + $this->cursorCol = 0; + $this->clearSelection(); + $this->markDirty(true); + } + + public function getValue(): string + { + return implode("\n", $this->lines); + } + + public function setFocused(bool $focused): void + { + $this->focused = $focused; + $this->cursorVisible = true; + $this->cursorBlinkTimer = 0; + $this->markDirty(true); + } + + public function isFocused(): bool + { + return $this->focused; + } + + public function handleTextInput(string $text): void + { + if (!$this->focused || !$this->visible) { + return; + } + + $this->deleteSelection(); + + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol); + $after = mb_substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $before . $text . $after; + $this->cursorCol += mb_strlen($text); + + $this->value = $this->getValue(); + $this->resetCursorBlink(); + $this->markDirty(true); + } + + public function handleKeyDown(int $keycode, int $mod = 0): bool + { + if (!$this->focused || !$this->visible) { + return false; + } + + $shift = ($mod & KMOD_SHIFT) !== 0; + $ctrl = ($mod & KMOD_CTRL) !== 0; + + if (!$shift && $this->hasSelection()) { + $this->clearSelection(); + } + + switch ($keycode) { + case \SDLK_RETURN: + //case \SDLK_KP_ENTER: + $this->handleEnter(); + return true; + + case \SDLK_BACKSPACE: + $this->handleBackspace(); + return true; + + case \SDLK_DELETE: + $this->handleDelete(); + return true; + + case \SDLK_LEFT: + $this->handleLeft($shift); + return true; + + case \SDLK_RIGHT: + $this->handleRight($shift); + return true; + + case \SDLK_UP: + $this->handleUp($shift); + return true; + + case \SDLK_DOWN: + $this->handleDown($shift); + return true; + + case \SDLK_HOME: + $this->handleHome($shift); + return true; + + case \SDLK_END: + $this->handleEnd($shift); + return true; + + case \SDLK_A: + if ($ctrl) { + $this->selectAll(); + return true; + } + break; + + case \SDLK_C: + if ($ctrl && $this->hasSelection()) { + $this->copyToClipboard(); + return true; + } + break; + + case \SDLK_X: + if ($ctrl && $this->hasSelection()) { + $this->copyToClipboard(); + $this->deleteSelection(); + return true; + } + break; + + case \SDLK_V: + if ($ctrl) { + $this->pasteFromClipboard(); + return true; + } + break; + } + + return false; + } + + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool + { + if (!$this->visible) { + return false; + } + + if ( + $mouseX >= $this->viewport->x && + $mouseX <= ($this->viewport->x + $this->viewport->width) && + $mouseY >= $this->viewport->y && + $mouseY <= ($this->viewport->y + $this->viewport->height) + ) { + $this->setFocused(true); + + // Calculate which line was clicked + $relativeY = ($mouseY - $this->contentViewport->y) + $this->scrollOffsetY; + $clickedLine = (int) floor($relativeY / $this->lineHeight); + $clickedLine = max(0, min($clickedLine, count($this->lines) - 1)); + + $this->cursorLine = $clickedLine; + + // For now, just move cursor to end of line + // TODO: Calculate exact column based on mouseX position + $this->cursorCol = mb_strlen($this->lines[$this->cursorLine]); + $this->clearSelection(); + $this->resetCursorBlink(); + $this->markDirty(true); + + return true; + } + + if ($this->focused) { + $this->setFocused(false); + } + + return false; + } + + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + if ($textRenderer !== null && $textRenderer->isInitialized()) { + $textStyle = $this->computedStyles[Text::class] ?? new Text(); + [, $this->lineHeight] = $textRenderer->measureText('Ay', $textStyle->size); + } + } + + public function renderContent(&$window, null|TextRenderer $textRenderer = null): void + { + if (!$this->visible || $textRenderer === null || !$textRenderer->isInitialized()) { + return; + } + + // Setup text rendering + $textStyle = $this->computedStyles[Text::class] ?? new Text(); + $textColor = $textStyle->color; + + $textRenderer->setColor( + $textColor->red / 255, + $textColor->green / 255, + $textColor->blue / 255, + $textColor->alpha / 255, + ); + + // Draw selection if active + if ($this->hasSelection()) { + $this->renderSelection($window, $textRenderer); + } + + // Draw text lines + $y = ((int) $this->contentViewport->y) - $this->scrollOffsetY; + + foreach ($this->lines as $lineIdx => $line) { + if (($y + $this->lineHeight) < $this->contentViewport->y) { + $y += $this->lineHeight; + continue; + } + + if ($y > ($this->contentViewport->y + $this->contentViewport->height)) { + break; + } + + // Draw line (even empty lines to maintain spacing) + if ($line !== '') { + $textRenderer->drawText($line, (int) $this->contentViewport->x, $y, $textStyle->size); + } + + $y += $this->lineHeight; + } + + // Draw placeholder if empty + if (empty($this->value) && !empty($this->placeholder) && !$this->focused) { + $textRenderer->setColor(0.6, 0.6, 0.6, 1.0); + $textRenderer->drawText( + $this->placeholder, + (int) $this->contentViewport->x, + (int) $this->contentViewport->y, + $textStyle->size, + ); + } + + // Draw cursor if focused + if ($this->focused && $this->cursorVisible) { + $this->renderCursor($window, $textRenderer); + } + + // Update cursor blink + $this->cursorBlinkTimer++; + if ($this->cursorBlinkTimer >= 30) { + $this->cursorVisible = !$this->cursorVisible; + $this->cursorBlinkTimer = 0; + } + } + + private function handleEnter(): void + { + $this->deleteSelection(); + + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol); + $after = mb_substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $before; + array_splice($this->lines, $this->cursorLine + 1, 0, [$after]); + + $this->cursorLine++; + $this->cursorCol = 0; + + $this->value = $this->getValue(); + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleBackspace(): void + { + if ($this->hasSelection()) { + $this->deleteSelection(); + return; + } + + if ($this->cursorCol > 0) { + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol - 1); + $after = mb_substr($currentLine, $this->cursorCol); + $this->lines[$this->cursorLine] = $before . $after; + $this->cursorCol--; + } elseif ($this->cursorLine > 0) { + $currentLine = $this->lines[$this->cursorLine]; + $this->cursorLine--; + $this->cursorCol = mb_strlen($this->lines[$this->cursorLine]); + $this->lines[$this->cursorLine] .= $currentLine; + array_splice($this->lines, $this->cursorLine + 1, 1); + } + + $this->value = $this->getValue(); + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleDelete(): void + { + if ($this->hasSelection()) { + $this->deleteSelection(); + return; + } + + if ($this->cursorCol < mb_strlen($this->lines[$this->cursorLine])) { + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol); + $after = mb_substr($currentLine, $this->cursorCol + 1); + $this->lines[$this->cursorLine] = $before . $after; + } elseif ($this->cursorLine < (count($this->lines) - 1)) { + $nextLine = $this->lines[$this->cursorLine + 1]; + $this->lines[$this->cursorLine] .= $nextLine; + array_splice($this->lines, $this->cursorLine + 1, 1); + } + + $this->value = $this->getValue(); + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleLeft(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + if ($this->cursorCol > 0) { + $this->cursorCol--; + } elseif ($this->cursorLine > 0) { + $this->cursorLine--; + $this->cursorCol = mb_strlen($this->lines[$this->cursorLine]); + } + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleRight(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + if ($this->cursorCol < mb_strlen($this->lines[$this->cursorLine])) { + $this->cursorCol++; + } elseif ($this->cursorLine < (count($this->lines) - 1)) { + $this->cursorLine++; + $this->cursorCol = 0; + } + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleUp(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + if ($this->cursorLine > 0) { + $this->cursorLine--; + $lineLen = mb_strlen($this->lines[$this->cursorLine]); + $this->cursorCol = min($this->cursorCol, $lineLen); + } + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleDown(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + if ($this->cursorLine < (count($this->lines) - 1)) { + $this->cursorLine++; + $lineLen = mb_strlen($this->lines[$this->cursorLine]); + $this->cursorCol = min($this->cursorCol, $lineLen); + } + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleHome(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + $this->cursorCol = 0; + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function handleEnd(bool $shift): void + { + if ($shift) { + $this->startSelectionIfNeeded(); + } + + $this->cursorCol = mb_strlen($this->lines[$this->cursorLine]); + + if ($shift) { + $this->updateSelectionEnd(); + } + + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function startSelectionIfNeeded(): void + { + if (!$this->hasSelection()) { + $this->selectionStartLine = $this->cursorLine; + $this->selectionStartCol = $this->cursorCol; + } + } + + private function updateSelectionEnd(): void + { + $this->selectionEndLine = $this->cursorLine; + $this->selectionEndCol = $this->cursorCol; + } + + private function clearSelection(): void + { + $this->selectionStartLine = -1; + $this->selectionStartCol = -1; + $this->selectionEndLine = -1; + $this->selectionEndCol = -1; + $this->markDirty(true); + } + + private function hasSelection(): bool + { + return ( + $this->selectionStartLine >= 0 && + $this->selectionEndLine >= 0 && + !( + + $this->selectionStartLine === $this->selectionEndLine && + $this->selectionStartCol === $this->selectionEndCol + + ) + ); + } + + private function selectAll(): void + { + $this->selectionStartLine = 0; + $this->selectionStartCol = 0; + $this->selectionEndLine = count($this->lines) - 1; + $this->selectionEndCol = mb_strlen($this->lines[$this->selectionEndLine]); + $this->markDirty(true); + } + + private function deleteSelection(): void + { + if (!$this->hasSelection()) { + return; + } + + [$startLine, $startCol, $endLine, $endCol] = $this->normalizeSelection(); + + if ($startLine === $endLine) { + $line = $this->lines[$startLine]; + $before = mb_substr($line, 0, $startCol); + $after = mb_substr($line, $endCol); + $this->lines[$startLine] = $before . $after; + } else { + $firstLine = mb_substr($this->lines[$startLine], 0, $startCol); + $lastLine = mb_substr($this->lines[$endLine], $endCol); + $this->lines[$startLine] = $firstLine . $lastLine; + array_splice($this->lines, $startLine + 1, $endLine - $startLine); + } + + $this->cursorLine = $startLine; + $this->cursorCol = $startCol; + $this->clearSelection(); + $this->value = $this->getValue(); + $this->markDirty(true); + } + + private function normalizeSelection(): array + { + $startLine = $this->selectionStartLine; + $startCol = $this->selectionStartCol; + $endLine = $this->selectionEndLine; + $endCol = $this->selectionEndCol; + + if ($startLine > $endLine || $startLine === $endLine && $startCol > $endCol) { + return [$endLine, $endCol, $startLine, $startCol]; + } + + return [$startLine, $startCol, $endLine, $endCol]; + } + + private function copyToClipboard(): void + { + if (!$this->hasSelection()) { + return; + } + + [$startLine, $startCol, $endLine, $endCol] = $this->normalizeSelection(); + + if ($startLine === $endLine) { + $text = mb_substr($this->lines[$startLine], $startCol, $endCol - $startCol); + } else { + $selectedLines = []; + $selectedLines[] = mb_substr($this->lines[$startLine], $startCol); + + for ($i = $startLine + 1; $i < $endLine; $i++) { + $selectedLines[] = $this->lines[$i]; + } + + $selectedLines[] = mb_substr($this->lines[$endLine], 0, $endCol); + $text = implode("\n", $selectedLines); + } + + if (function_exists('sdl_set_clipboard_text')) { + sdl_set_clipboard_text($text); + } + } + + private function pasteFromClipboard(): void + { + if (!function_exists('sdl_get_clipboard_text')) { + return; + } + + $this->deleteSelection(); + + $text = sdl_get_clipboard_text(); + if (empty($text)) { + return; + } + + $pasteLines = explode("\n", $text); + + if (count($pasteLines) === 1) { + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol); + $after = mb_substr($currentLine, $this->cursorCol); + $this->lines[$this->cursorLine] = $before . $pasteLines[0] . $after; + $this->cursorCol += mb_strlen($pasteLines[0]); + } else { + $currentLine = $this->lines[$this->cursorLine]; + $before = mb_substr($currentLine, 0, $this->cursorCol); + $after = mb_substr($currentLine, $this->cursorCol); + + $this->lines[$this->cursorLine] = $before . $pasteLines[0]; + + $insertLines = []; + for ($i = 1; $i < (count($pasteLines) - 1); $i++) { + $insertLines[] = $pasteLines[$i]; + } + + $lastPasteLine = $pasteLines[count($pasteLines) - 1]; + $insertLines[] = $lastPasteLine . $after; + + array_splice($this->lines, $this->cursorLine + 1, 0, $insertLines); + + $this->cursorLine += count($pasteLines) - 1; + $this->cursorCol = mb_strlen($lastPasteLine); + } + + $this->value = $this->getValue(); + $this->resetCursorBlink(); + $this->markDirty(true); + } + + private function renderCursor(&$window, TextRenderer $textRenderer): void + { + $textStyle = $this->computedStyles[Text::class] ?? new Text(); + + $y = (((int) $this->contentViewport->y) + ($this->cursorLine * $this->lineHeight)) - $this->scrollOffsetY; + + if ( + $y < $this->contentViewport->y || + ($y + $this->lineHeight) > ($this->contentViewport->y + $this->contentViewport->height) + ) { + return; + } + + $beforeCursor = mb_substr($this->lines[$this->cursorLine], 0, $this->cursorCol); + [$cursorX] = $textRenderer->measureText($beforeCursor, $textStyle->size); + + sdl_set_render_draw_color($window, 0, 0, 0, 255); + sdl_render_fill_rect($window, [ + 'x' => (int) ($this->contentViewport->x + $cursorX), + 'y' => $y, + 'w' => 2, + 'h' => $this->lineHeight, + ]); + } + + private function renderSelection(&$window, TextRenderer $textRenderer): void + { + [$startLine, $startCol, $endLine, $endCol] = $this->normalizeSelection(); + + $textStyle = $this->computedStyles[Text::class] ?? new Text(); + + sdl_set_render_draw_color($window, 100, 150, 255, 128); + + for ($lineIdx = $startLine; $lineIdx <= $endLine; $lineIdx++) { + $y = (((int) $this->contentViewport->y) + ($lineIdx * $this->lineHeight)) - $this->scrollOffsetY; + + if ( + ($y + $this->lineHeight) < $this->contentViewport->y || + $y > ($this->contentViewport->y + $this->contentViewport->height) + ) { + continue; + } + + $lineStart = $lineIdx === $startLine ? $startCol : 0; + $lineEnd = $lineIdx === $endLine ? $endCol : mb_strlen($this->lines[$lineIdx]); + + $beforeSelection = mb_substr($this->lines[$lineIdx], 0, $lineStart); + $selectedText = mb_substr($this->lines[$lineIdx], $lineStart, $lineEnd - $lineStart); + + [$startX] = $textRenderer->measureText($beforeSelection, $textStyle->size); + [$selectionWidth] = $textRenderer->measureText($selectedText, $textStyle->size); + + sdl_render_fill_rect($window, [ + 'x' => (int) ($this->contentViewport->x + $startX), + 'y' => $y, + 'w' => (int) $selectionWidth, + 'h' => $this->lineHeight, + ]); + } + } + + private function resetCursorBlink(): void + { + $this->cursorVisible = true; + $this->cursorBlinkTimer = 0; + } +}