453 lines
16 KiB
PHP
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
|
|
}
|
|
}
|