From 0e354213060b4f7b82021116d1dc1c7afac850b4 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 7 May 2026 13:03:57 -0500 Subject: [PATCH 1/3] Add UIInteractiveSpriteWidget for clickable sprites in UI tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- arcade/gui/__init__.py | 2 + arcade/gui/widgets/__init__.py | 67 ++++++++ .../gui/test_interactive_sprite_widget.py | 146 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 tests/unit/gui/test_interactive_sprite_widget.py 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/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 From 4d32ede287a90b7a5f4edf0a9c1a451c45ae39d9 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 7 May 2026 13:15:23 -0500 Subject: [PATCH 2/3] 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. --- arcade/examples/interactive_sprite_widget.py | 118 +++++++++ child_data_access.md | 155 +++++++++++ .../images/interactive_sprite_widget.png | Bin 0 -> 20523 bytes doc/example_code/index.rst | 6 + .../interactive_sprite_widget.rst | 15 ++ text_pool_spec.md | 171 ++++++++++++ ui_sprite.md | 247 ++++++++++++++++++ 7 files changed, 712 insertions(+) create mode 100644 arcade/examples/interactive_sprite_widget.py create mode 100644 child_data_access.md create mode 100644 doc/example_code/images/interactive_sprite_widget.png create mode 100644 doc/example_code/interactive_sprite_widget.rst create mode 100644 text_pool_spec.md create mode 100644 ui_sprite.md 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/child_data_access.md b/child_data_access.md new file mode 100644 index 0000000000..3e9fe9eeb4 --- /dev/null +++ b/child_data_access.md @@ -0,0 +1,155 @@ +# Arcade Enhancement: Public Access to Child Layout Data + +## Problem + +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. + +The public `children` property strips this data: + +```python +# arcade/gui/widgets/__init__.py line 529 +@property +def children(self) -> list[UIWidget]: + """Provides all child widgets.""" + return [child for child, data in self._children] +``` + +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. + +## Real-World Use Case + +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: + +```python +# Current workaround — accesses private API +entry = self._btn_anchor._children[0] # _ChildEntry(child, data) +entry.data["align_x"] = new_x +entry.data["align_y"] = new_y +``` + +Without this, the only alternative is to call `remove()` + `add()` each frame, which is wasteful and causes flicker. + +## Current Internal Structure + +```python +# arcade/gui/widgets/__init__.py + +class _ChildEntry(NamedTuple): + child: UIWidget + data: dict + +class UIWidget: + _children = ListProperty[_ChildEntry]() + + def add(self, child, **kwargs): + child.parent = self + self._children.append(_ChildEntry(child, kwargs)) + return child + + def remove(self, child): + child.parent = None + for c in self._children: + if c.child == child: + self._children.remove(c) + return c.data # Note: remove() already returns the data + return None + + @property + def children(self) -> list[UIWidget]: + return [child for child, data in self._children] +``` + +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. + +## Proposed API + +Add two methods to `UIWidget`: + +```python +def get_child_data(self, child: UIWidget) -> dict | None: + """Get the layout data for a child widget. + + Returns the kwargs dict that was passed when the child was added + (e.g., anchor_x, align_x for UIAnchorLayout children). + Modifying the returned dict will affect the child's layout + on the next do_layout() call. + + Args: + child: The child widget to look up. + + Returns: + The layout data dict, or None if the child is not found. + """ + for entry in self._children: + if entry.child == child: + return entry.data + return None + +def get_child_entry(self, index: int) -> tuple[UIWidget, dict]: + """Get the child widget and its layout data by index. + + Args: + index: The index of the child (0-based, in add order). + + Returns: + A tuple of (child_widget, layout_data_dict). + + Raises: + IndexError: If the index is out of range. + """ + entry = self._children[index] + return entry.child, entry.data +``` + +### Usage After Change + +```python +# Look up by child reference +data = layout.get_child_data(my_button_row) +if data: + data["align_x"] = new_x + data["align_y"] = new_y + +# Look up by index +child, data = layout.get_child_entry(0) +data["align_x"] = new_x +``` + +## Design Decisions + +### Why return a mutable dict? + +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. + +### Why two methods? + +- `get_child_data(child)` — when you have a reference to the child widget (common case) +- `get_child_entry(index)` — when you know the position but not the widget (useful for single-child layouts) + +### Why not make `_ChildEntry` public? + +`_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. + +### Why not a `children_with_data` property? + +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. + +## Files to Change + +| File | Change | +|------|--------| +| `arcade/gui/widgets/__init__.py` | Add `get_child_data()` and `get_child_entry()` to `UIWidget` | +| `tests/unit/gui/` | Tests for both methods | +| `doc/` | Document the new methods, add example | + +## Test Plan + +1. `get_child_data(child)` returns the correct dict for an added child +2. `get_child_data(unknown_widget)` returns `None` +3. Modifying the returned dict affects layout on next `do_layout()` call +4. `get_child_entry(0)` returns first child and its data +5. `get_child_entry(-1)` returns last child (Python index semantics) +6. `get_child_entry(out_of_range)` raises `IndexError` +7. Works with `UIAnchorLayout`, `UIBoxLayout`, `UIGridLayout` +8. Returned dict matches what was passed to `add()` +9. After `remove()` + `add()`, `get_child_data()` returns the new data 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 0000000000000000000000000000000000000000..041513ced7f115a16ffa8e5973ccd21f626fd4a6 GIT binary patch literal 20523 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

@LGVc-A2r^28v3uxS{oDkQ{u9{+SlKR$boY; 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 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/text_pool_spec.md b/text_pool_spec.md new file mode 100644 index 0000000000..16a33dd7f2 --- /dev/null +++ b/text_pool_spec.md @@ -0,0 +1,171 @@ +# Arcade Enhancement: TextPool (Keyed Text Object Cache) + +## Problem + +Games that draw dynamic text every frame (scores, labels, status messages, player names) face a performance problem: creating `arcade.Text` objects is expensive because each construction resolves font names and creates a `pyglet.text.Label`. The existing `arcade.Text` docs acknowledge this — they recommend reusing Text instances rather than calling `draw_text()`. + +But Arcade provides no built-in way to manage a pool of reusable Text objects. The result is that every game with dynamic text independently reinvents the same caching pattern. + +## Real-World Evidence + +The Worker Placement Game project (a board game using Arcade) has this identical `_text()` method copy-pasted across **5 separate classes**: + +- `client/views/game_view.py` (~2900 lines, 40+ cached text keys) +- `client/ui/resource_bar.py` +- `client/ui/board_renderer.py` (via inline caching) +- `client/ui/tabbed_panel.py` +- `client/ui/game_log.py` + +Each class maintains its own `_text_cache: dict[str, arcade.Text]` and implements the same method: + +```python +def _text(self, key, text, x, y, color, font_size, **kwargs): + if key in self._text_cache: + t = self._text_cache[key] + t.text = text + t.x = x + t.y = y + t.color = color + t.font_size = font_size + return t + t = arcade.Text(text, x, y, color, font_size=font_size, font_name="Tahoma", **kwargs) + self._text_cache[key] = t + return t +``` + +The pattern is always the same: +1. Look up by a string key (e.g., `"score_label"`, `"player_name_3"`) +2. If it exists, update its mutable properties (text, position, color, size) +3. If not, create it and store it +4. Call `.draw()` on the returned object + +## Proposed API + +A `TextPool` class in `arcade.text`: + +```python +class TextPool: + """A keyed cache of reusable Text objects. + + Avoids the cost of creating new Text objects every frame for + dynamic text that changes position, content, or color frequently. + + Example:: + + pool = arcade.TextPool(font_name="Arial") + + def on_draw(self): + pool.draw("score", f"Score: {self.score}", 10, 580, + color=arcade.color.WHITE, font_size=16) + pool.draw("fps", f"FPS: {arcade.get_fps():.0f}", 10, 560, + color=arcade.color.GRAY, font_size=12) + """ + + def __init__(self, font_name="calibri", **defaults): + """Create a pool with shared default properties. + + Args: + font_name: Default font for all text in this pool + **defaults: Default kwargs passed to arcade.Text (e.g., bold, anchor_x) + """ + ... + + def draw(self, key: str, text: str, x: float, y: float, + color=arcade.color.WHITE, font_size: float = 12, + **kwargs) -> arcade.Text: + """Get or create a cached Text object, update it, and draw it. + + First call with a given key creates the Text object. + Subsequent calls update the existing object's properties + and draw it, avoiding reconstruction costs. + + Args: + key: Unique identifier for this text slot + text: The string to display + x: X position + y: Y position + color: Text color + font_size: Font size in points + **kwargs: Additional properties (bold, anchor_x, anchor_y, etc.) + + Returns: + The Text object (useful for measuring content_width, etc.) + """ + ... + + def get(self, key: str, text: str, x: float, y: float, + color=arcade.color.WHITE, font_size: float = 12, + **kwargs) -> arcade.Text: + """Like draw() but returns the Text without drawing it. + + Useful when you need to measure the text or draw it later. + """ + ... + + def clear(self): + """Remove all cached Text objects.""" + ... + + def remove(self, key: str): + """Remove a specific cached Text object.""" + ... +``` + +## Design Decisions + +### Why a separate class, not changes to `arcade.Text`? + +`arcade.Text` is already well-designed for single instances. The pool pattern is about managing *collections* of text objects with keyed lookup. Making Text itself pooling-aware would complicate its API for users who only need one or two labels. + +### Why string keys? + +Games naturally name their text slots: `"score"`, `"player_name_3"`, `"round_label"`. This matches how every real-world implementation of this pattern works. Integer indices would be less readable and error-prone. + +### Why `draw()` and `get()` as separate methods? + +Sometimes you need the Text object to measure `content_width` before drawing (e.g., to position adjacent elements). `get()` returns the object without drawing; `draw()` is the common case that does both. + +### Property update behavior + +When an existing Text is retrieved, only the explicitly passed properties are updated. This allows properties set at creation (like `bold` or `anchor_x`) to persist without being re-specified every frame. + +The `**defaults` in the constructor provide pool-wide defaults (like `font_name`) so individual `draw()` calls don't need to repeat them. + +### Batch integration + +The pool could optionally accept a `pyglet.graphics.Batch` and assign it to all created Text objects, enabling batch rendering: + +```python +pool = arcade.TextPool(font_name="Arial", batch=my_batch) +# Individual draw() calls just update properties; +# my_batch.draw() renders everything at once +``` + +## Implementation Notes + +- The core implementation is ~40 lines — the pattern is simple +- Should live in `arcade/text.py` alongside the existing `Text` class +- The `__init__` defaults should use the same font resolution as `Text` (call `_attempt_font_name_resolution` once, reuse for all pool members) +- Consider using `Text.__enter__`/`__exit__` context manager for efficient multi-property updates on existing objects (calls `pyglet.Label.begin_update()`/`end_update()`) + +## Files to Change + +| File | Change | +|------|--------| +| `arcade/text.py` | Add `TextPool` class | +| `arcade/__init__.py` | Export `TextPool` | +| `doc/` | Add example showing TextPool usage | +| `tests/unit/text/` | Unit tests for pool behavior | + +## Test Plan + +1. Create pool, draw text with key, verify it renders +2. Call draw() again with same key but different text — verify object is reused (same id) +3. Call draw() with different key — verify new object created +4. Verify `get()` returns object without drawing +5. Verify `clear()` removes all cached objects +6. Verify `remove()` removes specific key +7. Verify pool defaults (font_name) apply to all created text +8. Verify per-call kwargs override defaults +9. Verify `content_width` is accessible on returned objects +10. Performance test: 100 text objects, verify pool is faster than creating new Text each frame diff --git a/ui_sprite.md b/ui_sprite.md new file mode 100644 index 0000000000..9151025673 --- /dev/null +++ b/ui_sprite.md @@ -0,0 +1,247 @@ +# Arcade Enhancement: Interactive Sprite Widget + +## Problem + +Arcade has two parallel input systems for handling mouse clicks: + +1. **UI system** (`UIManager` / `UIWidget` / `UIInteractiveWidget`) — full event dispatch with hover, press, click states, focus management, and callback registration +2. **Sprite system** (`Sprite` / `SpriteList` / `get_sprites_at_point()`) — spatial queries that return which sprites are under a point, but no event dispatch + +Games that use sprites as clickable elements (card selection dialogs, board spaces, inventory grids) must bridge these two systems manually. Arcade provides no built-in way to make a Sprite respond to UI input events. + +### What exists today + +`UISpriteWidget` (`arcade/gui/widgets/__init__.py` line 952) wraps a `Sprite` for **display only** — it renders the sprite into the UI tree but inherits from `UIWidget`, not `UIInteractiveWidget`, so it has no click/hover handling. + +`UITextureButton` (`arcade/gui/widgets/buttons.py` line 30) accepts textures and responds to clicks, but it's a full styled widget with text overlay support — it doesn't wrap an existing `Sprite` or integrate with a `SpriteList`. + +Neither helps the common case: "I have sprites on screen, I want to know when the user clicks one." + +## Real-World Evidence + +The Worker Placement Game project has this pattern in multiple places: + +### Pattern 1: Card Selection Dialog (`client/ui/dialogs.py`) + +A dialog displays card sprites and detects which one was clicked: + +```python +class CardSpriteSelectionDialog: + def __init__(self, ...): + self._sprite_list = arcade.SpriteList() + self._card_ids: list[str] = [] + + def _rebuild_sprites(self): + self._sprite_list.clear() + for card in self.cards: + sprite = arcade.Sprite(card_image_path) + sprite.scale = target_width / sprite.texture.width + sprite.position = (cx, cy) + self._sprite_list.append(sprite) + self._card_ids.append(card["id"]) + + def draw(self): + self._sprite_list.draw() + + def on_click(self, x, y) -> bool: + hits = arcade.get_sprites_at_point((x, y), self._sprite_list) + if hits: + idx = self._sprite_list.index(hits[0]) + card_id = self._card_ids[idx] + self.on_select(card_id) + return True + return False +``` + +### Pattern 2: Board Space Hit Testing (`client/ui/board_renderer.py`) + +The board tracks clickable rectangles for sprites and resolves clicks manually: + +```python +class BoardRenderer: + def __init__(self): + self._space_rects: dict[str, tuple] = {} + + def draw(self): + # After drawing each sprite, register its rect + self._space_rects[f"quest_card_{qid}"] = (x, y, w, h) + self._space_rects[f"building_card_{bid}"] = (x, y, w, h) + + def get_space_at(self, x, y) -> str | None: + for space_id, (rx, ry, rw, rh) in self._space_rects.items(): + if rx <= x <= rx + rw and ry <= y <= ry + rh: + return space_id + return None +``` + +### Pattern 3: Game View Click Dispatch (`client/views/game_view.py`) + +The main view manually routes mouse events through a priority chain: + +```python +def on_mouse_press(self, x, y, button, modifiers): + # 1. Check dialog sprites + if self._card_sprite_dialog: + if self._card_sprite_dialog.on_click(x, y): + return + # 2. Check board sprites + space_id = self.board_renderer.get_space_at(x, y) + if space_id: + self._handle_space_click(space_id) +``` + +No hover effects are implemented because managing hover state across sprites requires even more manual tracking (`on_mouse_motion` → `get_sprites_at_point()` → track previous hover → update visual state). + +## Analysis of the Gap + +The gap is specifically between `UISpriteWidget` (display-only) and `UIInteractiveWidget` (full interaction). Making a sprite interactive requires: + +1. **Hit testing** — already solved by `get_sprites_at_point()` but not connected to the UI event system +2. **State tracking** — `UIInteractiveWidget` tracks `hovered`, `pressed`, `disabled` states; sprite users must reimplement this +3. **Event dispatch** — `UIInteractiveWidget` fires `on_click` events with proper button/modifier data; sprite users get raw coordinates only +4. **Visual feedback** — `UIInteractiveWidget` updates rendering based on state (hover texture, pressed texture); sprite users must manage this manually + +## Proposed API + +Extend `UISpriteWidget` to support interaction by adding a new class: + +```python +class UIInteractiveSpriteWidget(UISpriteWidget, UIInteractiveWidget): + """A sprite embedded in the UI tree that responds to click and hover events. + + Wraps an existing Sprite, rendering it as a UI widget with full + interactive behavior: hover detection, press tracking, click events, + and optional visual state changes. + + 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, override do_render or listen for property changes:: + + widget.bind(hovered=lambda prop: update_outline(widget)) + """ + + def __init__( + self, + *, + sprite: Sprite, + width: float | None = None, + height: float | None = None, + **kwargs, + ): + """Create an interactive sprite widget. + + Args: + sprite: The sprite to display and make interactive. + width: Widget width. Defaults to sprite texture width. + height: Widget height. Defaults to sprite texture height. + **kwargs: Additional UIWidget kwargs (size_hint, etc.) + """ + ... +``` + +### Usage + +```python +# Single clickable sprite +card_sprite = arcade.Sprite("quest_card.png") +card_widget = arcade.gui.UIInteractiveSpriteWidget(sprite=card_sprite) + +@card_widget.event("on_click") +def on_click(event): + print("Card selected!") + +# Or with direct callback assignment +card_widget.on_click = lambda event: select_card(card_id) + +# Add to UI tree (gets full event dispatch automatically) +ui_manager.add(card_widget) + +# Place in a layout +layout = arcade.gui.UIBoxLayout(vertical=False, space_between=10) +for card in hand: + sprite = arcade.Sprite(card.image_path) + widget = arcade.gui.UIInteractiveSpriteWidget(sprite=sprite) + widget.on_click = lambda e, c=card: play_card(c) + layout.add(widget) +``` + +### Hover Feedback + +Since `UIInteractiveWidget` already tracks `hovered` and `pressed` states via observable properties, visual feedback comes naturally: + +```python +widget = UIInteractiveSpriteWidget(sprite=my_sprite) + +# React to hover state changes +def on_hover_change(prop): + if widget.hovered: + widget.sprite.color = (220, 220, 255) # Tint on hover + else: + widget.sprite.color = (255, 255, 255) # Normal + +widget.bind(hovered=on_hover_change) +``` + +## Design Decisions + +### Why extend `UISpriteWidget` rather than creating something new? + +`UISpriteWidget` already handles sprite rendering within the UI tree — it calls `sprite.update()`, `sprite.update_animation()`, and draws the sprite to the widget surface. The only thing missing is interaction. Combining it with `UIInteractiveWidget` via multiple inheritance follows the existing pattern: `UITextureButton` does the same thing with `UIInteractiveWidget` + `UIStyledWidget` + `UITextWidget`. + +### Why not add click handling directly to `UISpriteWidget`? + +Keeping `UISpriteWidget` as a non-interactive display widget preserves the existing distinction between display widgets (`UIWidget` subclasses) and interactive widgets (`UIInteractiveWidget` subclasses). Some uses of `UISpriteWidget` are purely decorative — forcing interaction handling on all of them would be unnecessary overhead and a breaking change. + +### Why not make Sprites themselves UI-aware? + +Sprites live in the game world coordinate system; UI widgets live in the UI coordinate system with their own camera. Mixing these concepts in the `Sprite` class itself would conflate two different concerns. The widget wrapper is the right boundary — it translates between the two systems. + +### Why not a clickable SpriteList? + +A `SpriteList` that dispatches click events per-sprite would be useful but is a larger change. It would require integrating `SpriteList` with the `UIManager` event system, which currently only walks the widget tree. The single-sprite widget is a smaller, composable building block — multiple clickable sprites can be placed in a `UIBoxLayout` or `UIGridLayout` to achieve the same effect. + +### What about the `on_click` vs `on_select` naming? + +The existing UI system uses `on_click` for single-widget click events. This is consistent. A hypothetical future `UIClickableSpriteList` could use `on_select(sprite, index)` for multi-sprite selection, but that's a separate concern. + +### MRO (Method Resolution Order) + +With `UIInteractiveSpriteWidget(UISpriteWidget, UIInteractiveWidget)`: +- `UISpriteWidget` inherits from `UIWidget` +- `UIInteractiveWidget` inherits from `UIWidget` +- Python's MRO resolves this cleanly via C3 linearization +- `on_event` from `UIInteractiveWidget` handles input; `do_render` from `UISpriteWidget` handles drawing +- This matches how `UITextureButton(UIInteractiveWidget, UIStyledWidget, UITextWidget)` works + +## Files to Change + +| File | Change | +|------|--------| +| `arcade/gui/widgets/__init__.py` | Add `UIInteractiveSpriteWidget` class after `UISpriteWidget` | +| `arcade/gui/__init__.py` | Export `UIInteractiveSpriteWidget` | +| `tests/unit/gui/` | Tests for click, hover, press states, callback dispatch | +| `doc/` | Document the new widget, add interactive sprite example | + +## Test Plan + +1. Click on the sprite fires `on_click` event with correct coordinates +2. Click outside the sprite does not fire `on_click` +3. `hovered` property updates on mouse enter/leave +4. `pressed` property is `True` between mouse press and release +5. Click with `disabled=True` does not fire `on_click` +6. Sprite animation continues to update (`update_animation` called) +7. Widget works inside `UIBoxLayout` and `UIAnchorLayout` +8. Multiple `UIInteractiveSpriteWidget` instances — only the topmost receives the click +9. Callback registration works via `@widget.event("on_click")` decorator +10. Callback registration works via `widget.on_click = callback` assignment +11. `hovered` state can be bound to visual changes via `widget.bind()` +12. Widget rect matches sprite dimensions when no explicit width/height given From 95c45c2589d2843bc55f06cd717268ae87add0f0 Mon Sep 17 00:00:00 2001 From: Paul V Craven Date: Thu, 7 May 2026 13:16:42 -0500 Subject: [PATCH 3/3] Remove outdated documentation for child layout data and text pool enhancements --- child_data_access.md | 155 --------------------------- text_pool_spec.md | 171 ------------------------------ ui_sprite.md | 247 ------------------------------------------- 3 files changed, 573 deletions(-) delete mode 100644 child_data_access.md delete mode 100644 text_pool_spec.md delete mode 100644 ui_sprite.md diff --git a/child_data_access.md b/child_data_access.md deleted file mode 100644 index 3e9fe9eeb4..0000000000 --- a/child_data_access.md +++ /dev/null @@ -1,155 +0,0 @@ -# Arcade Enhancement: Public Access to Child Layout Data - -## Problem - -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. - -The public `children` property strips this data: - -```python -# arcade/gui/widgets/__init__.py line 529 -@property -def children(self) -> list[UIWidget]: - """Provides all child widgets.""" - return [child for child, data in self._children] -``` - -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. - -## Real-World Use Case - -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: - -```python -# Current workaround — accesses private API -entry = self._btn_anchor._children[0] # _ChildEntry(child, data) -entry.data["align_x"] = new_x -entry.data["align_y"] = new_y -``` - -Without this, the only alternative is to call `remove()` + `add()` each frame, which is wasteful and causes flicker. - -## Current Internal Structure - -```python -# arcade/gui/widgets/__init__.py - -class _ChildEntry(NamedTuple): - child: UIWidget - data: dict - -class UIWidget: - _children = ListProperty[_ChildEntry]() - - def add(self, child, **kwargs): - child.parent = self - self._children.append(_ChildEntry(child, kwargs)) - return child - - def remove(self, child): - child.parent = None - for c in self._children: - if c.child == child: - self._children.remove(c) - return c.data # Note: remove() already returns the data - return None - - @property - def children(self) -> list[UIWidget]: - return [child for child, data in self._children] -``` - -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. - -## Proposed API - -Add two methods to `UIWidget`: - -```python -def get_child_data(self, child: UIWidget) -> dict | None: - """Get the layout data for a child widget. - - Returns the kwargs dict that was passed when the child was added - (e.g., anchor_x, align_x for UIAnchorLayout children). - Modifying the returned dict will affect the child's layout - on the next do_layout() call. - - Args: - child: The child widget to look up. - - Returns: - The layout data dict, or None if the child is not found. - """ - for entry in self._children: - if entry.child == child: - return entry.data - return None - -def get_child_entry(self, index: int) -> tuple[UIWidget, dict]: - """Get the child widget and its layout data by index. - - Args: - index: The index of the child (0-based, in add order). - - Returns: - A tuple of (child_widget, layout_data_dict). - - Raises: - IndexError: If the index is out of range. - """ - entry = self._children[index] - return entry.child, entry.data -``` - -### Usage After Change - -```python -# Look up by child reference -data = layout.get_child_data(my_button_row) -if data: - data["align_x"] = new_x - data["align_y"] = new_y - -# Look up by index -child, data = layout.get_child_entry(0) -data["align_x"] = new_x -``` - -## Design Decisions - -### Why return a mutable dict? - -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. - -### Why two methods? - -- `get_child_data(child)` — when you have a reference to the child widget (common case) -- `get_child_entry(index)` — when you know the position but not the widget (useful for single-child layouts) - -### Why not make `_ChildEntry` public? - -`_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. - -### Why not a `children_with_data` property? - -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. - -## Files to Change - -| File | Change | -|------|--------| -| `arcade/gui/widgets/__init__.py` | Add `get_child_data()` and `get_child_entry()` to `UIWidget` | -| `tests/unit/gui/` | Tests for both methods | -| `doc/` | Document the new methods, add example | - -## Test Plan - -1. `get_child_data(child)` returns the correct dict for an added child -2. `get_child_data(unknown_widget)` returns `None` -3. Modifying the returned dict affects layout on next `do_layout()` call -4. `get_child_entry(0)` returns first child and its data -5. `get_child_entry(-1)` returns last child (Python index semantics) -6. `get_child_entry(out_of_range)` raises `IndexError` -7. Works with `UIAnchorLayout`, `UIBoxLayout`, `UIGridLayout` -8. Returned dict matches what was passed to `add()` -9. After `remove()` + `add()`, `get_child_data()` returns the new data diff --git a/text_pool_spec.md b/text_pool_spec.md deleted file mode 100644 index 16a33dd7f2..0000000000 --- a/text_pool_spec.md +++ /dev/null @@ -1,171 +0,0 @@ -# Arcade Enhancement: TextPool (Keyed Text Object Cache) - -## Problem - -Games that draw dynamic text every frame (scores, labels, status messages, player names) face a performance problem: creating `arcade.Text` objects is expensive because each construction resolves font names and creates a `pyglet.text.Label`. The existing `arcade.Text` docs acknowledge this — they recommend reusing Text instances rather than calling `draw_text()`. - -But Arcade provides no built-in way to manage a pool of reusable Text objects. The result is that every game with dynamic text independently reinvents the same caching pattern. - -## Real-World Evidence - -The Worker Placement Game project (a board game using Arcade) has this identical `_text()` method copy-pasted across **5 separate classes**: - -- `client/views/game_view.py` (~2900 lines, 40+ cached text keys) -- `client/ui/resource_bar.py` -- `client/ui/board_renderer.py` (via inline caching) -- `client/ui/tabbed_panel.py` -- `client/ui/game_log.py` - -Each class maintains its own `_text_cache: dict[str, arcade.Text]` and implements the same method: - -```python -def _text(self, key, text, x, y, color, font_size, **kwargs): - if key in self._text_cache: - t = self._text_cache[key] - t.text = text - t.x = x - t.y = y - t.color = color - t.font_size = font_size - return t - t = arcade.Text(text, x, y, color, font_size=font_size, font_name="Tahoma", **kwargs) - self._text_cache[key] = t - return t -``` - -The pattern is always the same: -1. Look up by a string key (e.g., `"score_label"`, `"player_name_3"`) -2. If it exists, update its mutable properties (text, position, color, size) -3. If not, create it and store it -4. Call `.draw()` on the returned object - -## Proposed API - -A `TextPool` class in `arcade.text`: - -```python -class TextPool: - """A keyed cache of reusable Text objects. - - Avoids the cost of creating new Text objects every frame for - dynamic text that changes position, content, or color frequently. - - Example:: - - pool = arcade.TextPool(font_name="Arial") - - def on_draw(self): - pool.draw("score", f"Score: {self.score}", 10, 580, - color=arcade.color.WHITE, font_size=16) - pool.draw("fps", f"FPS: {arcade.get_fps():.0f}", 10, 560, - color=arcade.color.GRAY, font_size=12) - """ - - def __init__(self, font_name="calibri", **defaults): - """Create a pool with shared default properties. - - Args: - font_name: Default font for all text in this pool - **defaults: Default kwargs passed to arcade.Text (e.g., bold, anchor_x) - """ - ... - - def draw(self, key: str, text: str, x: float, y: float, - color=arcade.color.WHITE, font_size: float = 12, - **kwargs) -> arcade.Text: - """Get or create a cached Text object, update it, and draw it. - - First call with a given key creates the Text object. - Subsequent calls update the existing object's properties - and draw it, avoiding reconstruction costs. - - Args: - key: Unique identifier for this text slot - text: The string to display - x: X position - y: Y position - color: Text color - font_size: Font size in points - **kwargs: Additional properties (bold, anchor_x, anchor_y, etc.) - - Returns: - The Text object (useful for measuring content_width, etc.) - """ - ... - - def get(self, key: str, text: str, x: float, y: float, - color=arcade.color.WHITE, font_size: float = 12, - **kwargs) -> arcade.Text: - """Like draw() but returns the Text without drawing it. - - Useful when you need to measure the text or draw it later. - """ - ... - - def clear(self): - """Remove all cached Text objects.""" - ... - - def remove(self, key: str): - """Remove a specific cached Text object.""" - ... -``` - -## Design Decisions - -### Why a separate class, not changes to `arcade.Text`? - -`arcade.Text` is already well-designed for single instances. The pool pattern is about managing *collections* of text objects with keyed lookup. Making Text itself pooling-aware would complicate its API for users who only need one or two labels. - -### Why string keys? - -Games naturally name their text slots: `"score"`, `"player_name_3"`, `"round_label"`. This matches how every real-world implementation of this pattern works. Integer indices would be less readable and error-prone. - -### Why `draw()` and `get()` as separate methods? - -Sometimes you need the Text object to measure `content_width` before drawing (e.g., to position adjacent elements). `get()` returns the object without drawing; `draw()` is the common case that does both. - -### Property update behavior - -When an existing Text is retrieved, only the explicitly passed properties are updated. This allows properties set at creation (like `bold` or `anchor_x`) to persist without being re-specified every frame. - -The `**defaults` in the constructor provide pool-wide defaults (like `font_name`) so individual `draw()` calls don't need to repeat them. - -### Batch integration - -The pool could optionally accept a `pyglet.graphics.Batch` and assign it to all created Text objects, enabling batch rendering: - -```python -pool = arcade.TextPool(font_name="Arial", batch=my_batch) -# Individual draw() calls just update properties; -# my_batch.draw() renders everything at once -``` - -## Implementation Notes - -- The core implementation is ~40 lines — the pattern is simple -- Should live in `arcade/text.py` alongside the existing `Text` class -- The `__init__` defaults should use the same font resolution as `Text` (call `_attempt_font_name_resolution` once, reuse for all pool members) -- Consider using `Text.__enter__`/`__exit__` context manager for efficient multi-property updates on existing objects (calls `pyglet.Label.begin_update()`/`end_update()`) - -## Files to Change - -| File | Change | -|------|--------| -| `arcade/text.py` | Add `TextPool` class | -| `arcade/__init__.py` | Export `TextPool` | -| `doc/` | Add example showing TextPool usage | -| `tests/unit/text/` | Unit tests for pool behavior | - -## Test Plan - -1. Create pool, draw text with key, verify it renders -2. Call draw() again with same key but different text — verify object is reused (same id) -3. Call draw() with different key — verify new object created -4. Verify `get()` returns object without drawing -5. Verify `clear()` removes all cached objects -6. Verify `remove()` removes specific key -7. Verify pool defaults (font_name) apply to all created text -8. Verify per-call kwargs override defaults -9. Verify `content_width` is accessible on returned objects -10. Performance test: 100 text objects, verify pool is faster than creating new Text each frame diff --git a/ui_sprite.md b/ui_sprite.md deleted file mode 100644 index 9151025673..0000000000 --- a/ui_sprite.md +++ /dev/null @@ -1,247 +0,0 @@ -# Arcade Enhancement: Interactive Sprite Widget - -## Problem - -Arcade has two parallel input systems for handling mouse clicks: - -1. **UI system** (`UIManager` / `UIWidget` / `UIInteractiveWidget`) — full event dispatch with hover, press, click states, focus management, and callback registration -2. **Sprite system** (`Sprite` / `SpriteList` / `get_sprites_at_point()`) — spatial queries that return which sprites are under a point, but no event dispatch - -Games that use sprites as clickable elements (card selection dialogs, board spaces, inventory grids) must bridge these two systems manually. Arcade provides no built-in way to make a Sprite respond to UI input events. - -### What exists today - -`UISpriteWidget` (`arcade/gui/widgets/__init__.py` line 952) wraps a `Sprite` for **display only** — it renders the sprite into the UI tree but inherits from `UIWidget`, not `UIInteractiveWidget`, so it has no click/hover handling. - -`UITextureButton` (`arcade/gui/widgets/buttons.py` line 30) accepts textures and responds to clicks, but it's a full styled widget with text overlay support — it doesn't wrap an existing `Sprite` or integrate with a `SpriteList`. - -Neither helps the common case: "I have sprites on screen, I want to know when the user clicks one." - -## Real-World Evidence - -The Worker Placement Game project has this pattern in multiple places: - -### Pattern 1: Card Selection Dialog (`client/ui/dialogs.py`) - -A dialog displays card sprites and detects which one was clicked: - -```python -class CardSpriteSelectionDialog: - def __init__(self, ...): - self._sprite_list = arcade.SpriteList() - self._card_ids: list[str] = [] - - def _rebuild_sprites(self): - self._sprite_list.clear() - for card in self.cards: - sprite = arcade.Sprite(card_image_path) - sprite.scale = target_width / sprite.texture.width - sprite.position = (cx, cy) - self._sprite_list.append(sprite) - self._card_ids.append(card["id"]) - - def draw(self): - self._sprite_list.draw() - - def on_click(self, x, y) -> bool: - hits = arcade.get_sprites_at_point((x, y), self._sprite_list) - if hits: - idx = self._sprite_list.index(hits[0]) - card_id = self._card_ids[idx] - self.on_select(card_id) - return True - return False -``` - -### Pattern 2: Board Space Hit Testing (`client/ui/board_renderer.py`) - -The board tracks clickable rectangles for sprites and resolves clicks manually: - -```python -class BoardRenderer: - def __init__(self): - self._space_rects: dict[str, tuple] = {} - - def draw(self): - # After drawing each sprite, register its rect - self._space_rects[f"quest_card_{qid}"] = (x, y, w, h) - self._space_rects[f"building_card_{bid}"] = (x, y, w, h) - - def get_space_at(self, x, y) -> str | None: - for space_id, (rx, ry, rw, rh) in self._space_rects.items(): - if rx <= x <= rx + rw and ry <= y <= ry + rh: - return space_id - return None -``` - -### Pattern 3: Game View Click Dispatch (`client/views/game_view.py`) - -The main view manually routes mouse events through a priority chain: - -```python -def on_mouse_press(self, x, y, button, modifiers): - # 1. Check dialog sprites - if self._card_sprite_dialog: - if self._card_sprite_dialog.on_click(x, y): - return - # 2. Check board sprites - space_id = self.board_renderer.get_space_at(x, y) - if space_id: - self._handle_space_click(space_id) -``` - -No hover effects are implemented because managing hover state across sprites requires even more manual tracking (`on_mouse_motion` → `get_sprites_at_point()` → track previous hover → update visual state). - -## Analysis of the Gap - -The gap is specifically between `UISpriteWidget` (display-only) and `UIInteractiveWidget` (full interaction). Making a sprite interactive requires: - -1. **Hit testing** — already solved by `get_sprites_at_point()` but not connected to the UI event system -2. **State tracking** — `UIInteractiveWidget` tracks `hovered`, `pressed`, `disabled` states; sprite users must reimplement this -3. **Event dispatch** — `UIInteractiveWidget` fires `on_click` events with proper button/modifier data; sprite users get raw coordinates only -4. **Visual feedback** — `UIInteractiveWidget` updates rendering based on state (hover texture, pressed texture); sprite users must manage this manually - -## Proposed API - -Extend `UISpriteWidget` to support interaction by adding a new class: - -```python -class UIInteractiveSpriteWidget(UISpriteWidget, UIInteractiveWidget): - """A sprite embedded in the UI tree that responds to click and hover events. - - Wraps an existing Sprite, rendering it as a UI widget with full - interactive behavior: hover detection, press tracking, click events, - and optional visual state changes. - - 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, override do_render or listen for property changes:: - - widget.bind(hovered=lambda prop: update_outline(widget)) - """ - - def __init__( - self, - *, - sprite: Sprite, - width: float | None = None, - height: float | None = None, - **kwargs, - ): - """Create an interactive sprite widget. - - Args: - sprite: The sprite to display and make interactive. - width: Widget width. Defaults to sprite texture width. - height: Widget height. Defaults to sprite texture height. - **kwargs: Additional UIWidget kwargs (size_hint, etc.) - """ - ... -``` - -### Usage - -```python -# Single clickable sprite -card_sprite = arcade.Sprite("quest_card.png") -card_widget = arcade.gui.UIInteractiveSpriteWidget(sprite=card_sprite) - -@card_widget.event("on_click") -def on_click(event): - print("Card selected!") - -# Or with direct callback assignment -card_widget.on_click = lambda event: select_card(card_id) - -# Add to UI tree (gets full event dispatch automatically) -ui_manager.add(card_widget) - -# Place in a layout -layout = arcade.gui.UIBoxLayout(vertical=False, space_between=10) -for card in hand: - sprite = arcade.Sprite(card.image_path) - widget = arcade.gui.UIInteractiveSpriteWidget(sprite=sprite) - widget.on_click = lambda e, c=card: play_card(c) - layout.add(widget) -``` - -### Hover Feedback - -Since `UIInteractiveWidget` already tracks `hovered` and `pressed` states via observable properties, visual feedback comes naturally: - -```python -widget = UIInteractiveSpriteWidget(sprite=my_sprite) - -# React to hover state changes -def on_hover_change(prop): - if widget.hovered: - widget.sprite.color = (220, 220, 255) # Tint on hover - else: - widget.sprite.color = (255, 255, 255) # Normal - -widget.bind(hovered=on_hover_change) -``` - -## Design Decisions - -### Why extend `UISpriteWidget` rather than creating something new? - -`UISpriteWidget` already handles sprite rendering within the UI tree — it calls `sprite.update()`, `sprite.update_animation()`, and draws the sprite to the widget surface. The only thing missing is interaction. Combining it with `UIInteractiveWidget` via multiple inheritance follows the existing pattern: `UITextureButton` does the same thing with `UIInteractiveWidget` + `UIStyledWidget` + `UITextWidget`. - -### Why not add click handling directly to `UISpriteWidget`? - -Keeping `UISpriteWidget` as a non-interactive display widget preserves the existing distinction between display widgets (`UIWidget` subclasses) and interactive widgets (`UIInteractiveWidget` subclasses). Some uses of `UISpriteWidget` are purely decorative — forcing interaction handling on all of them would be unnecessary overhead and a breaking change. - -### Why not make Sprites themselves UI-aware? - -Sprites live in the game world coordinate system; UI widgets live in the UI coordinate system with their own camera. Mixing these concepts in the `Sprite` class itself would conflate two different concerns. The widget wrapper is the right boundary — it translates between the two systems. - -### Why not a clickable SpriteList? - -A `SpriteList` that dispatches click events per-sprite would be useful but is a larger change. It would require integrating `SpriteList` with the `UIManager` event system, which currently only walks the widget tree. The single-sprite widget is a smaller, composable building block — multiple clickable sprites can be placed in a `UIBoxLayout` or `UIGridLayout` to achieve the same effect. - -### What about the `on_click` vs `on_select` naming? - -The existing UI system uses `on_click` for single-widget click events. This is consistent. A hypothetical future `UIClickableSpriteList` could use `on_select(sprite, index)` for multi-sprite selection, but that's a separate concern. - -### MRO (Method Resolution Order) - -With `UIInteractiveSpriteWidget(UISpriteWidget, UIInteractiveWidget)`: -- `UISpriteWidget` inherits from `UIWidget` -- `UIInteractiveWidget` inherits from `UIWidget` -- Python's MRO resolves this cleanly via C3 linearization -- `on_event` from `UIInteractiveWidget` handles input; `do_render` from `UISpriteWidget` handles drawing -- This matches how `UITextureButton(UIInteractiveWidget, UIStyledWidget, UITextWidget)` works - -## Files to Change - -| File | Change | -|------|--------| -| `arcade/gui/widgets/__init__.py` | Add `UIInteractiveSpriteWidget` class after `UISpriteWidget` | -| `arcade/gui/__init__.py` | Export `UIInteractiveSpriteWidget` | -| `tests/unit/gui/` | Tests for click, hover, press states, callback dispatch | -| `doc/` | Document the new widget, add interactive sprite example | - -## Test Plan - -1. Click on the sprite fires `on_click` event with correct coordinates -2. Click outside the sprite does not fire `on_click` -3. `hovered` property updates on mouse enter/leave -4. `pressed` property is `True` between mouse press and release -5. Click with `disabled=True` does not fire `on_click` -6. Sprite animation continues to update (`update_animation` called) -7. Widget works inside `UIBoxLayout` and `UIAnchorLayout` -8. Multiple `UIInteractiveSpriteWidget` instances — only the topmost receives the click -9. Callback registration works via `@widget.event("on_click")` decorator -10. Callback registration works via `widget.on_click = callback` assignment -11. `hovered` state can be bound to visual changes via `widget.bind()` -12. Widget rect matches sprite dimensions when no explicit width/height given