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