diff --git a/arcade/examples/interactive_sprite_widget.py b/arcade/examples/interactive_sprite_widget.py new file mode 100644 index 0000000000..6407d3f0e6 --- /dev/null +++ b/arcade/examples/interactive_sprite_widget.py @@ -0,0 +1,118 @@ +""" +Interactive Sprite Widget + +Demonstrates UIInteractiveSpriteWidget — making sprites clickable +inside the Arcade UI system with hover and press feedback. + +Click a gem to score a point. Gems light up on hover and +shrink when pressed. + +If Python and Arcade are installed, this example can be run from the +command line with: +python -m arcade.examples.interactive_sprite_widget +""" + +import arcade +from arcade.color import TRANSPARENT_BLACK +from arcade.gui import UIManager, UIInteractiveSpriteWidget +from arcade.gui.property import bind +from arcade.gui.surface import Surface +from arcade.gui.widgets.layout import UIBoxLayout, UIAnchorLayout + +WINDOW_WIDTH = 800 +WINDOW_HEIGHT = 600 +WINDOW_TITLE = "Interactive Sprite Widget Example" + +GEM_IMAGES = [ + ":resources:images/items/gemBlue.png", + ":resources:images/items/gemGreen.png", + ":resources:images/items/gemRed.png", + ":resources:images/items/gemYellow.png", +] +GEM_SCALE = 0.75 +PRESS_SHRINK = 0.85 + + +class PressableGemWidget(UIInteractiveSpriteWidget): + """A gem that visually shrinks when pressed, without affecting layout.""" + + def do_render(self, surface: Surface): + self.prepare_render(surface) + surface.clear(color=TRANSPARENT_BLACK) + if self._sprite is not None: + if self.pressed: + draw_width = self.width * PRESS_SHRINK + draw_height = self.height * PRESS_SHRINK + offset_x = (self.width - draw_width) / 2 + offset_y = (self.height - draw_height) / 2 + surface.draw_sprite(offset_x, offset_y, draw_width, draw_height, self._sprite) + else: + surface.draw_sprite(0, 0, self.width, self.height, self._sprite) + + +class GameView(arcade.View): + + def __init__(self): + super().__init__() + self.ui_manager = UIManager() + self.score = 0 + self.score_display = None + self.background_color = arcade.color.DARK_BLUE_GRAY + + def setup(self): + self.score = 0 + self.score_display = arcade.Text( + "Score: 0", 10, WINDOW_HEIGHT - 30, + arcade.color.WHITE, font_size=18, + ) + + gem_row = UIBoxLayout(vertical=False, space_between=30) + + for image_path in GEM_IMAGES: + sprite = arcade.Sprite(image_path, scale=GEM_SCALE) + widget = PressableGemWidget(sprite=sprite) + + def make_hover_callback(wgt, spr): + def on_hover_change(instance): + if wgt.hovered: + spr.color = (220, 220, 255) + else: + spr.color = (255, 255, 255) + return on_hover_change + bind(widget, "hovered", make_hover_callback(widget, sprite)) + + def make_click_callback(path): + def on_click(event): + self.score += 1 + self.score_display.text = f"Score: {self.score}" + return on_click + widget.on_click = make_click_callback(image_path) + + gem_row.add(widget) + + anchor = UIAnchorLayout(width=WINDOW_WIDTH, height=WINDOW_HEIGHT, size_hint=None) + anchor.add(gem_row, anchor_x="center", anchor_y="center") + self.ui_manager.add(anchor) + + def on_show_view(self): + self.ui_manager.enable() + + def on_hide_view(self): + self.ui_manager.disable() + + def on_draw(self): + self.clear() + self.ui_manager.draw() + self.score_display.draw() + + +def main(): + window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE) + view = GameView() + window.show_view(view) + view.setup() + arcade.run() + + +if __name__ == "__main__": + main() diff --git a/arcade/gui/__init__.py b/arcade/gui/__init__.py index 693974f52f..3a6d702e71 100644 --- a/arcade/gui/__init__.py +++ b/arcade/gui/__init__.py @@ -32,6 +32,7 @@ from arcade.gui.view import UIView from arcade.gui.widgets.dropdown import UIDropdown from arcade.gui.widgets import UISpriteWidget +from arcade.gui.widgets import UIInteractiveSpriteWidget from arcade.gui.widgets import UIWidget from arcade.gui.widgets.buttons import ( UITextureButton, @@ -63,6 +64,7 @@ "UIFlatButton", "UIImage", "UIInteractiveWidget", + "UIInteractiveSpriteWidget", "UIInputText", "UILayout", "UILabel", diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 7f1c9a8034..8d74828f6d 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -963,6 +963,73 @@ def do_render(self, surface: Surface): surface.draw_sprite(0, 0, self.width, self.height, self._sprite) +class UIInteractiveSpriteWidget(UIInteractiveWidget, UISpriteWidget): + """A sprite embedded in the UI tree that responds to click and hover events. + + Wraps an existing :py:class:`~arcade.Sprite`, rendering it as a UI + widget with full interactive behavior: hover detection, press + tracking, click events, and optional visual state changes. + + Combines :py:class:`UIInteractiveWidget` (mouse/keyboard + interaction, ``hovered`` / ``pressed`` / ``disabled`` states, + ``on_click`` event) with :py:class:`UISpriteWidget` (sprite + rendering and animation updates). + + Example:: + + sprite = arcade.Sprite("card.png") + widget = UIInteractiveSpriteWidget(sprite=sprite) + + @widget.event("on_click") + def on_click(event): + print(f"Card clicked at {event.x}, {event.y}") + + ui_manager.add(widget) + + For hover feedback, bind to the ``hovered`` property:: + + from arcade.gui.property import bind + + def on_hover_change(widget): + if widget.hovered: + widget._sprite.color = (220, 220, 255) + else: + widget._sprite.color = (255, 255, 255) + + bind(widget, "hovered", on_hover_change) + + Args: + sprite: The sprite to display and make interactive. + width: Widget width in pixels. Defaults to the sprite's + texture width if not provided. + height: Widget height in pixels. Defaults to the sprite's + texture height if not provided. + **kwargs: Additional :py:class:`UIWidget` keyword arguments + (``size_hint``, ``size_hint_min``, ``size_hint_max``, + ``interaction_buttons``, etc.). + """ + + def __init__( + self, + *, + sprite: Sprite, + width: float | None = None, + height: float | None = None, + **kwargs, + ): + if width is None: + width = sprite.texture.width + if height is None: + height = sprite.texture.height + + super().__init__( + sprite=sprite, + width=width, + height=height, + **kwargs, + ) + + class UILayout(UIWidget): """Base class for widgets, which position themselves or their children. diff --git a/doc/example_code/images/interactive_sprite_widget.png b/doc/example_code/images/interactive_sprite_widget.png new file mode 100644 index 0000000000..041513ced7 Binary files /dev/null and b/doc/example_code/images/interactive_sprite_widget.png differ diff --git a/doc/example_code/index.rst b/doc/example_code/index.rst index ccb6508da7..70a54923b8 100644 --- a/doc/example_code/index.rst +++ b/doc/example_code/index.rst @@ -643,6 +643,12 @@ Graphical User Interface :ref:`gui_own_layout` +.. figure:: images/thumbs/interactive_sprite_widget.png + :figwidth: 170px + :target: interactive_sprite_widget.html + + :ref:`interactive_sprite_widget` + .. note:: Not all existing examples made it into this section. You can find more under `Arcade GUI Examples `_ diff --git a/doc/example_code/interactive_sprite_widget.rst b/doc/example_code/interactive_sprite_widget.rst new file mode 100644 index 0000000000..d6e5ada961 --- /dev/null +++ b/doc/example_code/interactive_sprite_widget.rst @@ -0,0 +1,15 @@ +:orphan: + +.. _interactive_sprite_widget: + +Interactive Sprite Widget +========================= + +.. image:: images/interactive_sprite_widget.png + :width: 600px + :align: center + :alt: Screen shot of interactive sprite widget example + +.. literalinclude:: ../../arcade/examples/interactive_sprite_widget.py + :caption: interactive_sprite_widget.py + :linenos: diff --git a/tests/unit/gui/test_interactive_sprite_widget.py b/tests/unit/gui/test_interactive_sprite_widget.py new file mode 100644 index 0000000000..a8edd9853e --- /dev/null +++ b/tests/unit/gui/test_interactive_sprite_widget.py @@ -0,0 +1,146 @@ +from unittest.mock import Mock + +import arcade +from arcade.gui import UIInteractiveSpriteWidget, UIBoxLayout +from arcade.gui.events import UIOnClickEvent, UIMousePressEvent, UIMouseReleaseEvent +from arcade.gui.widgets.layout import UIAnchorLayout + +from . import record_ui_events + + +def _make_widget(**kwargs) -> UIInteractiveSpriteWidget: + sprite = arcade.SpriteSolidColor(100, 100, color=arcade.color.RED) + return UIInteractiveSpriteWidget(sprite=sprite, **kwargs) + + +def test_click_fires_on_click(ui): + """Clicking the widget should dispatch an on_click event.""" + widget = _make_widget() + ui.add(widget) + + with record_ui_events(widget, "on_click") as events: + ui.click(widget.center_x, widget.center_y) + + assert len(events) == 1 + assert isinstance(events[0], UIOnClickEvent) + assert events[0].source is widget + + +def test_click_outside_does_not_fire(ui): + """Clicking outside the widget should not dispatch on_click.""" + widget = _make_widget() + ui.add(widget) + + with record_ui_events(widget, "on_click") as events: + ui.click(widget.right + 50, widget.top + 50) + + assert len(events) == 0 + + +def test_hovered_updates_on_mouse_move(ui): + """Moving the mouse over the widget should set hovered=True.""" + widget = _make_widget() + ui.add(widget) + + assert widget.hovered is False + ui.move_mouse(widget.center_x, widget.center_y) + assert widget.hovered is True + ui.move_mouse(widget.right + 50, widget.top + 50) + assert widget.hovered is False + + +def test_pressed_between_press_and_release(ui): + """pressed should be True between mouse press and release.""" + widget = _make_widget() + ui.add(widget) + + assert widget.pressed is False + ui.click_and_hold(widget.center_x, widget.center_y) + assert widget.pressed is True + ui.release(widget.center_x, widget.center_y) + assert widget.pressed is False + + +def test_disabled_blocks_click(ui): + """Clicking a disabled widget should not fire on_click.""" + widget = _make_widget() + widget.disabled = True + ui.add(widget) + + with record_ui_events(widget, "on_click") as events: + ui.click(widget.center_x, widget.center_y) + + assert len(events) == 0 + + +def test_widget_rect_matches_sprite_size(window): + """Widget dimensions should default to sprite texture size.""" + sprite = arcade.SpriteSolidColor(150, 75, color=arcade.color.BLUE) + widget = UIInteractiveSpriteWidget(sprite=sprite) + assert widget.width == 150 + assert widget.height == 75 + + +def test_explicit_size_overrides_sprite(window): + """Explicit width/height should override sprite texture size.""" + sprite = arcade.SpriteSolidColor(100, 100, color=arcade.color.RED) + widget = UIInteractiveSpriteWidget(sprite=sprite, width=200, height=50) + assert widget.width == 200 + assert widget.height == 50 + + +def test_works_in_box_layout(ui): + """Widget should be usable inside a UIBoxLayout.""" + layout = UIBoxLayout(vertical=False, space_between=10, size_hint=None) + widget_a = _make_widget() + widget_b = _make_widget() + layout.add(widget_a) + layout.add(widget_b) + ui.add(layout) + + layout.do_layout() + + with record_ui_events(widget_a, "on_click") as events: + ui.click(widget_a.center_x, widget_a.center_y) + + assert len(events) == 1 + assert events[0].source is widget_a + + +def test_works_in_anchor_layout(ui): + """Widget should be usable inside a UIAnchorLayout.""" + layout = UIAnchorLayout(width=500, height=500, size_hint=None) + widget = _make_widget() + layout.add(widget, anchor_x="center", anchor_y="center") + ui.add(layout) + + layout.do_layout() + + with record_ui_events(widget, "on_click") as events: + ui.click(widget.center_x, widget.center_y) + + assert len(events) == 1 + + +def test_callback_via_event_decorator(ui): + """Callback registration via @widget.event('on_click') should work.""" + widget = _make_widget() + callback = Mock() + widget.push_handlers(on_click=callback) + ui.add(widget) + + ui.click(widget.center_x, widget.center_y) + + assert callback.called + assert isinstance(callback.call_args[0][0], UIOnClickEvent) + + +def test_callback_via_assignment(ui): + """Callback registration via widget.on_click = callback should work.""" + widget = _make_widget() + widget.on_click = Mock() + ui.add(widget) + + ui.click(widget.center_x, widget.center_y) + + assert widget.on_click.called