Skip to content

Commit 4d32ede

Browse files
author
Paul V Craven
committed
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.
1 parent 0e35421 commit 4d32ede

7 files changed

Lines changed: 712 additions & 0 deletions

File tree

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()

child_data_access.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Arcade Enhancement: Public Access to Child Layout Data
2+
3+
## Problem
4+
5+
When a widget is added to a layout (e.g., `UIAnchorLayout.add(child, anchor_x="left", align_x=50)`), the layout kwargs (`anchor_x`, `align_x`, `anchor_y`, `align_y`) are stored internally in a `_ChildEntry` named tuple, accessible only through the private `_children` list property.
6+
7+
The public `children` property strips this data:
8+
9+
```python
10+
# arcade/gui/widgets/__init__.py line 529
11+
@property
12+
def children(self) -> list[UIWidget]:
13+
"""Provides all child widgets."""
14+
return [child for child, data in self._children]
15+
```
16+
17+
This means there's **no public way** to read or modify a child's layout parameters after it's been added, short of removing and re-adding it, or accessing the private `_children` attribute.
18+
19+
## Real-World Use Case
20+
21+
A game UI with buttons positioned in an `UIAnchorLayout`. On window resize, the button position (`align_x`, `align_y`) needs to update without rebuilding the entire widget tree:
22+
23+
```python
24+
# Current workaround — accesses private API
25+
entry = self._btn_anchor._children[0] # _ChildEntry(child, data)
26+
entry.data["align_x"] = new_x
27+
entry.data["align_y"] = new_y
28+
```
29+
30+
Without this, the only alternative is to call `remove()` + `add()` each frame, which is wasteful and causes flicker.
31+
32+
## Current Internal Structure
33+
34+
```python
35+
# arcade/gui/widgets/__init__.py
36+
37+
class _ChildEntry(NamedTuple):
38+
child: UIWidget
39+
data: dict
40+
41+
class UIWidget:
42+
_children = ListProperty[_ChildEntry]()
43+
44+
def add(self, child, **kwargs):
45+
child.parent = self
46+
self._children.append(_ChildEntry(child, kwargs))
47+
return child
48+
49+
def remove(self, child):
50+
child.parent = None
51+
for c in self._children:
52+
if c.child == child:
53+
self._children.remove(c)
54+
return c.data # Note: remove() already returns the data
55+
return None
56+
57+
@property
58+
def children(self) -> list[UIWidget]:
59+
return [child for child, data in self._children]
60+
```
61+
62+
Note that `remove()` already returns the layout data dict, which suggests the framework considers this data useful — it just doesn't provide a way to access it without removing the child.
63+
64+
## Proposed API
65+
66+
Add two methods to `UIWidget`:
67+
68+
```python
69+
def get_child_data(self, child: UIWidget) -> dict | None:
70+
"""Get the layout data for a child widget.
71+
72+
Returns the kwargs dict that was passed when the child was added
73+
(e.g., anchor_x, align_x for UIAnchorLayout children).
74+
Modifying the returned dict will affect the child's layout
75+
on the next do_layout() call.
76+
77+
Args:
78+
child: The child widget to look up.
79+
80+
Returns:
81+
The layout data dict, or None if the child is not found.
82+
"""
83+
for entry in self._children:
84+
if entry.child == child:
85+
return entry.data
86+
return None
87+
88+
def get_child_entry(self, index: int) -> tuple[UIWidget, dict]:
89+
"""Get the child widget and its layout data by index.
90+
91+
Args:
92+
index: The index of the child (0-based, in add order).
93+
94+
Returns:
95+
A tuple of (child_widget, layout_data_dict).
96+
97+
Raises:
98+
IndexError: If the index is out of range.
99+
"""
100+
entry = self._children[index]
101+
return entry.child, entry.data
102+
```
103+
104+
### Usage After Change
105+
106+
```python
107+
# Look up by child reference
108+
data = layout.get_child_data(my_button_row)
109+
if data:
110+
data["align_x"] = new_x
111+
data["align_y"] = new_y
112+
113+
# Look up by index
114+
child, data = layout.get_child_entry(0)
115+
data["align_x"] = new_x
116+
```
117+
118+
## Design Decisions
119+
120+
### Why return a mutable dict?
121+
122+
The layout data dict is already mutable internally — `do_layout()` reads from it each time. Returning it directly lets callers modify positioning without remove/re-add cycles, which is the primary use case. This matches the existing pattern where `remove()` returns the same dict.
123+
124+
### Why two methods?
125+
126+
- `get_child_data(child)` — when you have a reference to the child widget (common case)
127+
- `get_child_entry(index)` — when you know the position but not the widget (useful for single-child layouts)
128+
129+
### Why not make `_ChildEntry` public?
130+
131+
`_ChildEntry` is a `NamedTuple` with `child` and `data` fields. Making it public is an option, but the proposed methods are simpler — callers don't need to learn about a new type. The internal storage structure can evolve independently.
132+
133+
### Why not a `children_with_data` property?
134+
135+
A property returning `list[tuple[UIWidget, dict]]` would work but encourages iterating the full list. The lookup methods are more intentional and match the typical use case of updating a specific child.
136+
137+
## Files to Change
138+
139+
| File | Change |
140+
|------|--------|
141+
| `arcade/gui/widgets/__init__.py` | Add `get_child_data()` and `get_child_entry()` to `UIWidget` |
142+
| `tests/unit/gui/` | Tests for both methods |
143+
| `doc/` | Document the new methods, add example |
144+
145+
## Test Plan
146+
147+
1. `get_child_data(child)` returns the correct dict for an added child
148+
2. `get_child_data(unknown_widget)` returns `None`
149+
3. Modifying the returned dict affects layout on next `do_layout()` call
150+
4. `get_child_entry(0)` returns first child and its data
151+
5. `get_child_entry(-1)` returns last child (Python index semantics)
152+
6. `get_child_entry(out_of_range)` raises `IndexError`
153+
7. Works with `UIAnchorLayout`, `UIBoxLayout`, `UIGridLayout`
154+
8. Returned dict matches what was passed to `add()`
155+
9. After `remove()` + `add()`, `get_child_data()` returns the new data
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:

0 commit comments

Comments
 (0)