From bf087c798d3f9bcbe304491556aba3682c8fd8f0 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Wed, 5 Nov 2025 19:15:36 +0100 Subject: [PATCH] =?UTF-8?q?Icon=20f=C3=BCr=20Buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Framework/IconFontRegistry.php | 77 +++++++++++ src/Ui/Widget/Button.php | 73 +++++++++++ src/Ui/Widget/Icon.php | 200 +++++++++++++++++++++++++++++ 3 files changed, 350 insertions(+) create mode 100644 src/Framework/IconFontRegistry.php create mode 100644 src/Ui/Widget/Icon.php diff --git a/src/Framework/IconFontRegistry.php b/src/Framework/IconFontRegistry.php new file mode 100644 index 0000000..2fd7dbd --- /dev/null +++ b/src/Framework/IconFontRegistry.php @@ -0,0 +1,77 @@ +> */ + private static array $loadedFonts = []; + + /** + * Define the default font that should be used for icons. + * Calling this clears any previously cached fonts for other paths. + */ + public static function setDefaultFontPath(string $fontPath): void + { + if (!is_file($fontPath)) { + throw new \InvalidArgumentException("Icon font not found at path: {$fontPath}"); + } + + if (self::$defaultFontPath !== $fontPath) { + self::clear(); + self::$defaultFontPath = $fontPath; + } + } + + public static function getDefaultFontPath(): null|string + { + return self::$defaultFontPath; + } + + /** + * Resolve (and lazily load) a font handle for the requested size. + * + * @return resource|null + */ + public static function getFont(int $size, null|string $fontPath = null) + { + $path = $fontPath ?? self::$defaultFontPath; + if ($path === null) { + return null; + } + + $key = $path . '|' . $size; + + if (isset(self::$loadedFonts[$key]) && is_resource(self::$loadedFonts[$key])) { + return self::$loadedFonts[$key]; + } + + $font = @ttf_open_font($path, $size); + if ($font === false) { + return null; + } + + self::$loadedFonts[$key] = $font; + + return $font; + } + + /** + * Close all cached font handles. + */ + public static function clear(): void + { + foreach (self::$loadedFonts as $font) { + if (is_resource($font)) { + @ttf_close_font($font); + } + } + self::$loadedFonts = []; + } +} diff --git a/src/Ui/Widget/Button.php b/src/Ui/Widget/Button.php index dfe27c1..3c5edf1 100644 --- a/src/Ui/Widget/Button.php +++ b/src/Ui/Widget/Button.php @@ -5,10 +5,13 @@ namespace PHPNative\Ui\Widget; use PHPNative\Async\AsyncTask; use PHPNative\Async\TaskManager; use PHPNative\Framework\TextRenderer; +use PHPNative\Ui\Component; class Button extends Container { private Label $label; + private null|Icon $leadingIcon = null; + private string $iconPosition = 'left'; private $onClick = null; private $onClickAsync = null; @@ -29,6 +32,33 @@ class Button extends Container $this->onClick = $onClick; } + public function setIcon(null|Icon $icon, string $position = 'left'): void + { + $position = strtolower($position); + if (!in_array($position, ['left', 'right'], true)) { + throw new \InvalidArgumentException('Button icon position must be "left" or "right".'); + } + + if ($this->leadingIcon !== null) { + $this->removeChild($this->leadingIcon); + $this->leadingIcon = null; + } + + if ($icon !== null) { + $this->addComponent($icon); + $this->leadingIcon = $icon; + $this->iconPosition = $position; + + if ($position === 'left') { + $this->moveChildBefore($icon, $this->label); + } else { + $this->moveChildAfter($icon, $this->label); + } + } + + $this->markDirty(true); + } + public function setText(string $text): void { $this->text = $text; @@ -127,4 +157,47 @@ class Button extends Container // Don't call parent::handleMouseClick as that would cause double event propagation return false; } + + 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); + } + + private function moveChildBefore(Component $child, Component $reference): void + { + $childIndex = array_search($child, $this->children, true); + $referenceIndex = array_search($reference, $this->children, true); + + if ($childIndex === false || $referenceIndex === false || $childIndex === $referenceIndex) { + return; + } + + array_splice($this->children, $childIndex, 1); + $referenceIndex = array_search($reference, $this->children, true); + array_splice($this->children, $referenceIndex, 0, [$child]); + } + + private function moveChildAfter(Component $child, Component $reference): void + { + $childIndex = array_search($child, $this->children, true); + $referenceIndex = array_search($reference, $this->children, true); + + if ($childIndex === false || $referenceIndex === false || $childIndex === $referenceIndex + 1) { + return; + } + + array_splice($this->children, $childIndex, 1); + $referenceIndex = array_search($reference, $this->children, true); + array_splice($this->children, $referenceIndex + 1, 0, [$child]); + } } diff --git a/src/Ui/Widget/Icon.php b/src/Ui/Widget/Icon.php new file mode 100644 index 0000000..dc8d4a2 --- /dev/null +++ b/src/Ui/Widget/Icon.php @@ -0,0 +1,200 @@ +glyph = $this->resolveGlyph($icon); + $this->size = max(1, $size); + $this->fontPath = $fontPath; + } + + public function setIcon(string|int|\PHPNative\Tailwind\Data\Icon $icon): void + { + $glyph = $this->resolveGlyph($icon); + if ($glyph === $this->glyph) { + return; + } + + $this->glyph = $glyph; + $this->clearTexture(); + $this->markDirty(true); + } + + public function setSize(int $size): void + { + $size = max(1, $size); + if ($this->size === $size) { + return; + } + + $this->size = $size; + $this->clearTexture(); + $this->markDirty(true); + } + + public function setFontPath(null|string $fontPath): void + { + if ($this->fontPath === $fontPath) { + return; + } + + $this->fontPath = $fontPath; + $this->clearTexture(); + $this->markDirty(true); + } + + public function layout(null|\PHPNative\Framework\TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + $font = IconFontRegistry::getFont($this->size, $this->fontPath); + $width = $this->size; + $height = $this->size; + + if ($font) { + $dimensions = ttf_size_text($font, $this->glyph); + if ($dimensions !== false) { + $width = (int) $dimensions['w']; + $height = (int) $dimensions['h']; + } + } + + if (!isset($this->computedStyles[\PHPNative\Tailwind\Style\Width::class])) { + $this->viewport->width = $width; + $this->contentViewport->width = $width; + } + + if (!isset($this->computedStyles[\PHPNative\Tailwind\Style\Height::class])) { + $this->viewport->height = $height; + $this->contentViewport->height = $height; + } + + if (isset($this->computedStyles[Padding::class]) && ($padding = $this->computedStyles[Padding::class])) { + $this->viewport->width = $width + $padding->left + $padding->right; + $this->viewport->height = $height + $padding->top + $padding->bottom; + $this->contentViewport->width = $width; + $this->contentViewport->height = $height; + } + } + + public function renderContent(&$renderer, null|\PHPNative\Framework\TextRenderer $textRenderer = null): void + { + if (!$this->isVisible()) { + return; + } + + if ($this->texture === null || $this->isDirty()) { + $this->createTexture($renderer); + } + + if ($this->texture !== null) { + sdl_render_texture($renderer, $this->texture, [ + 'x' => (int) $this->contentViewport->x, + 'y' => (int) $this->contentViewport->y, + 'w' => $this->textureWidth, + 'h' => $this->textureHeight, + ]); + } + + parent::renderContent($renderer, $textRenderer); + } + + public function __destruct() + { + $this->clearTexture(); + parent::__destruct(); + } + + private function createTexture($renderer): void + { + $this->clearTexture(); + + $font = IconFontRegistry::getFont($this->size, $this->fontPath); + if (!$font) { + $this->renderDirty = false; + return; + } + + $textStyle = $this->computedStyles[Text::class] ?? new Text(); + $color = $textStyle->color; + $r = max(0, min(255, $color->red >= 0 ? $color->red : 0)); + $g = max(0, min(255, $color->green >= 0 ? $color->green : 0)); + $b = max(0, min(255, $color->blue >= 0 ? $color->blue : 0)); + $a = max(0, min(255, $color->alpha)); + + $surface = ttf_render_text_blended($font, $this->glyph, $r, $g, $b); + if (!$surface) { + $this->renderDirty = false; + return; + } + + $texture = sdl_create_texture_from_surface($renderer, $surface); + if (!$texture) { + $this->renderDirty = false; + return; + } + + $dimensions = ttf_size_text($font, $this->glyph); + if ($dimensions !== false) { + $this->textureWidth = (int) $dimensions['w']; + $this->textureHeight = (int) $dimensions['h']; + } else { + $this->textureWidth = $this->size; + $this->textureHeight = $this->size; + } + + sdl_set_texture_blend_mode($texture, SDL_BLENDMODE_BLEND); + if (function_exists('sdl_set_texture_alpha_mod')) { + sdl_set_texture_alpha_mod($texture, $a); + } + + $this->texture = $texture; + $this->renderDirty = false; + } + + private function clearTexture(): void + { + if ($this->texture !== null) { + sdl_destroy_texture($this->texture); + $this->texture = null; + } + } + + private function resolveGlyph(string|int|\PHPNative\Tailwind\Data\Icon $icon): string + { + if ($icon instanceof \PHPNative\Tailwind\Data\Icon) { + return mb_chr($icon->value, 'UTF-8'); + } + + if (is_int($icon)) { + return mb_chr($icon, 'UTF-8'); + } + + if (mb_strlen($icon, 'UTF-8') === 1) { + return $icon; + } + + throw new \InvalidArgumentException('Icon must be a valid unicode codepoint or a single character string.'); + } +}