This commit is contained in:
Thomas Peterson 2025-11-12 11:56:45 +01:00
parent 96cd1ea48e
commit d7e3a95f9b
26 changed files with 2781 additions and 80 deletions

View File

@ -0,0 +1,92 @@
<?php
namespace ServerManager;
use PHPNative\Framework\Application;
use PHPNative\Framework\Settings;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\StatusBar;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Window;
use ServerManager\UI\MenuBarBuilder;
use ServerManager\UI\ServerListTab;
use ServerManager\UI\SettingsModal;
use ServerManager\UI\SftpManagerTab;
class App
{
private Application $app;
private Window $window;
private Settings $settings;
public function __construct()
{
// Initialize application and window
$this->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();
}
}

View File

@ -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

View File

@ -0,0 +1,60 @@
<?php
namespace ServerManager\Services;
use LKDev\HetznerCloud\HetznerAPIClient;
class HetznerService
{
/**
* Load servers from Hetzner API asynchronously
* This function is designed to run in a separate thread
*/
public static function loadServersAsync(string $apiKey): array
{
try {
if (empty($apiKey)) {
return ['error' => '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;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace ServerManager\UI;
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Menu;
use PHPNative\Ui\Widget\MenuBar;
class MenuBarBuilder
{
public static function buildMenus(Application $app, MenuBar $menuBar, SettingsModal $settingsModal): void
{
// File Menu
$fileMenu = new Menu(title: 'Datei');
$fileMenu->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);
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace ServerManager\UI;
use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Widget\Table;
use PHPNative\Ui\Widget\TextInput;
use ServerManager\Services\HetznerService;
class ServerListTab
{
private Container $tab;
private Table $table;
private TextInput $searchInput;
private Label $statusLabel;
private Button $refreshButton;
private Button $sftpButton;
private Button $sshTerminalButton;
public array $currentServerData = [];
public null|array $selectedServer = null;
private Label $detailId;
private Label $detailName;
private Label $detailStatus;
private Label $detailType;
private Label $detailIpv4;
public function __construct(
string &$apiKey,
string &$privateKeyPath,
TabContainer $tabContainer,
Label $statusLabel,
) {
$this->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;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace ServerManager\UI;
use PHPNative\Framework\Settings;
use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\MenuBar;
use PHPNative\Ui\Widget\Modal;
use PHPNative\Ui\Widget\TextInput;
class SettingsModal
{
private Modal $modal;
private TextInput $apiKeyInput;
private TextInput $privateKeyPathInput;
private TextInput $remoteStartDirInput;
public function __construct(Settings $settings, MenuBar $menuBar, string &$apiKey, string &$privateKeyPath, string &$remoteStartDir)
{
// Create input fields
$this->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);
}
}

View File

@ -0,0 +1,925 @@
<?php
namespace ServerManager\UI;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\FileBrowser;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\Modal;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Widget\TextInput;
class SftpManagerTab
{
private Container $tab;
private FileBrowser $localFileBrowser;
private FileBrowser $remoteFileBrowser;
private Label $connectionStatusLabel;
private Modal $editModal;
private TextArea $fileEditor;
private Label $filePathLabel;
private string $currentEditFilePath = '';
private Modal $filenameModal;
private TextInput $filenameInput;
private Modal $renameModal;
private TextInput $renameInput;
private string $currentRenameFilePath = '';
private Modal $deleteConfirmModal;
private string $currentDeleteFilePath = '';
private Label $deleteConfirmLabel;
public function __construct(
string &$apiKey,
string &$privateKeyPath,
string &$remoteStartDir,
ServerListTab $serverListTab,
\PHPNative\Ui\Widget\TabContainer $tabContainer,
Label $statusLabel,
) {
$this->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);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
// Bootstrap: Load composer autoloader
require_once __DIR__ . '/../vendor/autoload.php';
// PSR-4 Autoloader for ServerManager namespace
spl_autoload_register(function ($class) {
$prefix = 'ServerManager\\';
$baseDir = __DIR__ . '/ServerManager/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
// Initialize Icon Font
$iconFontCandidates = [
__DIR__ . '/../assets/fonts/fa-solid-900.ttf',
__DIR__ . '/../assets/fonts/fontawesome/fa7_freesolid_900.otf',
'/usr/share/fonts/truetype/fontawesome-webfont.ttf',
'/usr/share/fonts/truetype/fontawesome/fa-solid-900.ttf',
'/usr/share/fonts/truetype/fa-solid-900.ttf',
];
$iconFontPath = null;
foreach ($iconFontCandidates as $candidate) {
if (is_file($candidate)) {
$iconFontPath = $candidate;
break;
}
}
if ($iconFontPath !== null) {
\PHPNative\Framework\IconFontRegistry::setDefaultFontPath($iconFontPath);
} else {
echo "Hinweis: FontAwesome Font nicht gefunden. Icons werden ohne Symbol dargestellt.\n";
}
// Run the application
$app = new ServerManager\App();
$app->run();

54
examples/shadow_debug.php Normal file
View File

@ -0,0 +1,54 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Tailwind\StyleParser;
// Test shadow parsing
$testStyles = [
'shadow-sm',
'shadow',
'shadow-md',
'shadow-lg',
'shadow-xl',
'shadow-2xl',
'shadow-inner',
'shadow-none',
];
echo "Testing Shadow Parsing:\n";
echo str_repeat('=', 50) . "\n\n";
foreach ($testStyles as $styleString) {
echo "Testing: '{$styleString}'\n";
$styles = StyleParser::parse($styleString);
echo "Parsed StyleCollection:\n";
echo 'Number of styles: ' . $styles->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";
}

74
examples/shadow_test.php Normal file
View File

@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Window;
// Create application
$app = new Application();
$window = new Window('Shadow Test', 800, 600);
// Main container
$mainContainer = new Container('flex flex-col gap-4 p-8');
// Title
$title = new Label('Tailwind Shadow Test', 'text-2xl font-bold text-black mb-4');
$mainContainer->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();

View File

@ -104,36 +104,8 @@ $menuBar = new MenuBar();
// File Menu // File Menu
$fileMenu = new Menu(title: 'Datei'); $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 // Settings Menu
$settingsMenu = new Menu(title: 'Einstellungen'); $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($fileMenu);
$menuBar->addMenu($settingsMenu); $menuBar->addMenu($settingsMenu);
$mainContainer->addComponent($menuBar); $mainContainer->addComponent($menuBar);
@ -581,7 +553,7 @@ $sftpButton->setOnClickAsync(
}, },
); );
$mainContainer->addComponent($tabContainer); //$mainContainer->addComponent($tabContainer);
// === 3. StatusBar === // === 3. StatusBar ===
$statusBar = new StatusBar(); $statusBar = new StatusBar();
@ -599,7 +571,7 @@ $statusBar->addSegment(new Label(
text: 'Version 1.0', text: 'Version 1.0',
style: 'border-l text-black basis-2/8', style: 'border-l text-black basis-2/8',
)); ));
$mainContainer->addComponent($statusBar); //$mainContainer->addComponent($statusBar);
$cancelButton->setOnClick(function () use ($menuBar, $modal) { $cancelButton->setOnClick(function () use ($menuBar, $modal) {
$menuBar->closeAllMenus(); $menuBar->closeAllMenus();

View File

@ -149,7 +149,6 @@ class TextRenderer
'w' => $textSize['w'], 'w' => $textSize['w'],
'h' => $textSize['h'], 'h' => $textSize['h'],
]); ]);
// Note: Texture and surface are automatically cleaned up by PHP resource destructors // Note: Texture and surface are automatically cleaned up by PHP resource destructors
} }

View File

@ -15,4 +15,8 @@ enum Icon:int
case user = 0xf007; // f007 - user case user = 0xf007; // f007 - user
case cog = 0xf013; // f013 - gear (settings) case cog = 0xf013; // f013 - gear (settings)
case home = 0xf015; // f015 - house (home) 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)
} }

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace PHPNative\Tailwind\Parser;
class Shadow implements Parser
{
public static function parse(string $style): ?\PHPNative\Tailwind\Style\Shadow
{
// shadow-sm, shadow, shadow-md, shadow-lg, shadow-xl, shadow-2xl, shadow-inner, shadow-none
if (preg_match('/\bshadow-(sm|md|lg|xl|2xl|inner|none)\b/', $style, $matches)) {
return new \PHPNative\Tailwind\Style\Shadow($matches[1]);
}
// shadow (base shadow)
if (preg_match('/\bshadow\b/', $style)) {
return new \PHPNative\Tailwind\Style\Shadow('base');
}
return null;
}
public static function merge(\PHPNative\Tailwind\Style\Shadow $class, \PHPNative\Tailwind\Style\Shadow $style)
{
if ($style->size !== 'none') {
$class->size = $style->size;
}
}
}

View File

@ -6,7 +6,15 @@ namespace PHPNative\Tailwind\Style;
class Color implements 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;
} }
} }

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace PHPNative\Tailwind\Style;
class Shadow implements Style
{
public function __construct(
public string $size = 'none', // none, sm, base, md, lg, xl, 2xl, inner
public Color $color = new Color(),
) {}
}

View File

@ -96,6 +96,9 @@ class StyleParser
if($b = \PHPNative\Tailwind\Parser\Border::parse($style)) { if($b = \PHPNative\Tailwind\Parser\Border::parse($style)) {
return $b; return $b;
} }
if($s = \PHPNative\Tailwind\Parser\Shadow::parse($style)) {
return $s;
}
return null; return null;
} }

View File

@ -198,7 +198,7 @@ abstract class Component
SDL_PIXELFORMAT_RGBA8888, SDL_PIXELFORMAT_RGBA8888,
SDL_TEXTUREACCESS_TARGET, SDL_TEXTUREACCESS_TARGET,
(int) $this->viewport->width, (int) $this->viewport->width,
(int)$this->viewport->height (int) $this->viewport->height,
); );
if ($normalTexture) { if ($normalTexture) {
@ -236,7 +236,7 @@ abstract class Component
SDL_PIXELFORMAT_RGBA8888, SDL_PIXELFORMAT_RGBA8888,
SDL_TEXTUREACCESS_TARGET, SDL_TEXTUREACCESS_TARGET,
(int) $this->viewport->width, (int) $this->viewport->width,
(int)$this->viewport->height (int) $this->viewport->height,
); );
if ($hoverTexture) { if ($hoverTexture) {
@ -378,7 +378,7 @@ abstract class Component
Profiler::increment('uses_cache'); Profiler::increment('uses_cache');
if ($this->textureCacheValid) { if ($this->textureCacheValid) {
Profiler::increment('cache_valid'); 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) { if ($texture !== null) {
Profiler::increment('texture_cache_hit'); Profiler::increment('texture_cache_hit');
@ -403,6 +403,14 @@ abstract class Component
Profiler::increment('render_normal'); 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 // Render normally if no cache or cache building
if ( if (
isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) && isset($this->computedStyles[\PHPNative\Tailwind\Style\Background::class]) &&
@ -430,10 +438,10 @@ abstract class Component
sdl_rounded_box_ex( sdl_rounded_box_ex(
$renderer, $renderer,
$this->viewport->x, (int) $this->viewport->x,
$this->viewport->y, (int) $this->viewport->y,
$x2, (int) $x2,
$y2, (int) $y2,
$border->roundTopLeft ?? 0, $border->roundTopLeft ?? 0,
$border->roundTopRight ?? 0, $border->roundTopRight ?? 0,
$border->roundBottomRight ?? 0, $border->roundBottomRight ?? 0,
@ -478,8 +486,10 @@ abstract class Component
if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class])) { if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class])) {
$overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class];
// Enable clipping for hidden, clip, scroll, or auto overflow // Enable clipping for hidden, clip, scroll, or auto overflow
if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || if (
$overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { $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 // Actually, let's clip for anything that's not the default
$needsClipping = true; $needsClipping = true;
$clipRect = [ $clipRect = [
@ -503,10 +513,12 @@ abstract class Component
if ($needsClipping && $clipRect) { if ($needsClipping && $clipRect) {
$childViewport = $child->getViewport(); $childViewport = $child->getViewport();
// Check if child is visible within clip rect // Check if child is visible within clip rect
if ($childViewport->x + $childViewport->width < $clipRect['x'] || if (
$childViewport->x > $clipRect['x'] + $clipRect['w'] || ($childViewport->x + $childViewport->width) < $clipRect['x'] ||
$childViewport->y + $childViewport->height < $clipRect['y'] || $childViewport->x > ($clipRect['x'] + $clipRect['w']) ||
$childViewport->y > $clipRect['y'] + $clipRect['h']) { ($childViewport->y + $childViewport->height) < $clipRect['y'] ||
$childViewport->y > ($clipRect['y'] + $clipRect['h'])
) {
continue; // Child is completely outside clip rect, skip rendering continue; // Child is completely outside clip rect, skip rendering
} }
} }
@ -631,8 +643,10 @@ abstract class Component
if ($hasClipping) { if ($hasClipping) {
$overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class]; $overflow = $this->computedStyles[\PHPNative\Tailwind\Style\Overflow::class];
if ($overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden || if (
$overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden) { $overflow->x !== \PHPNative\Tailwind\Style\OverflowEnum::hidden ||
$overflow->y !== \PHPNative\Tailwind\Style\OverflowEnum::hidden
) {
$clipRect = [ $clipRect = [
'x' => $this->contentViewport->x, 'x' => $this->contentViewport->x,
'y' => $this->contentViewport->y, 'y' => $this->contentViewport->y,
@ -661,10 +675,12 @@ abstract class Component
// If we have clipping, check if child is visible within clip rect // If we have clipping, check if child is visible within clip rect
if ($clipRect !== null) { if ($clipRect !== null) {
// Skip children that are completely outside the clip rect // Skip children that are completely outside the clip rect
if ($childViewport->x + $childViewport->width < $clipRect['x'] || if (
$childViewport->x > $clipRect['x'] + $clipRect['w'] || ($childViewport->x + $childViewport->width) < $clipRect['x'] ||
$childViewport->y + $childViewport->height < $clipRect['y'] || $childViewport->x > ($clipRect['x'] + $clipRect['w']) ||
$childViewport->y > $clipRect['y'] + $clipRect['h']) { ($childViewport->y + $childViewport->height) < $clipRect['y'] ||
$childViewport->y > ($clipRect['y'] + $clipRect['h'])
) {
// Child is outside visible area, send fake event to clear hover // Child is outside visible area, send fake event to clear hover
$child->handleMouseMove(-1000, -1000); $child->handleMouseMove(-1000, -1000);
continue; continue;
@ -830,4 +846,111 @@ abstract class Component
{ {
return $this->children; 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,
]);
}
}
}
} }

View File

@ -22,8 +22,9 @@ class Button extends Container
) { ) {
parent::__construct($style); parent::__construct($style);
// Enable texture caching for buttons (huge performance boost!) // Texture caching disabled for buttons - causes issues with variable width buttons
$this->setUseTextureCache(true); // Text is already cached by TextRenderer, so performance impact is minimal
// $this->setUseTextureCache(true);
// Create label inside button // Create label inside button
$this->label = new Label( $this->label = new Label(
@ -144,8 +145,7 @@ class Button extends Container
if ($this->onClickAsync['onError'] !== null) { if ($this->onClickAsync['onError'] !== null) {
$task->onError($this->onClickAsync['onError']); $task->onError($this->onClickAsync['onError']);
} }
} // Call sync onClick callback if set } elseif ($this->onClick !== null) { // Call sync onClick callback if set
elseif ($this->onClick !== null) {
($this->onClick)(); ($this->onClick)();
} }
@ -195,7 +195,7 @@ class Button extends Container
$childIndex = array_search($child, $this->children, true); $childIndex = array_search($child, $this->children, true);
$referenceIndex = array_search($reference, $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; return;
} }

View File

@ -8,11 +8,14 @@ class FileBrowser extends Container
private Label $pathLabel; private Label $pathLabel;
private string $currentPath; private string $currentPath;
private $onFileSelect = null; private $onFileSelect = null;
private $onEditFile = null;
private $onRenameFile = null;
private $onDeleteFile = null;
private bool $isRemote = false; private bool $isRemote = false;
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '') 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->currentPath = $initialPath;
$this->isRemote = $isRemote; $this->isRemote = $isRemote;
@ -22,12 +25,13 @@ class FileBrowser extends Container
$this->addComponent($this->pathLabel); $this->addComponent($this->pathLabel);
// File table with explicit flex-1 for scrolling // File table with explicit flex-1 for scrolling
$this->fileTable = new Table(' flex-1'); $this->fileTable = new Table('');
$this->fileTable->setColumns([ $this->fileTable->setColumns([
['key' => 'type', 'title' => 'Typ', 'width' => 60], ['key' => 'type', 'title' => 'Typ', 'width' => 60],
['key' => 'name', 'title' => 'Name'], ['key' => 'name', 'title' => 'Name'],
['key' => 'size', 'title' => 'Größe', 'width' => 100], ['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); $this->addComponent($this->fileTable);
@ -216,6 +220,81 @@ class FileBrowser extends Container
$this->onFileSelect = $callback; $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 * Get current path
*/ */

View File

@ -30,15 +30,13 @@ class Label extends Component
$this->text = $text; $this->text = $text;
$this->clearTextTexture(); $this->clearTextTexture();
$this->markDirty(true);
// $this->markDirty(true);
} }
public function layout(null|TextRenderer $textRenderer = null): void public function layout(null|TextRenderer $textRenderer = null): void
{ {
// Call parent to compute styles and setup viewports // Call parent to compute styles and setup viewports
parent::layout($textRenderer); parent::layout($textRenderer);
// Measure text to get intrinsic size // Measure text to get intrinsic size
if ($textRenderer !== null && $textRenderer->isInitialized()) { if ($textRenderer !== null && $textRenderer->isInitialized()) {
$textStyle = $this->computedStyles[Text::class] ?? new Text(); $textStyle = $this->computedStyles[Text::class] ?? new Text();
@ -114,9 +112,6 @@ class Label extends Component
} }
$this->renderDirty = false; $this->renderDirty = false;
// Call parent to render children if any
parent::renderContent($window, $textRenderer);
} }
public function __destruct() public function __destruct()

View File

@ -13,7 +13,7 @@ class Menu extends Container
public function __construct(string $title) public function __construct(string $title)
{ {
parent::__construct('relative'); parent::__construct('');
// Create menu button // Create menu button
$this->menuButton = new Button($title, 'px-4 py-2 hover:bg-gray-200', fn() => $this->toggle()); $this->menuButton = new Button($title, 'px-4 py-2 hover:bg-gray-200', fn() => $this->toggle());

View File

@ -46,4 +46,18 @@ class MenuBar extends Container
$this->openMenu = null; $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;
}
} }

View File

@ -162,8 +162,10 @@ class Modal extends Container
return; return;
} }
// Always handle mouse move in modal to prevent background hover states // Pass mouse move to children but don't mark the modal itself as dirty
parent::handleMouseMove($mouseX, $mouseY); foreach ($this->children as $child) {
$child->handleMouseMove($mouseX, $mouseY);
}
// Don't propagate to components below the modal // Don't propagate to components below the modal
} }

View File

@ -92,8 +92,20 @@ class Table extends Container
$rowContainer = new Container($rowStyle); $rowContainer = new Container($rowStyle);
// Enable texture caching for table rows (huge performance boost!) // 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); $rowContainer->setUseTextureCache(true);
}
foreach ($this->columns as $column) { foreach ($this->columns as $column) {
$key = $column['key']; $key = $column['key'];
@ -107,9 +119,15 @@ class Table extends Container
$cellStyle .= ' flex-1'; $cellStyle .= ' flex-1';
} }
// 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); $cellLabel = new Label((string) $value, $cellStyle);
$rowContainer->addComponent($cellLabel); $rowContainer->addComponent($cellLabel);
} }
}
// Make row clickable // Make row clickable
$clickHandler = new class($rowContainer, $rowIndex, $this) extends Container { $clickHandler = new class($rowContainer, $rowIndex, $this) extends Container {
@ -126,7 +144,13 @@ class Table extends Container
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool 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 ( if (
$mouseX >= $this->viewport->x && $mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) && $mouseX <= ($this->viewport->x + $this->viewport->width) &&
@ -136,7 +160,8 @@ class Table extends Container
$this->table->selectRow($this->rowIndex); $this->table->selectRow($this->rowIndex);
return true; return true;
} }
return parent::handleMouseClick($mouseX, $mouseY, $button);
return false;
} }
}; };

694
src/Ui/Widget/TextArea.php Normal file
View File

@ -0,0 +1,694 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Style\Background;
use PHPNative\Tailwind\Style\Border;
use PHPNative\Tailwind\Style\Padding;
use PHPNative\Tailwind\Style\Text;
class TextArea extends Container
{
private array $lines = [''];
private int $cursorLine = 0;
private int $cursorCol = 0;
private bool $focused = false;
private int $cursorBlinkTimer = 0;
private bool $cursorVisible = true;
private int $scrollOffsetY = 0;
private int $lineHeight = 20;
private int $selectionStartLine = -1;
private int $selectionStartCol = -1;
private int $selectionEndLine = -1;
private int $selectionEndCol = -1;
public function __construct(
public string $value = '',
public string $placeholder = '',
string $style = '',
) {
parent::__construct($style);
if (!empty($value)) {
$this->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;
}
}