sdl3/src/Ui/Widget/TextArea.php
2025-11-12 11:56:45 +01:00

695 lines
20 KiB
PHP

<?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;
}
}