Skip to content

Commit c278d2a

Browse files
pvcravenPaul V CravenclaudeCopiloteruvanos
authored
Add UIInteractiveSpriteWidget for clickable sprites in UI (#2849)
* Add UIInteractiveSpriteWidget for clickable sprites in UI tree Combines UIInteractiveWidget and UISpriteWidget via multiple inheritance, giving sprites full UI event dispatch: hover detection, press tracking, click events, and disabled state — without requiring manual hit testing or state management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add interactive sprite widget example and documentation - Introduced UIInteractiveSpriteWidget for clickable sprites in the UI. - Added example code demonstrating sprite interaction with hover and click feedback. - Updated documentation to include the new interactive sprite widget and its usage. * Remove outdated documentation for child layout data and text pool enhancements * Add UIInteractiveSpriteWidget entry to CHANGELOG.md unreleased section Agent-Logs-Url: https://github.com/pythonarcade/arcade/sessions/b3951d4b-3627-459c-953d-64cbc923de43 Co-authored-by: eruvanos <9437863+eruvanos@users.noreply.github.com> * Improve CHANGELOG entry readability with sub-bullets Agent-Logs-Url: https://github.com/pythonarcade/arcade/sessions/b3951d4b-3627-459c-953d-64cbc923de43 Co-authored-by: eruvanos <9437863+eruvanos@users.noreply.github.com> --------- Co-authored-by: Paul V Craven <paul.craven@optimizley.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eruvanos <9437863+eruvanos@users.noreply.github.com>
1 parent d962be8 commit c278d2a

8 files changed

Lines changed: 359 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page.
55

66
## Unreleased
77

8+
### New Features
9+
- GUI: Added `UIInteractiveSpriteWidget` — combines `UIInteractiveWidget` and `UISpriteWidget` to make sprites clickable and hoverable in the UI tree. See [#2847](https://github.com/pythonarcade/arcade/pull/2847)
10+
- Supports `hovered`, `pressed`, and `disabled` states with `on_click` event dispatch.
11+
- Widget size defaults to the sprite's texture dimensions, overridable with explicit `width`/`height`.
12+
813
## 4.0.0.dev4
914

1015
### New Features
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""
2+
Interactive Sprite Widget
3+
4+
Demonstrates UIInteractiveSpriteWidget — making sprites clickable
5+
inside the Arcade UI system with hover and press feedback.
6+
7+
Click a gem to score a point. Gems light up on hover and
8+
shrink when pressed.
9+
10+
If Python and Arcade are installed, this example can be run from the
11+
command line with:
12+
python -m arcade.examples.interactive_sprite_widget
13+
"""
14+
15+
import arcade
16+
from arcade.color import TRANSPARENT_BLACK
17+
from arcade.gui import UIManager, UIInteractiveSpriteWidget
18+
from arcade.gui.property import bind
19+
from arcade.gui.surface import Surface
20+
from arcade.gui.widgets.layout import UIBoxLayout, UIAnchorLayout
21+
22+
WINDOW_WIDTH = 800
23+
WINDOW_HEIGHT = 600
24+
WINDOW_TITLE = "Interactive Sprite Widget Example"
25+
26+
GEM_IMAGES = [
27+
":resources:images/items/gemBlue.png",
28+
":resources:images/items/gemGreen.png",
29+
":resources:images/items/gemRed.png",
30+
":resources:images/items/gemYellow.png",
31+
]
32+
GEM_SCALE = 0.75
33+
PRESS_SHRINK = 0.85
34+
35+
36+
class PressableGemWidget(UIInteractiveSpriteWidget):
37+
"""A gem that visually shrinks when pressed, without affecting layout."""
38+
39+
def do_render(self, surface: Surface):
40+
self.prepare_render(surface)
41+
surface.clear(color=TRANSPARENT_BLACK)
42+
if self._sprite is not None:
43+
if self.pressed:
44+
draw_width = self.width * PRESS_SHRINK
45+
draw_height = self.height * PRESS_SHRINK
46+
offset_x = (self.width - draw_width) / 2
47+
offset_y = (self.height - draw_height) / 2
48+
surface.draw_sprite(offset_x, offset_y, draw_width, draw_height, self._sprite)
49+
else:
50+
surface.draw_sprite(0, 0, self.width, self.height, self._sprite)
51+
52+
53+
class GameView(arcade.View):
54+
55+
def __init__(self):
56+
super().__init__()
57+
self.ui_manager = UIManager()
58+
self.score = 0
59+
self.score_display = None
60+
self.background_color = arcade.color.DARK_BLUE_GRAY
61+
62+
def setup(self):
63+
self.score = 0
64+
self.score_display = arcade.Text(
65+
"Score: 0", 10, WINDOW_HEIGHT - 30,
66+
arcade.color.WHITE, font_size=18,
67+
)
68+
69+
gem_row = UIBoxLayout(vertical=False, space_between=30)
70+
71+
for image_path in GEM_IMAGES:
72+
sprite = arcade.Sprite(image_path, scale=GEM_SCALE)
73+
widget = PressableGemWidget(sprite=sprite)
74+
75+
def make_hover_callback(wgt, spr):
76+
def on_hover_change(instance):
77+
if wgt.hovered:
78+
spr.color = (220, 220, 255)
79+
else:
80+
spr.color = (255, 255, 255)
81+
return on_hover_change
82+
bind(widget, "hovered", make_hover_callback(widget, sprite))
83+
84+
def make_click_callback(path):
85+
def on_click(event):
86+
self.score += 1
87+
self.score_display.text = f"Score: {self.score}"
88+
return on_click
89+
widget.on_click = make_click_callback(image_path)
90+
91+
gem_row.add(widget)
92+
93+
anchor = UIAnchorLayout(width=WINDOW_WIDTH, height=WINDOW_HEIGHT, size_hint=None)
94+
anchor.add(gem_row, anchor_x="center", anchor_y="center")
95+
self.ui_manager.add(anchor)
96+
97+
def on_show_view(self):
98+
self.ui_manager.enable()
99+
100+
def on_hide_view(self):
101+
self.ui_manager.disable()
102+
103+
def on_draw(self):
104+
self.clear()
105+
self.ui_manager.draw()
106+
self.score_display.draw()
107+
108+
109+
def main():
110+
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
111+
view = GameView()
112+
window.show_view(view)
113+
view.setup()
114+
arcade.run()
115+
116+
117+
if __name__ == "__main__":
118+
main()

arcade/gui/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from arcade.gui.view import UIView
3333
from arcade.gui.widgets.dropdown import UIDropdown
3434
from arcade.gui.widgets import UISpriteWidget
35+
from arcade.gui.widgets import UIInteractiveSpriteWidget
3536
from arcade.gui.widgets import UIWidget
3637
from arcade.gui.widgets.buttons import (
3738
UITextureButton,
@@ -63,6 +64,7 @@
6364
"UIFlatButton",
6465
"UIImage",
6566
"UIInteractiveWidget",
67+
"UIInteractiveSpriteWidget",
6668
"UIInputText",
6769
"UILayout",
6870
"UILabel",

arcade/gui/widgets/__init__.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,73 @@ def do_render(self, surface: Surface):
963963
surface.draw_sprite(0, 0, self.width, self.height, self._sprite)
964964

965965

966+
class UIInteractiveSpriteWidget(UIInteractiveWidget, UISpriteWidget):
967+
"""A sprite embedded in the UI tree that responds to click and hover events.
968+
969+
Wraps an existing :py:class:`~arcade.Sprite`, rendering it as a UI
970+
widget with full interactive behavior: hover detection, press
971+
tracking, click events, and optional visual state changes.
972+
973+
Combines :py:class:`UIInteractiveWidget` (mouse/keyboard
974+
interaction, ``hovered`` / ``pressed`` / ``disabled`` states,
975+
``on_click`` event) with :py:class:`UISpriteWidget` (sprite
976+
rendering and animation updates).
977+
978+
Example::
979+
980+
sprite = arcade.Sprite("card.png")
981+
widget = UIInteractiveSpriteWidget(sprite=sprite)
982+
983+
@widget.event("on_click")
984+
def on_click(event):
985+
print(f"Card clicked at {event.x}, {event.y}")
986+
987+
ui_manager.add(widget)
988+
989+
For hover feedback, bind to the ``hovered`` property::
990+
991+
from arcade.gui.property import bind
992+
993+
def on_hover_change(widget):
994+
if widget.hovered:
995+
widget._sprite.color = (220, 220, 255)
996+
else:
997+
widget._sprite.color = (255, 255, 255)
998+
999+
bind(widget, "hovered", on_hover_change)
1000+
1001+
Args:
1002+
sprite: The sprite to display and make interactive.
1003+
width: Widget width in pixels. Defaults to the sprite's
1004+
texture width if not provided.
1005+
height: Widget height in pixels. Defaults to the sprite's
1006+
texture height if not provided.
1007+
**kwargs: Additional :py:class:`UIWidget` keyword arguments
1008+
(``size_hint``, ``size_hint_min``, ``size_hint_max``,
1009+
``interaction_buttons``, etc.).
1010+
"""
1011+
1012+
def __init__(
1013+
self,
1014+
*,
1015+
sprite: Sprite,
1016+
width: float | None = None,
1017+
height: float | None = None,
1018+
**kwargs,
1019+
):
1020+
if width is None:
1021+
width = sprite.texture.width
1022+
if height is None:
1023+
height = sprite.texture.height
1024+
1025+
super().__init__(
1026+
sprite=sprite,
1027+
width=width,
1028+
height=height,
1029+
**kwargs,
1030+
)
1031+
1032+
9661033
class UILayout(UIWidget):
9671034
"""Base class for widgets, which position themselves or their children.
9681035
20 KB
Loading

doc/example_code/index.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,12 @@ Graphical User Interface
643643

644644
:ref:`gui_own_layout`
645645

646+
.. figure:: images/thumbs/interactive_sprite_widget.png
647+
:figwidth: 170px
648+
:target: interactive_sprite_widget.html
649+
650+
:ref:`interactive_sprite_widget`
651+
646652
.. note::
647653

648654
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>`_
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
:orphan:
2+
3+
.. _interactive_sprite_widget:
4+
5+
Interactive Sprite Widget
6+
=========================
7+
8+
.. image:: images/interactive_sprite_widget.png
9+
:width: 600px
10+
:align: center
11+
:alt: Screen shot of interactive sprite widget example
12+
13+
.. literalinclude:: ../../arcade/examples/interactive_sprite_widget.py
14+
:caption: interactive_sprite_widget.py
15+
:linenos:
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
from unittest.mock import Mock
2+
3+
import arcade
4+
from arcade.gui import UIInteractiveSpriteWidget, UIBoxLayout
5+
from arcade.gui.events import UIOnClickEvent, UIMousePressEvent, UIMouseReleaseEvent
6+
from arcade.gui.widgets.layout import UIAnchorLayout
7+
8+
from . import record_ui_events
9+
10+
11+
def _make_widget(**kwargs) -> UIInteractiveSpriteWidget:
12+
sprite = arcade.SpriteSolidColor(100, 100, color=arcade.color.RED)
13+
return UIInteractiveSpriteWidget(sprite=sprite, **kwargs)
14+
15+
16+
def test_click_fires_on_click(ui):
17+
"""Clicking the widget should dispatch an on_click event."""
18+
widget = _make_widget()
19+
ui.add(widget)
20+
21+
with record_ui_events(widget, "on_click") as events:
22+
ui.click(widget.center_x, widget.center_y)
23+
24+
assert len(events) == 1
25+
assert isinstance(events[0], UIOnClickEvent)
26+
assert events[0].source is widget
27+
28+
29+
def test_click_outside_does_not_fire(ui):
30+
"""Clicking outside the widget should not dispatch on_click."""
31+
widget = _make_widget()
32+
ui.add(widget)
33+
34+
with record_ui_events(widget, "on_click") as events:
35+
ui.click(widget.right + 50, widget.top + 50)
36+
37+
assert len(events) == 0
38+
39+
40+
def test_hovered_updates_on_mouse_move(ui):
41+
"""Moving the mouse over the widget should set hovered=True."""
42+
widget = _make_widget()
43+
ui.add(widget)
44+
45+
assert widget.hovered is False
46+
ui.move_mouse(widget.center_x, widget.center_y)
47+
assert widget.hovered is True
48+
ui.move_mouse(widget.right + 50, widget.top + 50)
49+
assert widget.hovered is False
50+
51+
52+
def test_pressed_between_press_and_release(ui):
53+
"""pressed should be True between mouse press and release."""
54+
widget = _make_widget()
55+
ui.add(widget)
56+
57+
assert widget.pressed is False
58+
ui.click_and_hold(widget.center_x, widget.center_y)
59+
assert widget.pressed is True
60+
ui.release(widget.center_x, widget.center_y)
61+
assert widget.pressed is False
62+
63+
64+
def test_disabled_blocks_click(ui):
65+
"""Clicking a disabled widget should not fire on_click."""
66+
widget = _make_widget()
67+
widget.disabled = True
68+
ui.add(widget)
69+
70+
with record_ui_events(widget, "on_click") as events:
71+
ui.click(widget.center_x, widget.center_y)
72+
73+
assert len(events) == 0
74+
75+
76+
def test_widget_rect_matches_sprite_size(window):
77+
"""Widget dimensions should default to sprite texture size."""
78+
sprite = arcade.SpriteSolidColor(150, 75, color=arcade.color.BLUE)
79+
widget = UIInteractiveSpriteWidget(sprite=sprite)
80+
assert widget.width == 150
81+
assert widget.height == 75
82+
83+
84+
def test_explicit_size_overrides_sprite(window):
85+
"""Explicit width/height should override sprite texture size."""
86+
sprite = arcade.SpriteSolidColor(100, 100, color=arcade.color.RED)
87+
widget = UIInteractiveSpriteWidget(sprite=sprite, width=200, height=50)
88+
assert widget.width == 200
89+
assert widget.height == 50
90+
91+
92+
def test_works_in_box_layout(ui):
93+
"""Widget should be usable inside a UIBoxLayout."""
94+
layout = UIBoxLayout(vertical=False, space_between=10, size_hint=None)
95+
widget_a = _make_widget()
96+
widget_b = _make_widget()
97+
layout.add(widget_a)
98+
layout.add(widget_b)
99+
ui.add(layout)
100+
101+
layout.do_layout()
102+
103+
with record_ui_events(widget_a, "on_click") as events:
104+
ui.click(widget_a.center_x, widget_a.center_y)
105+
106+
assert len(events) == 1
107+
assert events[0].source is widget_a
108+
109+
110+
def test_works_in_anchor_layout(ui):
111+
"""Widget should be usable inside a UIAnchorLayout."""
112+
layout = UIAnchorLayout(width=500, height=500, size_hint=None)
113+
widget = _make_widget()
114+
layout.add(widget, anchor_x="center", anchor_y="center")
115+
ui.add(layout)
116+
117+
layout.do_layout()
118+
119+
with record_ui_events(widget, "on_click") as events:
120+
ui.click(widget.center_x, widget.center_y)
121+
122+
assert len(events) == 1
123+
124+
125+
def test_callback_via_event_decorator(ui):
126+
"""Callback registration via @widget.event('on_click') should work."""
127+
widget = _make_widget()
128+
callback = Mock()
129+
widget.push_handlers(on_click=callback)
130+
ui.add(widget)
131+
132+
ui.click(widget.center_x, widget.center_y)
133+
134+
assert callback.called
135+
assert isinstance(callback.call_args[0][0], UIOnClickEvent)
136+
137+
138+
def test_callback_via_assignment(ui):
139+
"""Callback registration via widget.on_click = callback should work."""
140+
widget = _make_widget()
141+
widget.on_click = Mock()
142+
ui.add(widget)
143+
144+
ui.click(widget.center_x, widget.center_y)
145+
146+
assert widget.on_click.called

0 commit comments

Comments
 (0)