sdl3/src/Ui/Widget/TextInput.php
2025-11-13 22:23:20 +01:00

453 lines
16 KiB
PHP

<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Style\Text as TextStyle;
class TextInput extends Container
{
private string $value = '';
private string $placeholder = '';
private bool $focused = false;
private $onChange = null;
private int $cursorPosition = 0;
private float $cursorBlinkTime = 0;
private bool $cursorVisible = true;
private ?int $selectionStart = null;
private ?int $selectionEnd = null;
public function __construct(
string $placeholder = '',
string $style = '',
$onChange = null,
) {
parent::__construct($style);
$this->placeholder = $placeholder;
$this->onChange = $onChange;
}
public function layout(null|TextRenderer $textRenderer = null): void
{
// Parse styles first to get computed styles
$this->computedStyles = \PHPNative\Tailwind\StyleParser::parse($this->style)->getValidStyles(
\PHPNative\Tailwind\Style\MediaQueryEnum::normal,
\PHPNative\Tailwind\Style\StateEnum::normal,
);
// Call parent layout
parent::layout($textRenderer);
// If no explicit height is set, calculate based on text size + padding
if (!isset($this->computedStyles[\PHPNative\Tailwind\Style\Height::class])) {
$padding = $this->computedStyles[\PHPNative\Tailwind\Style\Padding::class] ?? null;
$paddingY = $padding ? ($padding->top + $padding->bottom) : 0;
$textStyle = $this->computedStyles[\PHPNative\Tailwind\Style\Text::class] ?? new TextStyle();
$fontSize = $textStyle->size;
// Calculate text height
$textHeight = 16; // Default font size
if ($textRenderer !== null && $textRenderer->isInitialized()) {
$displayText = empty($this->value) ? ($this->placeholder ?: 'A') : 'A';
$size = $textRenderer->measureText($displayText, $fontSize);
$textHeight = $size[1] ?? 16;
}
// Set height to text height + padding + borders (2px * 2)
$this->viewport->height = (int) ($textHeight + $paddingY + 4);
$this->contentViewport->height = max(0, (int) ($textHeight + $paddingY));
}
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): void
{
$this->value = $value;
$this->cursorPosition = mb_strlen($value);
if ($this->onChange !== null) {
($this->onChange)($value);
}
}
public function setOnChange(callable $onChange): void
{
$this->onChange = $onChange;
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
// Check if click is within bounds
if (
$mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
$mouseY >= $this->viewport->y &&
$mouseY <= ($this->viewport->y + $this->viewport->height)
) {
$this->focused = true;
return true;
} else {
$this->focused = false;
return false;
}
}
public function handleTextInput(string $text): void
{
if (!$this->focused) {
return;
}
// Delete selection if any
if ($this->hasSelection()) {
$this->deleteSelection();
}
// Insert text at cursor position
$before = mb_substr($this->value, 0, $this->cursorPosition);
$after = mb_substr($this->value, $this->cursorPosition);
$this->value = $before . $text . $after;
$this->cursorPosition += mb_strlen($text);
if ($this->onChange !== null) {
($this->onChange)($this->value);
}
}
public function handleKeyDown(int $keycode, int $mod = 0): bool
{
if (!$this->focused) {
return false;
}
// Check for modifier keys
$ctrlPressed = ($mod & \KMOD_CTRL) !== 0;
$shiftPressed = ($mod & \KMOD_SHIFT) !== 0;
// Handle Ctrl+A (Select All)
if ($ctrlPressed && $keycode === \SDLK_A) {
$this->selectionStart = 0;
$this->selectionEnd = mb_strlen($this->value);
return true;
}
// Handle Ctrl+C (Copy)
if ($ctrlPressed && $keycode === \SDLK_C) {
if ($this->hasSelection()) {
$selectedText = $this->getSelectedText();
\sdl_set_clipboard_text($selectedText);
}
return true;
}
// Handle Ctrl+X (Cut)
if ($ctrlPressed && $keycode === \SDLK_X) {
if ($this->hasSelection()) {
$selectedText = $this->getSelectedText();
\sdl_set_clipboard_text($selectedText);
$this->deleteSelection();
}
return true;
}
// Handle Ctrl+V (Paste)
if ($ctrlPressed && $keycode === \SDLK_V) {
$clipboardText = \sdl_get_clipboard_text();
if (!empty($clipboardText)) {
// Delete selection if any
if ($this->hasSelection()) {
$this->deleteSelection();
}
// Insert clipboard text at cursor
$before = mb_substr($this->value, 0, $this->cursorPosition);
$after = mb_substr($this->value, $this->cursorPosition);
$this->value = $before . $clipboardText . $after;
$this->cursorPosition += mb_strlen($clipboardText);
if ($this->onChange !== null) {
($this->onChange)($this->value);
}
}
return true;
}
switch ($keycode) {
case \SDLK_BACKSPACE:
if ($this->hasSelection()) {
$this->deleteSelection();
} elseif ($this->cursorPosition > 0) {
$before = mb_substr($this->value, 0, $this->cursorPosition - 1);
$after = mb_substr($this->value, $this->cursorPosition);
$this->value = $before . $after;
$this->cursorPosition--;
if ($this->onChange !== null) {
($this->onChange)($this->value);
}
}
return true;
case \SDLK_DELETE:
if ($this->hasSelection()) {
$this->deleteSelection();
} elseif ($this->cursorPosition < mb_strlen($this->value)) {
$before = mb_substr($this->value, 0, $this->cursorPosition);
$after = mb_substr($this->value, $this->cursorPosition + 1);
$this->value = $before . $after;
if ($this->onChange !== null) {
($this->onChange)($this->value);
}
}
return true;
case \SDLK_LEFT:
if ($this->cursorPosition > 0) {
if ($shiftPressed) {
// Start or extend selection
if (!$this->hasSelection()) {
$this->selectionStart = $this->cursorPosition;
}
$this->cursorPosition--;
$this->selectionEnd = $this->cursorPosition;
} else {
// Clear selection and move cursor
if ($this->hasSelection()) {
// Move cursor to start of selection
$this->cursorPosition = min($this->selectionStart, $this->selectionEnd);
$this->clearSelection();
} else {
$this->cursorPosition--;
}
}
}
return true;
case \SDLK_RIGHT:
if ($this->cursorPosition < mb_strlen($this->value)) {
if ($shiftPressed) {
// Start or extend selection
if (!$this->hasSelection()) {
$this->selectionStart = $this->cursorPosition;
}
$this->cursorPosition++;
$this->selectionEnd = $this->cursorPosition;
} else {
// Clear selection and move cursor
if ($this->hasSelection()) {
// Move cursor to end of selection
$this->cursorPosition = max($this->selectionStart, $this->selectionEnd);
$this->clearSelection();
} else {
$this->cursorPosition++;
}
}
}
return true;
case \SDLK_HOME:
if ($shiftPressed) {
// Extend selection to start
if (!$this->hasSelection()) {
$this->selectionStart = $this->cursorPosition;
}
$this->cursorPosition = 0;
$this->selectionEnd = $this->cursorPosition;
} else {
$this->cursorPosition = 0;
$this->clearSelection();
}
return true;
case \SDLK_END:
if ($shiftPressed) {
// Extend selection to end
if (!$this->hasSelection()) {
$this->selectionStart = $this->cursorPosition;
}
$this->cursorPosition = mb_strlen($this->value);
$this->selectionEnd = $this->cursorPosition;
} else {
$this->cursorPosition = mb_strlen($this->value);
$this->clearSelection();
}
return true;
case \SDLK_RETURN:
// Enter key - can be handled by parent
return false;
}
return false;
}
public function isFocused(): bool
{
return $this->focused;
}
public function focus(): void
{
$this->focused = true;
}
public function blur(): void
{
$this->focused = false;
$this->clearSelection();
}
private function hasSelection(): bool
{
return $this->selectionStart !== null &&
$this->selectionEnd !== null &&
$this->selectionStart !== $this->selectionEnd;
}
private function getSelectedText(): string
{
if (!$this->hasSelection()) {
return '';
}
$start = min($this->selectionStart, $this->selectionEnd);
$end = max($this->selectionStart, $this->selectionEnd);
return mb_substr($this->value, $start, $end - $start);
}
private function deleteSelection(): void
{
if (!$this->hasSelection()) {
return;
}
$start = min($this->selectionStart, $this->selectionEnd);
$end = max($this->selectionStart, $this->selectionEnd);
$before = mb_substr($this->value, 0, $start);
$after = mb_substr($this->value, $end);
$this->value = $before . $after;
$this->cursorPosition = $start;
$this->clearSelection();
if ($this->onChange !== null) {
($this->onChange)($this->value);
}
}
private function clearSelection(): void
{
$this->selectionStart = null;
$this->selectionEnd = null;
}
public function render(&$renderer, null|TextRenderer $textRenderer = null): void
{
// Render background with focus indicator
if ($this->focused) {
// Focused: blue border
sdl_set_render_draw_color($renderer, 59, 130, 246, 255); // Blue-500
} else {
// Not focused: gray border
sdl_set_render_draw_color($renderer, 209, 213, 219, 255); // Gray-300
}
// Draw border
sdl_render_fill_rect($renderer, [
'x' => $this->viewport->x,
'y' => $this->viewport->y,
'w' => $this->viewport->width,
'h' => $this->viewport->height,
]);
// Draw white background inside
sdl_set_render_draw_color($renderer, 255, 255, 255, 255);
sdl_render_fill_rect($renderer, [
'x' => $this->viewport->x + 2,
'y' => $this->viewport->y + 2,
'w' => $this->viewport->width - 4,
'h' => $this->viewport->height - 4,
]);
$textStyle = $this->computedStyles[\PHPNative\Tailwind\Style\Text::class] ?? new TextStyle();
$fontSize = $textStyle->size;
// Render selection highlight
if ($this->hasSelection() && $textRenderer !== null && $textRenderer->isInitialized()) {
$start = min($this->selectionStart, $this->selectionEnd);
$end = max($this->selectionStart, $this->selectionEnd);
$textBeforeSelection = mb_substr($this->value, 0, $start);
$selectedText = mb_substr($this->value, $start, $end - $start);
$selectionX = $this->viewport->x + 6;
if (!empty($textBeforeSelection)) {
$size = $textRenderer->measureText($textBeforeSelection, $fontSize);
$selectionX += $size[0];
}
$selectionWidth = 0;
if (!empty($selectedText)) {
$size = $textRenderer->measureText($selectedText, $fontSize);
$selectionWidth = $size[0];
}
// Draw selection background (blue)
sdl_set_render_draw_color($renderer, 59, 130, 246, 100); // Blue with transparency
sdl_render_fill_rect($renderer, [
'x' => $selectionX,
'y' => $this->viewport->y + 4,
'w' => $selectionWidth,
'h' => $this->viewport->height - 8,
]);
}
// Render text or placeholder
if ($textRenderer !== null && $textRenderer->isInitialized()) {
$displayText = empty($this->value) ? $this->placeholder : $this->value;
$textX = $this->viewport->x + 6;
$textY = $this->viewport->y + 6;
// Set color: gray for placeholder, black for text
if (empty($this->value)) {
$textRenderer->setColor(156/255, 163/255, 175/255, 1.0); // Gray-400
} else {
$textRenderer->setColor(0, 0, 0, 1.0); // Black
}
$textRenderer->drawText($displayText, (int) $textX, (int) $textY, $fontSize);
}
// Render cursor if focused
if ($this->focused && $this->cursorVisible && $textRenderer) {
// Calculate cursor position
$textBeforeCursor = mb_substr($this->value, 0, $this->cursorPosition);
$cursorX = $this->viewport->x + 6; // Left padding
if (!empty($textBeforeCursor)) {
$size = $textRenderer->measureText($textBeforeCursor, $fontSize);
$cursorX += $size[0];
}
// Draw cursor
sdl_set_render_draw_color($renderer, 0, 0, 0, 255);
sdl_render_fill_rect($renderer, [
'x' => $cursorX,
'y' => $this->viewport->y + 4,
'w' => 2,
'h' => $this->viewport->height - 8,
]);
}
// Don't render children - we handle text rendering directly
}
}