diff --git a/assets/fonts/fontawesome/fa7_brands_regular_400.otf b/assets/fonts/fontawesome/fa7_brands_regular_400.otf new file mode 100644 index 0000000..dc9a6df Binary files /dev/null and b/assets/fonts/fontawesome/fa7_brands_regular_400.otf differ diff --git a/assets/fonts/fontawesome/fa7_free_regular_400.otf b/assets/fonts/fontawesome/fa7_free_regular_400.otf new file mode 100644 index 0000000..bdaae48 Binary files /dev/null and b/assets/fonts/fontawesome/fa7_free_regular_400.otf differ diff --git a/assets/fonts/fontawesome/fa7_freesolid_900.otf b/assets/fonts/fontawesome/fa7_freesolid_900.otf new file mode 100644 index 0000000..881f5d3 Binary files /dev/null and b/assets/fonts/fontawesome/fa7_freesolid_900.otf differ diff --git a/examples/windows_app_example.php b/examples/windows_app_example.php index 6b2ad6f..82c968b 100644 --- a/examples/windows_app_example.php +++ b/examples/windows_app_example.php @@ -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); diff --git a/php-sdl3/.libs/sdl3.o b/php-sdl3/.libs/sdl3.o index 7e992ee..9144c6d 100644 Binary files a/php-sdl3/.libs/sdl3.o and b/php-sdl3/.libs/sdl3.o differ diff --git a/php-sdl3/.libs/sdl3.so b/php-sdl3/.libs/sdl3.so index 83caf72..e6c55b6 100755 Binary files a/php-sdl3/.libs/sdl3.so and b/php-sdl3/.libs/sdl3.so differ diff --git a/php-sdl3/.libs/sdl3_events.o b/php-sdl3/.libs/sdl3_events.o index a5aa257..d501645 100644 Binary files a/php-sdl3/.libs/sdl3_events.o and b/php-sdl3/.libs/sdl3_events.o differ diff --git a/php-sdl3/modules/sdl3.so b/php-sdl3/modules/sdl3.so index 83caf72..e6c55b6 100755 Binary files a/php-sdl3/modules/sdl3.so and b/php-sdl3/modules/sdl3.so differ diff --git a/php-sdl3/sdl3.c b/php-sdl3/sdl3.c index 9ea202c..1f1d8f5 100644 --- a/php-sdl3/sdl3.c +++ b/php-sdl3/sdl3.c @@ -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 }; diff --git a/php-sdl3/sdl3_events.c b/php-sdl3/sdl3_events.c index 9f632aa..9b623d4 100644 --- a/php-sdl3/sdl3_events.c +++ b/php-sdl3/sdl3_events.c @@ -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; +} diff --git a/php-sdl3/sdl3_events.h b/php-sdl3/sdl3_events.h index 3c94642..0a8a5b5 100644 --- a/php-sdl3/sdl3_events.h +++ b/php-sdl3/sdl3_events.h @@ -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); diff --git a/src/Framework/Application.php b/src/Framework/Application.php index f8ad824..ea32b08 100644 --- a/src/Framework/Application.php +++ b/src/Framework/Application.php @@ -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 diff --git a/src/Tailwind/Model/Style.php b/src/Tailwind/Model/Style.php index fa1cdec..23db85d 100644 --- a/src/Tailwind/Model/Style.php +++ b/src/Tailwind/Model/Style.php @@ -14,4 +14,11 @@ class Style ) { } -} \ No newline at end of file + + public function __clone() + { + if (is_object($this->style)) { + $this->style = clone $this->style; + } + } +} diff --git a/src/Tailwind/Model/StyleCollection.php b/src/Tailwind/Model/StyleCollection.php index 18a3b1e..a0b6db8 100644 --- a/src/Tailwind/Model/StyleCollection.php +++ b/src/Tailwind/Model/StyleCollection.php @@ -53,4 +53,14 @@ class StyleCollection extends TypedCollection return $tmp; } -} \ No newline at end of file + + public function __clone() + { + $cloned = []; + foreach ($this->items() as $style) { + $cloned[] = clone $style; + } + + $this->setElements($cloned); + } +} diff --git a/src/Tailwind/StyleParser.php b/src/Tailwind/StyleParser.php index 60e5cab..653c389 100644 --- a/src/Tailwind/StyleParser.php +++ b/src/Tailwind/StyleParser.php @@ -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)) { diff --git a/src/Ui/Component.php b/src/Ui/Component.php index adf734c..35a66f7 100644 --- a/src/Ui/Component.php +++ b/src/Ui/Component.php @@ -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; } } diff --git a/src/Ui/Widget/Container.php b/src/Ui/Widget/Container.php index 952801d..e356139 100644 --- a/src/Ui/Widget/Container.php +++ b/src/Ui/Widget/Container.php @@ -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 diff --git a/src/Ui/Widget/Modal.php b/src/Ui/Widget/Modal.php new file mode 100644 index 0000000..4af2db7 --- /dev/null +++ b/src/Ui/Widget/Modal.php @@ -0,0 +1,176 @@ +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); + } +} diff --git a/src/Ui/Widget/TextInput.php b/src/Ui/Widget/TextInput.php index 9d7a69d..0c6c515 100644 --- a/src/Ui/Widget/TextInput.php +++ b/src/Ui/Widget/TextInput.php @@ -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; diff --git a/src/Ui/Window.php b/src/Ui/Window.php index 75d706d..0b4d827 100644 --- a/src/Ui/Window.php +++ b/src/Ui/Window.php @@ -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); + } + } + } }