Backup
This commit is contained in:
parent
96cd1ea48e
commit
d7e3a95f9b
92
examples/ServerManager/App.php
Normal file
92
examples/ServerManager/App.php
Normal 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();
|
||||
}
|
||||
}
|
||||
61
examples/ServerManager/REFACTORING_PLAN.md
Normal file
61
examples/ServerManager/REFACTORING_PLAN.md
Normal 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
|
||||
60
examples/ServerManager/Services/HetznerService.php
Normal file
60
examples/ServerManager/Services/HetznerService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
examples/ServerManager/UI/MenuBarBuilder.php
Normal file
28
examples/ServerManager/UI/MenuBarBuilder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
277
examples/ServerManager/UI/ServerListTab.php
Normal file
277
examples/ServerManager/UI/ServerListTab.php
Normal 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;
|
||||
}
|
||||
}
|
||||
118
examples/ServerManager/UI/SettingsModal.php
Normal file
118
examples/ServerManager/UI/SettingsModal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
925
examples/ServerManager/UI/SftpManagerTab.php
Normal file
925
examples/ServerManager/UI/SftpManagerTab.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
51
examples/server_manager.php
Normal file
51
examples/server_manager.php
Normal 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
54
examples/shadow_debug.php
Normal 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
74
examples/shadow_test.php
Normal 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();
|
||||
@ -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();
|
||||
|
||||
@ -149,7 +149,6 @@ class TextRenderer
|
||||
'w' => $textSize['w'],
|
||||
'h' => $textSize['h'],
|
||||
]);
|
||||
|
||||
// Note: Texture and surface are automatically cleaned up by PHP resource destructors
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
30
src/Tailwind/Parser/Shadow.php
Normal file
30
src/Tailwind/Parser/Shadow.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
13
src/Tailwind/Style/Shadow.php
Normal file
13
src/Tailwind/Style/Shadow.php
Normal 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(),
|
||||
) {}
|
||||
}
|
||||
@ -96,6 +96,9 @@ class StyleParser
|
||||
if($b = \PHPNative\Tailwind\Parser\Border::parse($style)) {
|
||||
return $b;
|
||||
}
|
||||
if($s = \PHPNative\Tailwind\Parser\Shadow::parse($style)) {
|
||||
return $s;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -197,8 +197,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 ($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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -92,8 +92,20 @@ class Table extends Container
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
foreach ($this->columns as $column) {
|
||||
$key = $column['key'];
|
||||
@ -107,9 +119,15 @@ class Table extends Container
|
||||
$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);
|
||||
$rowContainer->addComponent($cellLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// Make row clickable
|
||||
$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
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
694
src/Ui/Widget/TextArea.php
Normal file
694
src/Ui/Widget/TextArea.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user