Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions arcade/examples/interactive_sprite_widget.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions arcade/gui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +64,7 @@
"UIFlatButton",
"UIImage",
"UIInteractiveWidget",
"UIInteractiveSpriteWidget",
"UIInputText",
"UILayout",
"UILabel",
Expand Down
67 changes: 67 additions & 0 deletions arcade/gui/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions doc/example_code/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/pythonarcade/arcade/tree/development/arcade/examples/gui>`_
Expand Down
15 changes: 15 additions & 0 deletions doc/example_code/interactive_sprite_widget.rst
Original file line number Diff line number Diff line change
@@ -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:
146 changes: 146 additions & 0 deletions tests/unit/gui/test_interactive_sprite_widget.py
Original file line number Diff line number Diff line change
@@ -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
Loading