This commit is contained in:
Thomas Peterson 2025-11-29 22:10:58 +01:00
parent e617930ca4
commit bf986acb49
8 changed files with 415 additions and 39 deletions

View File

@ -530,7 +530,25 @@ class ServerListTab
$serverListTab->currentServerData[$i]['domains'] = $domains; $serverListTab->currentServerData[$i]['domains'] = $domains;
} }
$serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker'];
$serverListTab->table->setData($serverListTab->currentServerData, true); $searchTerm = $serverListTab->searchInput->getValue();
if (empty($searchTerm)) {
$serverListTab->table->setData($serverListTab->currentServerData, true);
} else {
$filteredData = array_filter(
$serverListTab->currentServerData,
function ($row) use ($searchTerm) {
return (
str_contains(strtolower($row['name']), $searchTerm) ||
count(array_filter($row['domains'], function ($item) use (
$searchTerm,
) {
return str_contains(strtolower($item), $searchTerm);
}))
);
},
);
$serverListTab->table->setData(array_values($filteredData), true);
}
} }
// Map system status into human-readable table fields // Map system status into human-readable table fields
@ -664,20 +682,18 @@ class ServerListTab
if ($result['success']) { if ($result['success']) {
$serverListTab->statusLabel->setText('Reboot ausgelöst für ' . $name); $serverListTab->statusLabel->setText('Reboot ausgelöst für ' . $name);
if (function_exists('desktop_notify')) { if (function_exists('desktop_notify')) {
desktop_notify( desktop_notify('Reboot gestartet', 'Reboot ausgelöst für ' . $name, [
'Reboot gestartet', 'timeout' => 4000,
'Reboot ausgelöst für ' . $name, 'urgency' => 'normal',
['timeout' => 4000, 'urgency' => 'normal'], ]);
);
} }
} elseif (isset($result['error'])) { } elseif (isset($result['error'])) {
$serverListTab->statusLabel->setText('Reboot Fehler bei ' . $name . ': ' . $result['error']); $serverListTab->statusLabel->setText('Reboot Fehler bei ' . $name . ': ' . $result['error']);
if (function_exists('desktop_notify')) { if (function_exists('desktop_notify')) {
desktop_notify( desktop_notify('Reboot fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [
'Reboot fehlgeschlagen', 'timeout' => 6000,
'Fehler bei ' . $name . ': ' . $result['error'], 'urgency' => 'critical',
['timeout' => 6000, 'urgency' => 'critical'], ]);
);
} }
} }
}); });
@ -773,27 +789,39 @@ class ServerListTab
$needsReboot = (bool) $result['needs_reboot']; $needsReboot = (bool) $result['needs_reboot'];
$serverListTab->currentServerData[$index]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; $serverListTab->currentServerData[$index]['needs_reboot'] = $needsReboot ? 'ja' : 'nein';
} }
$searchTerm = $serverListTab->searchInput->getValue();
$serverListTab->table->setData($serverListTab->currentServerData, true); if (empty($searchTerm)) {
$serverListTab->table->setData($serverListTab->currentServerData, true);
} else {
$filteredData = array_filter($serverListTab->currentServerData, function ($row) use (
$searchTerm,
) {
return (
str_contains(strtolower($row['name']), $searchTerm) ||
count(array_filter($row['domains'], function ($item) use ($searchTerm) {
return str_contains(strtolower($item), $searchTerm);
}))
);
});
$serverListTab->table->setData(array_values($filteredData), false);
}
} }
if ($result['success']) { if ($result['success']) {
$serverListTab->statusLabel->setText('Updates ausgeführt für ' . $name); $serverListTab->statusLabel->setText('Updates ausgeführt für ' . $name);
if (function_exists('desktop_notify')) { if (function_exists('desktop_notify')) {
desktop_notify( desktop_notify('Update erfolgreich', 'Updates ausgeführt für ' . $name, [
'Update erfolgreich', 'timeout' => 5000,
'Updates ausgeführt für ' . $name, 'urgency' => 'normal',
['timeout' => 5000, 'urgency' => 'normal'], ]);
);
} }
} elseif (isset($result['error'])) { } elseif (isset($result['error'])) {
$serverListTab->statusLabel->setText('Update Fehler bei ' . $name . ': ' . $result['error']); $serverListTab->statusLabel->setText('Update Fehler bei ' . $name . ': ' . $result['error']);
if (function_exists('desktop_notify')) { if (function_exists('desktop_notify')) {
desktop_notify( desktop_notify('Update fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [
'Update fehlgeschlagen', 'timeout' => 6000,
'Fehler bei ' . $name . ': ' . $result['error'], 'urgency' => 'critical',
['timeout' => 6000, 'urgency' => 'critical'], ]);
);
} }
} }
}); });

View File

@ -31,6 +31,8 @@ class SftpManagerTab
private Label $deleteConfirmLabel; private Label $deleteConfirmLabel;
private null|array $currentLocalSelection = null; private null|array $currentLocalSelection = null;
private null|array $currentRemoteSelection = null; private null|array $currentRemoteSelection = null;
private ?string $lastRemoteClickPath = null;
private float $lastRemoteClickTime = 0.0;
public function __construct( public function __construct(
string &$apiKey, string &$apiKey,
@ -45,6 +47,7 @@ class SftpManagerTab
$currentRemoteStartDir = &$remoteStartDir; $currentRemoteStartDir = &$remoteStartDir;
// Left side: Local file browser // Left side: Local file browser
// Lokaler Browser: Spalte, Scrollen übernimmt der FileBrowser selbst
$localBrowserContainer = new Container('flex flex-col flex-1 gap-2'); $localBrowserContainer = new Container('flex flex-col flex-1 gap-2');
$localBrowserContainer->addComponent(new Label('Lokal', 'text-lg font-bold text-black mb-2')); $localBrowserContainer->addComponent(new Label('Lokal', 'text-lg font-bold text-black mb-2'));
$this->localFileBrowser = new FileBrowser( $this->localFileBrowser = new FileBrowser(
@ -64,6 +67,7 @@ class SftpManagerTab
}); });
// Right side: Remote file browser // Right side: Remote file browser
// Remote-Browser: Spalte, Scrollen übernimmt der FileBrowser selbst
$remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); $remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2');
// Header with title and new file button // Header with title and new file button
@ -191,6 +195,25 @@ class SftpManagerTab
return; return;
} }
// Require double click for navigation:
// first click selektiert nur, zweiter Klick (innerhalb kurzer Zeit)
// öffnet das Verzeichnis.
$now = microtime(true);
$doubleClickThreshold = 0.4; // Sekunden
if (
$sftpTab->lastRemoteClickPath !== $path ||
($now - $sftpTab->lastRemoteClickTime) > $doubleClickThreshold
) {
$sftpTab->lastRemoteClickPath = $path;
$sftpTab->lastRemoteClickTime = $now;
return;
}
// Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten
$sftpTab->lastRemoteClickPath = null;
$sftpTab->lastRemoteClickTime = 0.0;
$loadButton = new Button('Load', ''); $loadButton = new Button('Load', '');
$loadButton->setOnClickAsync( $loadButton->setOnClickAsync(
function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) {
@ -343,7 +366,9 @@ class SftpManagerTab
if (isset($result['success']) && $result['success']) { if (isset($result['success']) && $result['success']) {
// Switch to SFTP tab on successful connection // Switch to SFTP tab on successful connection
$tabContainer->setActiveTab(1); // Tab-Reihenfolge in App.php:
// 0 = Server, 1 = Kanban, 2 = SFTP Manager
$tabContainer->setActiveTab(2);
$sftpTab->connectionStatusLabel->setText( $sftpTab->connectionStatusLabel->setText(
'Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')', 'Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')',

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Window;
$app = new Application();
$window = new Window('TextArea Scroll Test', 800, 600);
// Root container: nur Hintergrund, TextArea soll den ganzen Bereich nutzen
$root = new \PHPNative\Ui\Widget\Container('bg-gray-100');
// Viel Text erzeugen
$lines = [];
for ($i = 1; $i <= 200; $i++) {
$lines[] = sprintf('Zeile %03d: Dies ist eine Testzeile zum Scrollen in der TextArea.', $i);
}
$longText = implode("\n", $lines);
$textArea = new TextArea($longText, 'Scroll-Test', 'w-full h-full border border-gray-300 bg-white text-black font-mono text-sm');
// Optional: kein Texture-Cache, damit Änderungen sofort sichtbar sind
$textArea->setUseTextureCache(false);
$root->addComponent($textArea);
$window->setRoot($root);
$app->addWindow($window);
$app->run();

View File

@ -8,6 +8,8 @@ namespace PHPNative\Framework;
class TextRenderer class TextRenderer
{ {
private const MAX_TEXTURE_SIZE = 16000;
private $renderer; private $renderer;
private bool $initialized = false; private bool $initialized = false;
private string $fontPath = ''; private string $fontPath = '';
@ -103,6 +105,58 @@ class TextRenderer
return $this->loadFont($size); return $this->loadFont($size);
} }
/**
* Ensure that rendered text does not exceed the maximum supported
* texture size by truncating very long strings.
*/
private function truncateToMaxTextureSize(string $text, $font): string
{
if ($text === '') {
return $text;
}
$dimensions = ttf_size_text($font, $text);
$width = (int) ($dimensions['w'] ?? 0);
$height = (int) ($dimensions['h'] ?? 0);
if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) {
return $text;
}
$length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text);
if ($length <= 1) {
return $text;
}
// Estimate how much we can keep based on width ratio
$scale = self::MAX_TEXTURE_SIZE / max($width, 1);
$targetLength = max(1, (int) floor($length * $scale));
$substr = \function_exists('mb_substr') ? 'mb_substr' : 'substr';
$text = $substr($text, 0, $targetLength);
// Safety: if still too large, iteratively shrink
for ($i = 0; $i < 3; $i++) {
$dimensions = ttf_size_text($font, $text);
$width = (int) ($dimensions['w'] ?? 0);
$height = (int) ($dimensions['h'] ?? 0);
if ($width <= self::MAX_TEXTURE_SIZE && $height <= self::MAX_TEXTURE_SIZE) {
break;
}
$length = \function_exists('mb_strlen') ? mb_strlen($text) : strlen($text);
if ($length <= 1) {
break;
}
$targetLength = max(1, (int) floor($length * 0.7));
$text = $substr($text, 0, $targetLength);
}
return $text;
}
private function getScaledFontSize(int $size): int private function getScaledFontSize(int $size): int
{ {
if ($this->pixelRatio <= 1.0) { if ($this->pixelRatio <= 1.0) {
@ -178,7 +232,14 @@ class TextRenderer
if (strlen($text) < 1) { if (strlen($text) < 1) {
return; return;
} }
$surface = ttf_render_text_blended($renderFont, $text, $r, $g, $b); // Truncate extremely long text so that the resulting texture
// stays within the GPU's max texture size.
$safeText = $this->truncateToMaxTextureSize($text, $renderFont);
if ($safeText === '') {
return;
}
$surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b);
if (!$surface) { if (!$surface) {
return; return;
} }
@ -190,7 +251,7 @@ class TextRenderer
} }
// Get text size // Get text size
$textSize = ttf_size_text($baseFont, $text); $textSize = ttf_size_text($baseFont, $safeText);
// Render texture // Render texture
sdl_render_texture($this->renderer, $texture, [ sdl_render_texture($this->renderer, $texture, [
@ -230,7 +291,14 @@ class TextRenderer
if (strlen($text) < 1) { if (strlen($text) < 1) {
return null; return null;
} }
$surface = ttf_render_text_blended($renderFont, $text, $r, $g, $b); // Truncate extremely long text so that the resulting texture
// stays within the GPU's max texture size.
$safeText = $this->truncateToMaxTextureSize($text, $renderFont);
if ($safeText === '') {
return null;
}
$surface = ttf_render_text_blended($renderFont, $safeText, $r, $g, $b);
if (!$surface) { if (!$surface) {
return null; return null;
} }
@ -240,7 +308,7 @@ class TextRenderer
return null; return null;
} }
$dimensions = ttf_size_text($baseFont, $text); $dimensions = ttf_size_text($baseFont, $safeText);
sdl_set_texture_blend_mode($texture, SDL_BLENDMODE_BLEND); sdl_set_texture_blend_mode($texture, SDL_BLENDMODE_BLEND);
if (\function_exists('sdl_set_texture_alpha_mod')) { if (\function_exists('sdl_set_texture_alpha_mod')) {

View File

@ -30,7 +30,7 @@ class Container extends Component
private float $scrollStartY = 0; private float $scrollStartY = 0;
// Scrollbar dimensions // Scrollbar dimensions
private const SCROLLBAR_WIDTH = 12; private const SCROLLBAR_WIDTH = 16;
private const SCROLLBAR_MIN_SIZE = 20; private const SCROLLBAR_MIN_SIZE = 20;
public function __construct(string $style = '') public function __construct(string $style = '')

View File

@ -12,10 +12,15 @@ class FileBrowser extends Container
private $onRenameFile = null; private $onRenameFile = null;
private $onDeleteFile = null; private $onDeleteFile = null;
private bool $isRemote = false; private bool $isRemote = false;
private ?string $lastClickPath = null;
private float $lastClickTime = 0.0;
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '') public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
{ {
parent::__construct('w-full flex flex-col gap-2 ' . $style); // Root-Container füllt die verfügbare Höhe im Eltern-Layout (flex-1),
// damit die Tabelle unten eine klar begrenzte Höhe bekommt und
// ihr Body sinnvoll scrollen kann.
parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style);
$this->currentPath = $initialPath; $this->currentPath = $initialPath;
$this->isRemote = $isRemote; $this->isRemote = $isRemote;
@ -24,8 +29,10 @@ class FileBrowser extends Container
$this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono'); $this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono');
$this->addComponent($this->pathLabel); $this->addComponent($this->pathLabel);
// File table with explicit flex-1 for scrolling // File table, die den verbleibenden Platz im FileBrowser nutzt.
$this->fileTable = new Table(''); // Das eigentliche Scrollen passiert im Body-Container der Table
// (overflow-auto dort).
$this->fileTable = new Table(' flex-1');
$this->fileTable->setColumns([ $this->fileTable->setColumns([
['key' => 'type', 'title' => 'Typ', 'width' => 60], ['key' => 'type', 'title' => 'Typ', 'width' => 60],
['key' => 'name', 'title' => 'Name'], ['key' => 'name', 'title' => 'Name'],
@ -106,13 +113,39 @@ class FileBrowser extends Container
// Handle row selection // Handle row selection
$fileBrowser = $this; $fileBrowser = $this;
$this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) { $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) {
if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { if (!$row || empty($row['path'])) {
// Navigate to directory return;
$fileBrowser->loadDirectory($row['path']);
} elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) {
// File selected
($fileBrowser->onFileSelect)($row['path'], $row);
} }
$path = $row['path'];
// Immer Auswahl-Callback auslösen (z.B. für Upload)
if ($fileBrowser->onFileSelect !== null) {
($fileBrowser->onFileSelect)($path, $row);
}
// Nur Verzeichnisse navigieren per Doppelklick
if (!($row['isDir'] ?? false)) {
return;
}
$now = microtime(true);
$doubleClickThreshold = 0.4; // Sekunden
if (
$fileBrowser->lastClickPath !== $path ||
($now - $fileBrowser->lastClickTime) > $doubleClickThreshold
) {
// Erster Klick: nur merken
$fileBrowser->lastClickPath = $path;
$fileBrowser->lastClickTime = $now;
return;
}
// Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten
$fileBrowser->lastClickPath = null;
$fileBrowser->lastClickTime = 0.0;
$fileBrowser->loadDirectory($path);
}); });
} }

View File

@ -17,7 +17,10 @@ class Table extends Container
public function __construct(string $style = '') public function __construct(string $style = '')
{ {
parent::__construct('flex flex-col w-full' . $style); // Table selbst ist kein Flex-Container; sie wird als Kind
// in einem flex-Layout benutzt (z.B. flex-1), aber intern
// werden Header und Body ganz normal vertikal gestapelt.
parent::__construct('w-full' . $style);
// Create header container // Create header container
$this->headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400'); $this->headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400');
@ -88,6 +91,18 @@ class Table extends Container
if ($preserveScroll && $scrollPosition !== null) { if ($preserveScroll && $scrollPosition !== null) {
$this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']); $this->bodyContainer->setScrollPosition($scrollPosition['x'], $scrollPosition['y']);
} }
// Debug-Ausgabe: Größe des Tabellen-Body und Viewport
$bodySize = $this->bodyContainer->getContentSize();
$bodyViewport = $this->bodyContainer->getContentViewport();
error_log(
sprintf(
'Table body debug: rows=%d, contentHeight=%d, viewportHeight=%d',
count($data),
(int) ($bodySize['height'] ?? 0),
(int) $bodyViewport->height,
),
);
} }
/** /**

View File

@ -10,6 +10,9 @@ use PHPNative\Tailwind\Style\Text;
class TextArea extends Container class TextArea extends Container
{ {
private const SCROLLBAR_WIDTH = 12;
private const SCROLLBAR_MIN_SIZE = 20;
private array $lines = ['']; private array $lines = [''];
private int $cursorLine = 0; private int $cursorLine = 0;
private int $cursorCol = 0; private int $cursorCol = 0;
@ -23,6 +26,10 @@ class TextArea extends Container
private int $selectionEndLine = -1; private int $selectionEndLine = -1;
private int $selectionEndCol = -1; private int $selectionEndCol = -1;
private bool $isDraggingScrollbar = false;
private float $dragStartY = 0.0;
private int $scrollStartOffsetY = 0;
public function __construct( public function __construct(
public string $value = '', public string $value = '',
public string $placeholder = '', public string $placeholder = '',
@ -173,6 +180,48 @@ class TextArea extends Container
return false; return false;
} }
// Handle click on scrollbar (if present)
$contentHeight = count($this->lines) * $this->lineHeight;
$viewportHeight = $this->contentViewport->height;
if ($contentHeight > $viewportHeight) {
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH;
$scrollbarY = $this->contentViewport->y;
if (
$mouseX >= $scrollbarX &&
$mouseX <= ($scrollbarX + self::SCROLLBAR_WIDTH) &&
$mouseY >= $scrollbarY &&
$mouseY <= ($scrollbarY + $viewportHeight)
) {
$scrollbarHeight = $viewportHeight;
$thumbHeight = max(
self::SCROLLBAR_MIN_SIZE,
($viewportHeight / $contentHeight) * $scrollbarHeight,
);
$maxScroll = $contentHeight - $viewportHeight;
if ($maxScroll > 0) {
$thumbRange = $scrollbarHeight - $thumbHeight;
// Position scroll to where user clicked (center thumb on click)
$clickPos = $mouseY - $scrollbarY - ($thumbHeight / 2);
$clickPos = max(0, min($thumbRange, $clickPos));
$scrollRatio = $thumbRange > 0 ? ($clickPos / $thumbRange) : 0;
$this->scrollOffsetY = (int) max(0, min($maxScroll, $scrollRatio * $maxScroll));
// Nur neu rendern, kein neues Layout nötig
$this->markDirty(false, false);
// Start drag
$this->isDraggingScrollbar = true;
$this->dragStartY = $mouseY;
$this->scrollStartOffsetY = $this->scrollOffsetY;
}
return true;
}
}
if ( if (
$mouseX >= $this->viewport->x && $mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) && $mouseX <= ($this->viewport->x + $this->viewport->width) &&
@ -205,6 +254,95 @@ class TextArea extends Container
return false; return false;
} }
public function handleMouseMove(float $mouseX, float $mouseY): void
{
parent::handleMouseMove($mouseX, $mouseY);
if (!$this->visible || !$this->isDraggingScrollbar) {
return;
}
$contentHeight = count($this->lines) * $this->lineHeight;
$viewportHeight = $this->contentViewport->height;
if ($contentHeight <= $viewportHeight) {
return;
}
$scrollbarHeight = $viewportHeight;
$thumbHeight = max(
self::SCROLLBAR_MIN_SIZE,
($viewportHeight / $contentHeight) * $scrollbarHeight,
);
$maxScroll = $contentHeight - $viewportHeight;
$thumbRange = $scrollbarHeight - $thumbHeight;
if ($thumbRange <= 0 || $maxScroll <= 0) {
return;
}
$deltaY = $mouseY - $this->dragStartY;
$scrollRatioDelta = $deltaY / $thumbRange;
$newScroll = $this->scrollStartOffsetY + ($scrollRatioDelta * $maxScroll);
$newOffset = (int) max(0, min($maxScroll, $newScroll));
if ($newOffset !== $this->scrollOffsetY) {
$this->scrollOffsetY = $newOffset;
// Nur neu rendern, kein neues Layout nötig
$this->markDirty(false, false);
}
}
public function handleMouseRelease(float $mouseX, float $mouseY, int $button): void
{
parent::handleMouseRelease($mouseX, $mouseY, $button);
$this->isDraggingScrollbar = false;
}
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool
{
if (!$this->visible) {
return false;
}
if (
$mouseX < $this->contentViewport->x ||
$mouseX > ($this->contentViewport->x + $this->contentViewport->width) ||
$mouseY < $this->contentViewport->y ||
$mouseY > ($this->contentViewport->y + $this->contentViewport->height)
) {
return false;
}
$contentHeight = count($this->lines) * $this->lineHeight;
$maxScroll = max(0, $contentHeight - $this->contentViewport->height);
if ($maxScroll <= 0) {
return false;
}
// Schneller scrollen: mehrere Zeilen pro Tick
$scrollStep = max($this->lineHeight * 3, 20);
$oldOffset = $this->scrollOffsetY;
$this->scrollOffsetY = (int) max(
0,
min($maxScroll, $this->scrollOffsetY + ($deltaY * $scrollStep)),
);
if ($this->scrollOffsetY === $oldOffset) {
// Keine tatsächliche Bewegung parent darf evtl. weiter scrollen
return false;
}
// Nur neu rendern, kein neues Layout nötig
$this->markDirty(false, false);
return true;
}
public function layout(null|TextRenderer $textRenderer = null): void public function layout(null|TextRenderer $textRenderer = null): void
{ {
parent::layout($textRenderer); parent::layout($textRenderer);
@ -274,6 +412,42 @@ class TextArea extends Container
$this->renderCursor($window, $textRenderer); $this->renderCursor($window, $textRenderer);
} }
// Draw simple vertical scrollbar if content is higher than viewport
$contentHeight = count($this->lines) * $this->lineHeight;
$viewportHeight = $this->contentViewport->height;
if ($contentHeight > $viewportHeight) {
$scrollbarWidth = self::SCROLLBAR_WIDTH;
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - $scrollbarWidth;
$scrollbarY = $this->contentViewport->y;
// Track
sdl_set_render_draw_color($window, 220, 220, 220, 255);
sdl_render_fill_rect($window, [
'x' => (int) $scrollbarX,
'y' => (int) $scrollbarY,
'w' => (int) $scrollbarWidth,
'h' => (int) $viewportHeight,
]);
// Thumb
$thumbHeight = max(
20,
($viewportHeight / $contentHeight) * $viewportHeight,
);
$maxScroll = $contentHeight - $viewportHeight;
$scrollRatio = $maxScroll > 0 ? ($this->scrollOffsetY / $maxScroll) : 0;
$thumbY = $scrollbarY + ($scrollRatio * ($viewportHeight - $thumbHeight));
sdl_set_render_draw_color($window, 120, 120, 120, 230);
sdl_render_fill_rect($window, [
'x' => (int) ($scrollbarX + 1),
'y' => (int) $thumbY,
'w' => (int) ($scrollbarWidth - 2),
'h' => (int) $thumbHeight,
]);
}
// Update cursor blink // Update cursor blink
$this->cursorBlinkTimer++; $this->cursorBlinkTimer++;
if ($this->cursorBlinkTimer >= 30) { if ($this->cursorBlinkTimer >= 30) {