from dataclasses import replace
from math import cos, radians, sin
from typing import Any, TypeVar
from pyglet.event import EVENT_UNHANDLED
from arcade.anim import Easing
from arcade.anim.easing import EasingFunction
from arcade.color import TRANSPARENT_BLACK, WHITE
from arcade.gui.events import (
UIEvent,
UIMouseDragEvent,
UIMouseEvent,
UIMouseMovementEvent,
UIOnUpdateEvent,
)
from arcade.gui.experimental.animate import Animation
from arcade.gui.experimental.transition import TransitionBase
from arcade.gui.property import AliasProperty, Property, bind
from arcade.gui.surface import Surface
from arcade.gui.widgets import UIInteractiveWidget, UILayout, UIWidget, W
from arcade.types import LBWH, Point2
__all__ = ["UIRenderGroup", "UIAnimatedGroup"]
T = TypeVar("T", bound=TransitionBase)
[docs]
class UIRenderGroup(UILayout):
"""A widget that renders its single child onto an internal surface.
The surface is then drawn into the parent surface at render time
with applied transformations (rotation, alpha, scale, tint).
This makes it cheap to animate a whole subtree of widgets:
as long as the child subtree does not change, only the cached
surface is drawn again, the child widgets are not re-rendered.
The child is positioned at the bottom left of the group and resized
to fill it, so the subtree lives in a local coordinate system
starting at (0, 0). Mouse events are converted into this local
coordinate system (including the inverse of rotation and scaling)
before they are passed to the child.
Only allows a single child. ``size_hint``, ``size_hint_min`` and
``size_hint_max`` are delegated to the child.
"""
angle = Property(0.0)
"""Rotation in degrees applied around ``anchor``."""
anchor = Property[Point2 | None](None)
"""Point (in local coordinates) to rotate and scale around.
Defaults to the center of the group."""
alpha = Property(255)
"""Alpha value (0-255) applied to the whole subtree."""
scale = Property(1.0) # type: ignore # intentionally shadows UIWidget.scale()
"""Scale factor applied around ``anchor``.
Shadows :meth:`UIWidget.scale`, which resizes the rect instead."""
tint = Property(WHITE)
"""Color multiplier applied to the whole subtree."""
offset_x = Property(0.0)
"""Horizontal translation offset applied when drawing the cached surface.
Positive values shift the subtree to the right."""
offset_y = Property(0.0)
"""Vertical translation offset applied when drawing the cached surface.
Positive values shift the subtree upward."""
_SizeHint = tuple[float | None, float | None] | None
size_hint = AliasProperty[_SizeHint]("child", "size_hint")
size_hint_min = AliasProperty[_SizeHint]("child", "size_hint_min")
size_hint_max = AliasProperty[_SizeHint]("child", "size_hint_max")
# rect = AliasProperty[Rect]("child", "rect")
def __init__(self, *, child: UIWidget, **kwargs):
# child has to be added before super().__init__,
# so the size_hint AliasProperties can delegate to it
self.add(child)
# UIWidget.__init__ assigns the size_hint arguments unconditionally,
# which would write through the alias and clobber the child's hints
hints = (child.size_hint, child.size_hint_min, child.size_hint_max)
super().__init__(children=tuple(), **kwargs)
self.size_hint = kwargs.get("size_hint") or hints[0]
self.size_hint_min = kwargs.get("size_hint_min") or hints[1]
self.size_hint_max = kwargs.get("size_hint_max") or hints[2]
# start with a minimal surface, it is resized during rendering
self._surface = Surface(size=(1, 1))
# changes of these properties only require to draw the cached surface
# again, but the area below has to be re-rendered by the parents
bind(self, "angle", UIRenderGroup.trigger_full_render)
bind(self, "anchor", UIRenderGroup.trigger_full_render)
bind(self, "alpha", UIRenderGroup.trigger_full_render)
bind(self, "scale", UIRenderGroup.trigger_full_render)
bind(self, "tint", UIRenderGroup.trigger_full_render)
bind(self, "offset_x", UIRenderGroup.trigger_full_render)
bind(self, "offset_y", UIRenderGroup.trigger_full_render)
self.rect = LBWH(0, 0, child.width, child.height)
@property
def child(self) -> UIWidget | None:
"""The wrapped child widget."""
return self.children[0] if self.children else None
[docs]
def add(self, child: W, **kwargs) -> W:
if len(self.children) >= 1:
raise ValueError("UIRenderGroup only supports a single child.")
super().add(child, **kwargs)
return child
[docs]
def do_layout(self):
"""Place the child at (0, 0) filling the group.
The child subtree uses a local coordinate system, which is mapped
into the parent surface when the cached surface is drawn.
"""
child = self.child
if child:
child.rect = LBWH(0, 0, self.content_width, self.content_height)
def _update_surface_size(self) -> bool:
"""Resize the internal surface to the group's content size.
Returns:
True if the surface was resized (content is lost in that case).
"""
size = (max(1, int(self.content_width)), max(1, int(self.content_height)))
if self._surface.size != size:
self._surface.resize(size=size, pixel_ratio=self._surface.pixel_ratio)
return True
return False
def _subtree_requires_render(self) -> bool:
"""Check if any visible widget in the child subtree requests a render."""
stack: list[UIWidget] = list(self.children)
while stack:
widget = stack.pop()
if not widget.visible:
continue
if widget._requires_render:
return True
stack.extend(widget.children)
return False
def _do_render(self, surface: Surface, force: bool = False) -> bool:
if not self.visible:
return False
resized = self._update_surface_size()
requires_draw = force or self._requires_render
self._requires_render = False
# Only re-render the subtree into the internal surface when a child
# requests it. A forced render from outside (e.g. a cleared parent
# surface) does not invalidate the cached surface content.
if resized or self._subtree_requires_render():
with self._surface.activate():
self._surface.clear(self._bg_color or TRANSPARENT_BLACK)
for child in self.children:
child._do_render(self._surface, force=True)
requires_draw = True
if requires_draw:
# draw without limiting the viewport to the widget's rect,
# so rotation and scaling may extend beyond the allocated space
surface.limit(None)
rect = self.content_rect
self._surface.draw(
position=(rect.left + self.offset_x, rect.bottom + self.offset_y),
angle=self.angle,
anchor=self.anchor,
alpha=int(self.alpha),
scale=self.scale,
color=self.tint,
)
return True
return False
def _dispatch_event_to_child(self, child: UIWidget, event: UIEvent) -> bool | None:
"""Convert mouse events into the child's local coordinate system.
Only events passed to the child subtree are transformed; the group
itself (e.g. hover or click handling of an interactive subclass)
keeps working with the untransformed event in parent space.
"""
if isinstance(event, UIMouseEvent):
transformed = self._transform_mouse_event(event)
if transformed is None:
return EVENT_UNHANDLED
event = transformed
return super()._dispatch_event_to_child(child, event)
def _scale_xy(self) -> tuple[float, float]:
scale = self.scale
if isinstance(scale, (int, float)):
return float(scale), float(scale)
return float(scale[0]), float(scale[1])
def _transform_mouse_event(self, event: UIMouseEvent) -> UIMouseEvent | None:
"""Return a copy of the event with coordinates in local space.
Applies the inverse of the visual transformation
(translation, rotation and scaling around ``anchor``).
Returns None if the transformation is not invertible (scale of 0).
"""
sx, sy = self._scale_xy()
if sx == 0 or sy == 0:
return None
x, y = self._to_local(event.x, event.y)
changes: dict[str, int] = {"x": round(x), "y": round(y)}
if isinstance(event, (UIMouseMovementEvent, UIMouseDragEvent)):
dx, dy = self._inverse_rotate_scale(event.dx, event.dy)
changes["dx"] = round(dx)
changes["dy"] = round(dy)
return replace(event, **changes)
def _to_local(self, x: float, y: float) -> tuple[float, float]:
"""Map a point from parent coordinates into local (child) coordinates."""
rect = self.content_rect
ax, ay = self.anchor or (rect.width / 2, rect.height / 2)
# translate into local coordinates, relative to the anchor,
# compensating for the visual offset applied when drawing
vx = x - rect.left - self.offset_x - ax
vy = y - rect.bottom - self.offset_y - ay
vx, vy = self._inverse_rotate_scale(vx, vy)
return vx + ax, vy + ay
def _inverse_rotate_scale(self, vx: float, vy: float) -> tuple[float, float]:
"""Apply the inverse of the rotation and scale to a vector.
The surface is drawn with scale applied first, then rotation,
so the inverse rotates back first and then scales back.
"""
a = radians(self.angle)
c, s = cos(a), sin(a)
rx = vx * c + vy * s
ry = -vx * s + vy * c
sx, sy = self._scale_xy()
return rx / sx, ry / sy
[docs]
class UIAnimatedGroup(UIRenderGroup, UIInteractiveWidget):
"""An interactive :class:`UIRenderGroup` which can animate itself.
This is the main entry point for GUI animations. It combines
- the cached rendering and transform properties of :class:`UIRenderGroup`
(``scale``, ``angle``, ``alpha``, ``offset_x``, ``offset_y``, ``tint``),
- the interactive states of :class:`~arcade.gui.UIInteractiveWidget`
(``hovered``, ``pressed``, ``disabled`` and the ``on_click`` event),
- and the animation API (:meth:`animate`, :meth:`add_transition`).
Hover and click hit-testing runs against the group's untransformed rect
in parent space, so the trigger zone stays stable while the visuals are
scaled or rotated. The child subtree still receives mouse events
transformed into its local coordinate system.
React to state changes by binding to the group's properties:
.. code-block:: python
group = UIAnimatedGroup(child=UIFlatButton(text="Play"))
def on_hover_change():
if group.hovered:
group.animate(scale=1.08, duration=0.15, ease=Easing.BACK_OUT)
else:
group.animate(scale=1.0, duration=0.15)
bind(group, "hovered", on_hover_change)
Args:
child: The wrapped widget.
**kwargs: Passed on, e.g. ``interaction_buttons`` of
:class:`~arcade.gui.UIInteractiveWidget`.
"""
def __init__(self, *, child: UIWidget, **kwargs):
super().__init__(child=child, **kwargs)
self._transitions: list[TransitionBase] = []
[docs]
def on_event(self, event: UIEvent) -> bool | None:
# tick here instead of in an on_update handler, so subclasses
# overriding on_update without calling super() do not break animations
if isinstance(event, UIOnUpdateEvent):
self._update_transitions(event.dt)
return super().on_event(event)
def _update_transitions(self, dt: float) -> None:
for transition in self._transitions[:]:
transition.tick(self, dt)
if transition.finished:
self._transitions.remove(transition)
[docs]
def add_transition(self, transition: T) -> T:
"""Add a low level transition, which is ticked with update time.
Most code should prefer :meth:`animate`.
"""
self._transitions.append(transition)
return transition
[docs]
def clear_transitions(self):
"""Remove all transitions from this group.
Finished transitions are removed automatically.
This also stops all running animations, including animations of other
code. Prefer :meth:`Animation.stop` or starting a new animation for
the same properties, which takes them over automatically.
"""
self._transitions.clear()
[docs]
def animate(
self,
*,
duration: float = 0.3,
ease: EasingFunction = Easing.LINEAR,
delay: float = 0.0,
repeat: int | bool = 0,
yoyo: bool = False,
**properties: Any,
) -> Animation:
"""Animate properties of this group over time.
.. code-block:: python
group.animate(scale=1.08, duration=0.15, ease=Easing.BACK_OUT)
group.animate(alpha=0, duration=0.5).then(duration=0.1).on_finish(arcade.exit)
group.animate(scale=1.05, duration=0.6, ease=Easing.SINE, repeat=True, yoyo=True)
Start values are read when the animation starts.
Starting a new animation takes over its properties from other running
animations, which keep animating their remaining properties.
The transform properties (``scale``, ``angle``, ``alpha``,
``offset_x``, ``offset_y``, ``tint``) animate the wrapped subtree
visually, without affecting layouting.
Args:
duration: Duration of the animation in seconds.
ease: Easing function, see :class:`arcade.anim.Easing`.
delay: Seconds to wait before the animation starts.
repeat: How often to repeat the animation, ``True`` repeats forever.
yoyo: If True, the animation plays backwards to its start values
before it finishes (or repeats).
**properties: Attribute names mapped to target values,
e.g. ``scale=1.08``. Values wrapped in
:func:`~arcade.gui.experimental.rel` are relative to the
current value.
Returns:
The running :class:`~arcade.gui.experimental.Animation`, which can
be extended with ``.then(...)``, observed with ``.on_finish(...)``
and cancelled with ``.stop()``.
"""
return self.add_transition(
Animation(
duration=duration,
ease=ease,
delay=delay,
repeat=repeat,
yoyo=yoyo,
**properties,
)
)