695 lines
20 KiB
PHP
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;
|
|
}
|
|
}
|