This commit is contained in:
Thomas Peterson 2025-11-09 20:24:05 +01:00
parent bf087c798d
commit 43a140c08d
20 changed files with 659 additions and 34 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,21 +3,80 @@
require_once __DIR__ . '/../vendor/autoload.php';
use PHPNative\Framework\Application;
use PHPNative\Framework\IconFontRegistry;
use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\Menu;
use PHPNative\Ui\Widget\MenuBar;
use PHPNative\Ui\Widget\Modal;
use PHPNative\Ui\Widget\StatusBar;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Widget\Table;
use PHPNative\Ui\Widget\TextInput;
use PHPNative\Ui\Window;
$iconFontCandidates = [
__DIR__ . '/../assets/fonts/fa-solid-900.ttf',
__DIR__ . '/../assets/fonts/fontawesome/fa7_freesolid_900.otf',
'/usr/share/fonts/truetype/fontawesome-webfont.ttf',
'/usr/share/fonts/truetype/fontawesome/fa-solid-900.ttf',
'/usr/share/fonts/truetype/fa-solid-900.ttf',
];
$iconFontPath = null;
foreach ($iconFontCandidates as $candidate) {
if (is_file($candidate)) {
$iconFontPath = $candidate;
break;
}
}
if ($iconFontPath !== null) {
IconFontRegistry::setDefaultFontPath($iconFontPath);
} else {
echo "Hinweis: FontAwesome Font nicht gefunden. Icons werden ohne Symbol dargestellt.\n";
}
$app = new Application();
$window = new Window('Windows Application Example', 800, 600);
$currentApiKey = '';
/** @var Label|null $statusLabel */
$statusLabel = null;
// Main container (flex-col: menu, content, status)
$mainContainer = new Container('flex flex-col bg-gray-100');
// Modal dialog setup (hidden by default)
$apiKeyInput = new TextInput('API Key', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black');
$modalDialog = new Container('bg-white border border-gray-300 rounded p-6 flex flex-col w-96 gap-3 shadow-lg');
$modalDialog->addComponent(new Label('API Einstellungen', 'text-xl font-bold text-black'));
$modalDialog->addComponent(new Label(
'Bitte gib deinen API Key ein, um externe Dienste zu verbinden.',
'text-sm text-gray-700',
));
$fieldContainer = new Container('flex flex-col gap-1');
$fieldContainer->addComponent(new Label('API Key', 'text-sm text-gray-600'));
$fieldContainer->addComponent($apiKeyInput);
$modalDialog->addComponent($fieldContainer);
$buttonRow = new Container('flex flex-row justify-end gap-2');
$cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300');
$saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center');
$saveIcon = new Icon(IconName::save, 18, 'text-white');
$saveButton->setIcon($saveIcon);
$buttonRow->addComponent($cancelButton);
$buttonRow->addComponent($saveButton);
$modalDialog->addComponent($buttonRow);
$modal = new Modal($modalDialog);
$modal->setBackdropAlpha(180);
// === 1. MenuBar ===
$menuBar = new MenuBar();
@ -37,8 +96,10 @@ $fileMenu->addItem('Beenden', function () use ($app) {
// Settings Menu
$settingsMenu = new Menu(title: 'Einstellungen');
$settingsMenu->addItem('Optionen', function () {
echo "Optionen clicked\n";
$settingsMenu->addItem('Optionen', function () use ($menuBar, $modal, $apiKeyInput, &$currentApiKey) {
$menuBar->closeAllMenus();
$apiKeyInput->setValue($currentApiKey);
$modal->setVisible(true);
});
$settingsMenu->addItem('Sprache', function () {
echo "Sprache clicked\n";
@ -105,20 +166,47 @@ $mainContainer->addComponent($tabContainer);
// === 3. StatusBar ===
$statusBar = new StatusBar();
$statusBar->addSegment($statusLabel);
$fpsLabel = new Label(
text: 'FPS: --',
style: 'basis-1/8 text-black border-l',
);
$statusBar->addSegment(new Label(
text: 'Zeilen: 10',
style: 'basis-2/8 text-black border-l',
)); // Fixed width
$statusBar->addSegment($fpsLabel);
$statusBar->addSegment(new Label(
text: 'Version 1.0',
style: 'border-l text-black basis-2/8',
));
$mainContainer->addComponent($statusBar);
$cancelButton->setOnClick(function () use ($menuBar, $modal) {
$menuBar->closeAllMenus();
$modal->setVisible(false);
});
$saveButton->setOnClick(function () use (&$currentApiKey, $apiKeyInput, $menuBar, $modal, &$statusLabel) {
$currentApiKey = trim($apiKeyInput->getValue());
if ($statusLabel !== null) {
$masked = strlen($currentApiKey) > 4
? (str_repeat('*', max(0, strlen($currentApiKey) - 4)) . substr($currentApiKey, -4))
: $currentApiKey;
$statusLabel->setText('API-Key gespeichert: ' . $masked);
}
$menuBar->closeAllMenus();
$modal->setVisible(false);
});
$mainContainer->addComponent($modal);
$window->setOnResize(function (Window $window) use (&$statusLabel) {
$statusLabel->setText(
'Fenster: ' . $window->getViewport()->windowWidth . 'x' . $window->getViewport()->windowHeight,
);
});
$window->setOnFpsChange(function (float $fps) use ($fpsLabel) {
$fpsLabel->setText(sprintf('FPS: %d', max(0, (int) round($fps))));
});
// Set root and run
$window->setRoot($mainContainer);
$app->addWindow($window);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -909,6 +909,9 @@ const zend_function_entry sdl3_functions[] = {
PHP_FE(sdl_poll_event, arginfo_sdl_poll_event)
PHP_FE(sdl_wait_event, arginfo_sdl_wait_event)
PHP_FE(sdl_wait_event_timeout, arginfo_sdl_wait_event_timeout)
PHP_FE(sdl_get_mod_state, arginfo_sdl_get_mod_state)
PHP_FE(sdl_get_clipboard_text, arginfo_sdl_get_clipboard_text)
PHP_FE(sdl_set_clipboard_text, arginfo_sdl_set_clipboard_text)
PHP_FE_END
};

View File

@ -55,6 +55,29 @@ void sdl3_events_register_constants(int module_number) {
REGISTER_LONG_CONSTANT("SDLK_RIGHT", SDLK_RIGHT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_UP", SDLK_UP, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_DOWN", SDLK_DOWN, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_HOME", SDLK_HOME, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_END", SDLK_END, CONST_CS | CONST_PERSISTENT);
// Letter keys for shortcuts
REGISTER_LONG_CONSTANT("SDLK_A", SDLK_A, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_C", SDLK_C, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_V", SDLK_V, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SDLK_X", SDLK_X, CONST_CS | CONST_PERSISTENT);
// Key modifiers
REGISTER_LONG_CONSTANT("KMOD_NONE", SDL_KMOD_NONE, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_LSHIFT", SDL_KMOD_LSHIFT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_RSHIFT", SDL_KMOD_RSHIFT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_LCTRL", SDL_KMOD_LCTRL, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_RCTRL", SDL_KMOD_RCTRL, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_LALT", SDL_KMOD_LALT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_RALT", SDL_KMOD_RALT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_LGUI", SDL_KMOD_LGUI, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_RGUI", SDL_KMOD_RGUI, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_CTRL", SDL_KMOD_CTRL, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_SHIFT", SDL_KMOD_SHIFT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_ALT", SDL_KMOD_ALT, CONST_CS | CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("KMOD_GUI", SDL_KMOD_GUI, CONST_CS | CONST_PERSISTENT);
}
PHP_FUNCTION(sdl_poll_event) {
@ -173,3 +196,35 @@ PHP_FUNCTION(sdl_wait_event_timeout) {
add_assoc_long(return_value, "type", event.type);
add_assoc_long(return_value, "timestamp", event.common.timestamp);
}
PHP_FUNCTION(sdl_get_mod_state) {
SDL_Keymod mod = SDL_GetModState();
RETURN_LONG((zend_long)mod);
}
PHP_FUNCTION(sdl_get_clipboard_text) {
char *text = SDL_GetClipboardText();
if (text == NULL) {
RETURN_EMPTY_STRING();
}
RETVAL_STRING(text);
SDL_free(text);
}
PHP_FUNCTION(sdl_set_clipboard_text) {
char *text;
size_t text_len;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "s", &text, &text_len) == FAILURE) {
RETURN_THROWS();
}
if (SDL_SetClipboardText(text) < 0) {
php_error_docref(NULL, E_WARNING, "SDL_SetClipboardText failed: %s", SDL_GetError());
RETURN_FALSE;
}
RETURN_TRUE;
}

View File

@ -8,6 +8,9 @@
PHP_FUNCTION(sdl_poll_event);
PHP_FUNCTION(sdl_wait_event);
PHP_FUNCTION(sdl_wait_event_timeout);
PHP_FUNCTION(sdl_get_mod_state);
PHP_FUNCTION(sdl_get_clipboard_text);
PHP_FUNCTION(sdl_set_clipboard_text);
// Argument Info
ZEND_BEGIN_ARG_INFO_EX(arginfo_sdl_poll_event, 0, 0, 0)
@ -20,6 +23,16 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_sdl_wait_event_timeout, 0, 0, 1)
ZEND_ARG_INFO(0, timeout_ms)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_INFO_EX(arginfo_sdl_get_mod_state, 0, 0, 0)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_INFO_EX(arginfo_sdl_get_clipboard_text, 0, 0, 0)
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_INFO_EX(arginfo_sdl_set_clipboard_text, 0, 0, 1)
ZEND_ARG_INFO(0, text)
ZEND_END_ARG_INFO()
// Funktion zum Registrieren von Event-Konstanten
void sdl3_events_register_constants(int module_number);

View File

@ -81,7 +81,11 @@ class Application
*/
public function run(): void
{
$targetFps = 60;
$targetFrameDuration = $targetFps > 0 ? (1.0 / $targetFps) : 0.0;
while ($this->running && count($this->windows) > 0) {
$frameStart = microtime(true);
// Layout all windows FIRST (sets window references and calculates positions)
foreach ($this->windows as $windowId => $window) {
$window->layout();
@ -178,8 +182,13 @@ class Application
}
}
// Limit frame rate to ~60 FPS
usleep(16666);
if ($targetFrameDuration > 0) {
$elapsed = microtime(true) - $frameStart;
$timeToSleep = $targetFrameDuration - $elapsed;
if ($timeToSleep > 0) {
usleep((int) ($timeToSleep * 1_000_000));
}
}
}
// Cleanup remaining windows

View File

@ -14,4 +14,11 @@ class Style
)
{
}
public function __clone()
{
if (is_object($this->style)) {
$this->style = clone $this->style;
}
}
}

View File

@ -53,4 +53,14 @@ class StyleCollection extends TypedCollection
return $tmp;
}
public function __clone()
{
$cloned = [];
foreach ($this->items() as $style) {
$cloned[] = clone $style;
}
$this->setElements($cloned);
}
}

View File

@ -10,13 +10,24 @@ use PHPNative\Tailwind\Style\Style;
class StyleParser
{
private static array $cache = [];
public static function parse($style): StyleCollection
{
$computed = new StyleCollection();
if($style === null || strlen(trim($style)) === 0) {
if($style === null) {
return $computed;
}
$styles = explode(" ", $style);
$normalized = trim(preg_replace('/\s+/', ' ', $style));
if ($normalized === '') {
return $computed;
}
if (isset(self::$cache[$normalized])) {
return clone self::$cache[$normalized];
}
$styles = explode(" ", $normalized);
foreach($styles as $styleStr) {
$styleStr = trim($styleStr);
@ -39,9 +50,17 @@ class StyleParser
}
$computed->add($s);
}
self::$cache[$normalized] = clone $computed;
return $computed;
}
public static function clearCache(): void
{
self::$cache = [];
}
private static function parseSimpleStyle(string $style): ?Style
{
if($pd = \PHPNative\Tailwind\Parser\Padding::parse($style)) {

View File

@ -454,11 +454,11 @@ abstract class Component
* @param int $keycode SDL keycode
* @return bool True if event was handled
*/
public function handleKeyDown(int $keycode): bool
public function handleKeyDown(int $keycode, int $mod = 0): bool
{
// Default implementation: propagate to children
foreach ($this->children as $child) {
if ($child->handleKeyDown($keycode)) {
if ($child->handleKeyDown($keycode, $mod)) {
return true;
}
}

View File

@ -130,19 +130,17 @@ class Container extends Component
// Layout overlays separately (they position themselves)
foreach ($this->children as $child) {
if ($child->isOverlay()) {
// Give overlay a viewport based on the window size
// The overlay can then adjust its position in its own layout() method
if (!isset($child->getViewport()->windowWidth)) {
$overlayViewport = new Viewport(
x: 0,
y: 0,
width: $this->contentViewport->windowWidth,
height: $this->contentViewport->windowHeight,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$child->setViewport($overlayViewport);
}
// Overlays always get a full-window viewport; they will decide inner layout themselves
$overlayViewport = new Viewport(
x: 0,
y: 0,
width: $this->contentViewport->windowWidth,
height: $this->contentViewport->windowHeight,
windowWidth: $this->contentViewport->windowWidth,
windowHeight: $this->contentViewport->windowHeight,
);
$child->setViewport($overlayViewport);
$child->setContentViewport(clone $overlayViewport);
$child->layout($textRenderer);
}
}
@ -441,6 +439,17 @@ class Container extends Component
// at the global level to ensure they appear on top of everything
}
/**
* Returns the computed content width/height within the container (excludes overlays).
*/
public function getContentSize(): array
{
return [
'width' => $this->contentWidth,
'height' => $this->contentHeight,
];
}
private function renderScrollbars(&$renderer, array $overflow): void
{
$scrollbarColor = [100, 100, 100, 200]; // Gray with some transparency

176
src/Ui/Widget/Modal.php Normal file
View File

@ -0,0 +1,176 @@
<?php
namespace PHPNative\Ui\Widget;
use PHPNative\Framework\TextRenderer;
use PHPNative\Tailwind\Style\Background;
use PHPNative\Ui\Component;
use PHPNative\Ui\Viewport;
class Modal extends Container
{
private Container $content;
private int $backdropAlpha;
public function __construct(
Container $content,
string $style = 'flex items-center justify-center bg-black',
int $backdropAlpha = 160,
) {
parent::__construct(trim($style));
$this->content = $content;
$this->backdropAlpha = max(0, min(255, $backdropAlpha));
$this->setOverlay(true);
$this->setVisible(false);
$this->setZIndex(1000);
$this->setUseTextureCache(false);
$this->addComponent($content);
}
public function layout(null|TextRenderer $textRenderer = null): void
{
$windowViewport = $this->attachedWindow?->getViewport();
if ($windowViewport instanceof Viewport) {
$referenceViewport = $windowViewport;
} else {
$referenceViewport = $this->getParent()?->getViewport() ?? $this->getViewport();
}
$overlayViewport = new Viewport(
x: 0,
y: 0,
width: $referenceViewport->windowWidth,
height: $referenceViewport->windowHeight,
windowWidth: $referenceViewport->windowWidth,
windowHeight: $referenceViewport->windowHeight,
);
// Set viewport for the modal background (full window)
$this->setViewport($overlayViewport);
$this->setContentViewport(clone $overlayViewport);
// Parse styles for the modal background
$styleCollection = \PHPNative\Tailwind\StyleParser::parse($this->style);
$this->computedStyles = $styleCollection->getValidStyles(
\PHPNative\Tailwind\Style\MediaQueryEnum::normal,
\PHPNative\Tailwind\Style\StateEnum::normal,
);
if (isset($this->computedStyles[Background::class])) {
$this->computedStyles[Background::class]->color->alpha = $this->backdropAlpha;
}
// Calculate max size for content (with padding)
$windowWidth = $overlayViewport->width;
$windowHeight = $overlayViewport->height;
$maxWidth = max(240, $windowWidth - 80);
$maxHeight = max(160, $windowHeight - 80);
// Measure content with max constraints
$measurementViewport = new Viewport(
x: 0,
y: 0,
width: $maxWidth,
height: $maxHeight,
windowWidth: $windowWidth,
windowHeight: $windowHeight,
);
$this->content->setViewport($measurementViewport);
$this->content->setContentViewport(clone $measurementViewport);
$this->content->layout($textRenderer);
// Get measured size
$measuredBounds = $this->content->getViewport();
$contentWidth = min($measuredBounds->width, $maxWidth);
$contentHeight = min($measuredBounds->height, $maxHeight);
// Center the content
$targetX = (int) max(0, ($windowWidth - $contentWidth) / 2);
$targetY = (int) max(0, ($windowHeight - $contentHeight) / 2);
// Apply final viewport to content
$finalViewport = new Viewport(
x: $targetX,
y: $targetY,
width: $contentWidth,
height: $contentHeight,
windowWidth: $windowWidth,
windowHeight: $windowHeight,
);
$this->content->setViewport($finalViewport);
$this->content->setContentViewport(clone $finalViewport);
$this->content->layout($textRenderer);
}
/**
* Allow replacing the content container.
*/
public function setContent(Container $content): void
{
$this->removeChild($this->content);
$this->content = $content;
$this->addComponent($content);
$this->markDirty(true);
}
/**
* Expose the content container for further customization.
*/
public function getContent(): Container
{
return $this->content;
}
public function setBackdropAlpha(int $alpha): void
{
$this->backdropAlpha = max(0, min(255, $alpha));
$this->markDirty(false);
}
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
{
if (!$this->isVisible()) {
return false;
}
parent::handleMouseClick($mouseX, $mouseY, $button);
return true;
}
public function handleMouseMove(float $mouseX, float $mouseY): void
{
if (!$this->isVisible()) {
return;
}
parent::handleMouseMove($mouseX, $mouseY);
}
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool
{
if (!$this->isVisible()) {
return false;
}
parent::handleMouseWheel($mouseX, $mouseY, $deltaY);
return true;
}
private function removeChild(Component $component): void
{
foreach ($this->children as $index => $child) {
if ($child === $component) {
if (method_exists($child, 'detachFromWindow')) {
$child->detachFromWindow();
}
$child->setParent(null);
unset($this->children[$index]);
}
}
$this->children = array_values($this->children);
}
}

View File

@ -13,6 +13,8 @@ class TextInput extends Container
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 = '',
@ -98,6 +100,11 @@ class TextInput extends Container
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);
@ -109,15 +116,69 @@ class TextInput extends Container
}
}
public function handleKeyDown(int $keycode): bool
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->cursorPosition > 0) {
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;
@ -129,8 +190,10 @@ class TextInput extends Container
}
return true;
case SDLK_DELETE:
if ($this->cursorPosition < mb_strlen($this->value)) {
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;
@ -141,19 +204,79 @@ class TextInput extends Container
}
return true;
case SDLK_LEFT:
case \SDLK_LEFT:
if ($this->cursorPosition > 0) {
$this->cursorPosition--;
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:
case \SDLK_RIGHT:
if ($this->cursorPosition < mb_strlen($this->value)) {
$this->cursorPosition++;
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_RETURN:
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;
}
@ -174,6 +297,53 @@ class TextInput extends Container
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
@ -204,6 +374,36 @@ class TextInput extends Container
'h' => $this->viewport->height - 4,
]);
// 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);
$selectionX += $size[0];
}
$selectionWidth = 0;
if (!empty($selectedText)) {
$size = $textRenderer->measureText($selectedText);
$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;

View File

@ -18,6 +18,10 @@ class Window
private float $pixelRatio = 2;
private bool $shouldClose = false;
private $onResize = null;
private $onFpsChange = null;
private float $lastFpsUpdate = 0.0;
private int $frameCounter = 0;
private float $currentFps = 0.0;
public function __construct(
private string $title,
private int $width = 800,
@ -78,6 +82,8 @@ class Window
height: $this->height,
);
$this->lastFpsUpdate = microtime(true);
}
public function setRoot(Component $component): self
@ -171,10 +177,11 @@ class Window
case SDL_EVENT_KEY_DOWN:
$keycode = $event['keycode'] ?? 0;
$mod = $event['mod'] ?? 0;
// Propagate key event to root component
if ($this->rootComponent) {
$this->rootComponent->handleKeyDown($keycode);
$this->rootComponent->handleKeyDown($keycode, $mod);
}
break;
@ -362,6 +369,8 @@ class Window
$this->rootComponent->markClean();
$this->updateFps();
sdl_render_present($this->renderer);
}
@ -411,4 +420,31 @@ class Window
{
$this->onResize = $callback;
}
public function setOnFpsChange(?callable $callback): void
{
$this->onFpsChange = $callback;
}
public function getCurrentFps(): float
{
return $this->currentFps;
}
private function updateFps(): void
{
$this->frameCounter++;
$now = microtime(true);
$elapsed = $now - $this->lastFpsUpdate;
if ($elapsed >= 1.0) {
$this->currentFps = $this->frameCounter / $elapsed;
$this->frameCounter = 0;
$this->lastFpsUpdate = $now;
if ($this->onFpsChange !== null) {
($this->onFpsChange)($this->currentFps);
}
}
}
}