diff --git a/arcade/gui/__init__.py b/arcade/gui/__init__.py index baea9e8822..83abd72b35 100644 --- a/arcade/gui/__init__.py +++ b/arcade/gui/__init__.py @@ -27,6 +27,17 @@ UILayout, ) from arcade.gui.widgets import UIDummy, Rect +from arcade.gui.transition import ( + EaseFunctions, + TransitionBase, + EventTransitionBase, + TransitionAttr, + TransitionAttrIncr, + TransitionChain, + TransitionParallel, + TransitionDelay, + TransitionAttrSet, +) from arcade.gui.widgets import UIInteractiveWidget from arcade.gui.widgets.text import UILabel, UIInputText, UITextArea from arcade.gui.widgets.toggle import UITextureToggle @@ -88,6 +99,16 @@ "Surface", "Rect", "NinePatchTexture", + # Transitions + "EaseFunctions", + "TransitionBase", + "EventTransitionBase", + "TransitionAttr", + "TransitionAttrIncr", + "TransitionAttrSet", + "TransitionChain", + "TransitionParallel", + "TransitionDelay", # Property classes "ListProperty", "DictProperty", diff --git a/arcade/gui/examples/transitions.py b/arcade/gui/examples/transitions.py new file mode 100644 index 0000000000..ea92f9395a --- /dev/null +++ b/arcade/gui/examples/transitions.py @@ -0,0 +1,63 @@ +import arcade +from arcade.gui import UIManager, TransitionChain, TransitionAttr, EaseFunctions, TransitionAttrIncr +from arcade.gui.transition import TransitionAttrSet +from arcade.gui.widgets.buttons import UIFlatButton + + +class DemoWindow(arcade.Window): + def __init__(self): + super().__init__(800, 600, "UI Mockup", resizable=True) + arcade.set_background_color(arcade.color.DARK_BLUE_GRAY) + + # Init UIManager + self.manager = UIManager() + self.manager.enable() + + button = self.manager.add(UIFlatButton(text="Click me I can move!")) + button.center_on_screen() + + @button.event + def on_click(event): + # button.disabled = True + + start_x, start_y = button.center + chain = TransitionChain() + + chain.add(TransitionAttrSet(attribute="disabled", value=True, duration=0)) + + chain.add(TransitionAttrIncr( + attribute="center_x", + increment=100, + duration=1.0 + )) + chain.add(TransitionAttrIncr( + attribute="center_y", + increment=100, + duration=1, + ease_function=EaseFunctions.sine + )) + + # Go back + chain.add(TransitionAttr( + attribute="center_x", + end=start_x, + duration=1, + ease_function=EaseFunctions.sine + )) + chain.add(TransitionAttr( + attribute="center_y", + end=start_y, + duration=1, + ease_function=EaseFunctions.sine + )) + chain.add(TransitionAttrSet(attribute="disabled", value=False, duration=0)) + + button.add_transition(chain) + + def on_draw(self): + self.clear() + self.manager.draw() + + +if __name__ == "__main__": + DemoWindow().run() diff --git a/arcade/gui/transition.py b/arcade/gui/transition.py new file mode 100644 index 0000000000..0503bcf691 --- /dev/null +++ b/arcade/gui/transition.py @@ -0,0 +1,257 @@ +import math +from abc import ABC, abstractmethod +from typing import Callable, Any, Optional, List, TypeVar + +from pyglet.event import EventDispatcher + +T = TypeVar("T", bound="TransitionBase") + + +class EaseFunctions: + @staticmethod + def linear(x: float): + return x + + @staticmethod + def sine(x: float): + return 1 - math.cos((x * math.pi) / 2) + + +class TransitionBase(ABC): + @abstractmethod + def tick(self, subject, dt) -> float: + """ + Update + + :return: dt, which is not consumed + """ + pass + + @property + @abstractmethod + def finished(self) -> bool: + raise not NotImplementedError() + + def __add__(self, other): + return TransitionChain(self, other) + + def __or__(self, other): + return TransitionParallel(self, other) + + +class EventTransitionBase(TransitionBase, EventDispatcher): + """ + Extension of TransitionBase, providing hooks via + + - on_tick(subject, progress: float) + - on_finish(subject) + + :param duration: Duration of the transition in seconds + :param delay: Start transition after x seconds + """ + + def __init__( + self, + *, + duration: float, + delay=0.0, + ): + self._duration = duration + self._elapsed = -delay + + self.register_event_type("on_tick") + self.register_event_type("on_finish") + + def tick(self, subject, dt) -> float: + self._elapsed += dt + if self._elapsed >= 0: + progress = min(self._elapsed / self._duration, 1) if self._duration else 1 + self.dispatch_event("on_tick", subject, progress) + + if self.finished: + self.dispatch_event("on_finish", subject) + + return max(0.0, self._elapsed - self._duration) + + def on_tick(self, subject, progress): + pass + + def on_finish(self, subject): + pass + + @property + def finished(self): + return self._elapsed >= self._duration + + +class TransitionDelay(EventTransitionBase): + def __init__(self, duration: float): + super().__init__(duration=duration) + + +class TransitionAttr(EventTransitionBase): + """ + Changes an attribute over time. + + :param start: start value, if None, the subjects value is read via `getattr` + :param end: target value + :param attribute: attribute to set + :param duration: Duration of the transition in seconds + :param ease_function: + :param delay: Start transition after x seconds + :param mutation_function: function to be used to set new value + """ + + def __init__( + self, + *, + end, + attribute, + duration: float, + start=None, + ease_function=EaseFunctions.linear, + delay=0.0, + mutation_function: Callable[[Any, str, float], None] = setattr, + ): + super().__init__(duration=duration, delay=delay) + self._start: Optional[float] = start + self._end = end + self._attribute = attribute + + self._ease_function = ease_function + self._mutation_function = mutation_function + + def on_tick(self, subject, progress): + if self._start is None: + self._start = getattr(subject, self._attribute) + + factor = self._ease_function(progress) + new_value = self._start + (self._end - self._start) * factor + + self._mutation_function(subject, self._attribute, new_value) + + +class TransitionAttrIncr(TransitionAttr): + """ + Changes an attribute over time. + + :param increment: difference the value should be changed over time (can be negative) + :param attribute: attribute to set + :param duration: Duration of the transition in seconds + :param ease_function: + :param delay: Start transition after x seconds + :param mutation_function: function to be used to set new value + """ + + def __init__( + self, + *, + increment: float, + attribute, + duration: float, + ease_function=EaseFunctions.linear, + delay=0.0, + mutation_function: Callable[[Any, str, float], None] = setattr, + ): + super().__init__(end=increment, attribute=attribute, duration=duration, delay=delay) + self._attribute = attribute + + self._ease_function = ease_function + self._mutation_function = mutation_function + + def on_tick(self, subject, progress): + if self._start is None: + self._start = getattr(subject, self._attribute) + self._end += self._start + + factor = self._ease_function(progress) + new_value = self._start + (self._end - self._start) * factor + + self._mutation_function(subject, self._attribute, new_value) + + +class TransitionAttrSet(EventTransitionBase): + """ + Set the attribute when expired. + + :param value: value to set + :param attribute: attribute to set + :param duration: Duration of the transition in seconds + """ + + def __init__( + self, + *, + value: float, + attribute, + duration: float, + mutation_function=setattr + ): + super().__init__(duration=duration) + self._attribute = attribute + self._value = value + self._mutation_function = mutation_function + + def on_finish(self, subject): + setattr(subject, self._attribute, self._value) + + +class TransitionParallel(TransitionBase): + """ + A transition assembled by multiple transitions. + Executing them in parallel. + """ + + def __init__(self, *transactions: TransitionBase): + super().__init__() + self._transitions: List[TransitionBase] = list(transactions) + + def add(self, transition: T) -> T: + self._transitions.append(transition) + return transition + + def tick(self, subject, dt): + remaining_dt = dt + + for transition in self._transitions[:]: + + r = transition.tick(subject, dt) + remaining_dt = min(remaining_dt, r) + + if transition.finished: + self._transitions.remove(transition) + + return remaining_dt + + @property + def finished(self) -> bool: + return not self._transitions + + +class TransitionChain(TransitionBase): + """ + A transition assembled by multiple transitions. + Executing them sequential. + """ + + def __init__(self, *transactions: TransitionBase): + super().__init__() + self._transitions: List[TransitionBase] = list(transactions) + + def add(self, transition: T) -> T: + self._transitions.append(transition) + return transition + + def tick(self, subject, dt): + while dt and not self.finished: + transition = self._transitions[0] + dt = transition.tick(subject, dt) + + if transition.finished: + self._transitions.pop(0) + + return min(0.0, dt) + + @property + def finished(self) -> bool: + return not self._transitions diff --git a/arcade/gui/widgets/__init__.py b/arcade/gui/widgets/__init__.py index 35e42aae1e..49ad2a7c51 100644 --- a/arcade/gui/widgets/__init__.py +++ b/arcade/gui/widgets/__init__.py @@ -27,10 +27,13 @@ from arcade.gui.property import Property, bind, ListProperty from arcade.gui.surface import Surface from arcade.gui.nine_patch import NinePatchTexture +from arcade.gui.transition import TransitionBase if TYPE_CHECKING: from arcade.gui.ui_manager import UIManager +T = TypeVar("T", bound="TransitionBase") + class Rect(NamedTuple): """ @@ -101,7 +104,7 @@ def center_y(self): @property def center(self): - return self.left, self.bottom + return self.center_x, self.center_y @property def position(self): @@ -255,6 +258,9 @@ def __init__( for child in children: self.add(child) + self._transitions: List[TransitionBase] = [] + self.event("on_update")(self._update_transitions) + bind(self, "rect", self.trigger_full_render) bind( self, "visible", self.trigger_full_render @@ -317,6 +323,27 @@ def clear(self): def __contains__(self, item): return item in self.children + def _update_transitions(self, dt): + # Update transitions + for transaction in self._transitions[:]: + transaction.tick(self, dt) + + if transaction.finished: + self._transitions.remove(transaction) + + def add_transition(self, transition: T) -> T: + """ + Add a transition, which will be updated using on_update time. + """ + self._transitions.append(transition) + return transition + + def clear_transactions(self): + """ + Remove all transitions from this widget. Finished Transitions are removed automatically. + """ + self._transitions.clear() + def on_update(self, dt): """Custom logic which will be triggered.""" pass @@ -483,10 +510,18 @@ def center(self, value: Tuple[int, int]): def center_x(self): return self.rect.center_x + @center_x.setter + def center_x(self, value): + self.rect = self.rect.align_center_x(value) + @property def center_y(self): return self.rect.center_y + @center_y.setter + def center_y(self, value): + self.rect = self.rect.align_center_y(value) + @property def padding(self): return ( diff --git a/doc/api_docs/arcade.rst b/doc/api_docs/arcade.rst index 6c7ccd54c6..d916711406 100644 --- a/doc/api_docs/arcade.rst +++ b/doc/api_docs/arcade.rst @@ -49,6 +49,7 @@ for the Python Arcade library. See also: api/gui_widgets api/gui_events api/gui_properties + api/gui_transition api/gui_style .. toctree:: diff --git a/doc/programming_guide/gui/concept.rst b/doc/programming_guide/gui/concept.rst index ca9660b72b..4a0cd568b2 100644 --- a/doc/programming_guide/gui/concept.rst +++ b/doc/programming_guide/gui/concept.rst @@ -54,6 +54,35 @@ For widgets, that might have transparent areas, they have to request a full rend Enforced rendering of the whole GUI might be very expensive! + +Transitions +=========== + +To animate a UIWidget, use :meth:`UIWidget.add_transition` and add a :class:`Transition`. +A :class:`Transition` can be used to manipulate a value over time. +:class:`EaseFunctions` provides a few easing functions. + +.. code-block:: + + widget = UIWidget() + # move a widgets center_x from 0 to 100 within 2 seconds + widget.add_transition(Transition(attribute="center_x", start=0, end=100, duration=2)) + +Transactions can also be chained to be executed sequentially using the `+` operator +or combined for parallel execution with the `|` operator. + +Arcade provides following transitions: + +- :class:`TransitionAttr` - Change value over time (start til end) +- :class:`TransitionAttrIncr` - Increment value over time +- :class:`TransitionAttrSet` - Set value after time +- :class:`TransitionParallel` - Execute multiple transactions parallel +- :class:`TransitionChain` - Execute multiple transactions sequentially +- :class:`TransitionDelay` - Used to pause :class:`TransitionChain` + +> Be aware, that transitions may interfere with :class:`UILayout` positioning. + + UILayout ======== @@ -119,6 +148,8 @@ Size hint support +--------------------------+------------+----------------+----------------+ | :class:`UIBoxLayout` | X | X | X | +--------------------------+------------+----------------+----------------+ +| :class:`UIGridLayout` | | | | ++--------------------------+------------+----------------+----------------+ | :class:`UIManager` | X | X | | +--------------------------+------------+----------------+----------------+ diff --git a/tests/test_gui/test_rect.py b/tests/test_gui/test_rect.py index f29671d0d8..53add5e070 100644 --- a/tests/test_gui/test_rect.py +++ b/tests/test_gui/test_rect.py @@ -60,6 +60,14 @@ def test_rect_align_center_y(): assert new_rect == (10, -50, 100, 200) +def test_rect_center(): + # WHEN + rect = Rect(0, 0, 100, 200) + + # THEN + assert rect.center == (50, 100) + + def test_rect_align_top(): # GIVEN rect = Rect(10, 20, 100, 200) diff --git a/tests/test_gui/test_transitions.py b/tests/test_gui/test_transitions.py new file mode 100644 index 0000000000..4d17112a42 --- /dev/null +++ b/tests/test_gui/test_transitions.py @@ -0,0 +1,190 @@ +from unittest.mock import Mock + +from arcade.gui import ( + UIWidget, TransitionAttr, TransitionChain, EventTransitionBase, + TransitionParallel, TransitionDelay, TransitionAttrIncr, TransitionAttrSet +) + + +def test_move_widget(): + widget = UIWidget() + assert widget.center_x == 50 + + widget.add_transition(TransitionAttr(attribute="center_x", start=0, end=100, duration=2)) + + # set start value + widget.dispatch_event("on_update", 0.0) + assert widget.center_x == 0 + + # update value + widget.dispatch_event("on_update", 0.1) + assert widget.center_x == 5 + + # reach 50% + widget.dispatch_event("on_update", 0.9) + assert widget.center_x == 50 + + # do not overshoot + widget.dispatch_event("on_update", 1.1) + assert widget.center_x == 100 + + # do not change value + widget.dispatch_event("on_update", 1) + assert widget.center_x == 100 + + +def test_transition_chain_perfect_update_interval(): + widget = UIWidget() + assert widget.center_x == 50 + + chain = widget.add_transition(TransitionChain()) + chain.add(TransitionAttr(attribute="center_x", end=100, duration=1)) + chain.add(TransitionAttr(attribute="center_x", end=50, duration=1)) + chain.add(TransitionAttr(attribute="center_x", end=150, duration=1)) + chain.add(TransitionAttr(attribute="center_x", end=200, duration=1)) + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 100 + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 50 + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 150 + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 200 + + +def test_transition_chain_split_update_interval(): + widget = UIWidget() + assert widget.center_x == 50 + + chain = widget.add_transition(TransitionChain()) + chain.add(TransitionAttr(attribute="center_x", end=100, duration=1)) + chain.add(TransitionAttr(attribute="center_x", end=50, duration=1)) + + widget.dispatch_event("on_update", 2) + assert widget.center_x == 50 + + +def test_parallel_transition(): + widget = UIWidget() + widget.center = (0, 0) + + parallel = widget.add_transition(TransitionParallel()) + parallel.add(TransitionAttr(attribute="center_x", end=100, duration=1)) + parallel.add(TransitionAttr(attribute="center_y", end=50, duration=1)) + + widget.dispatch_event("on_update", 0.5) + assert widget.center == (50, 25) + + widget.dispatch_event("on_update", 0.5) + assert widget.center == (100, 50) + + +def test_parallel_returns_remaining_dt(): + widget = UIWidget() + widget.center = (0, 0) + + parallel = widget.add_transition(TransitionParallel()) + parallel.add(TransitionAttr(attribute="center_x", end=100, duration=1.5)) + parallel.add(TransitionAttr(attribute="center_y", end=50, duration=1)) + + remaining_dt = parallel.tick(widget, 1) + assert remaining_dt == 0 + + remaining_dt = parallel.tick(widget, 1) + assert remaining_dt == 0.5 + + +def test_transition_chain_with_delay(): + widget = UIWidget() + widget.center = (0, 0) + + chain = widget.add_transition(TransitionChain()) + chain.add(TransitionDelay(duration=1.5)) + chain.add(TransitionAttr(attribute="center_y", end=50, duration=1)) + + widget.dispatch_event("on_update", 1) + assert widget.center_y == 0 + + widget.dispatch_event("on_update", 0.5) + assert widget.center_y == 0 + + widget.dispatch_event("on_update", 1) + assert widget.center_y == 50 + + +def test_event_transaction_base_dispatching(): + widget = UIWidget() + widget.center = (0, 0) + + et = widget.add_transition(EventTransitionBase(duration=1)) + + et.on_tick = Mock() + et.on_finish = Mock() + + widget.dispatch_event("on_update", 0.5) + + assert et.on_tick.called + assert not et.on_finish.called + + widget.dispatch_event("on_update", 0.5) + assert et.on_tick.called + assert et.on_finish.called + + +def test_transition_attr_increment(): + widget = UIWidget() + widget.center = (50, 0) + + widget.add_transition(TransitionAttrIncr(attribute="center_x", increment=100, duration=1)) + + widget.dispatch_event("on_update", 0.5) + assert widget.center_x == 100 + + widget.dispatch_event("on_update", 0.5) + assert widget.center_x == 150 + + +def test_transition_attr_setter(): + widget = UIWidget() + widget.center = (50, 0) + + widget.add_transition(TransitionAttrSet(attribute="visible", value=False, duration=1)) + + widget.dispatch_event("on_update", 0.5) + assert widget.visible is True + + widget.dispatch_event("on_update", 0.5) + assert widget.visible is False + + +def test_operation_syntax_parallel(): + widget = UIWidget() + widget.center = (0, 0) + + widget.add_transition( + TransitionAttrIncr(attribute="center_x", increment=100, duration=1) + + TransitionAttrIncr(attribute="center_x", increment=100, duration=1) + ) + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 100 + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 200 + + +def test_operation_syntax_chain(): + widget = UIWidget() + widget.center = (0, 0) + + widget.add_transition( + TransitionAttrIncr(attribute="center_x", increment=100, duration=1) + | TransitionAttrIncr(attribute="center_x", increment=100, duration=1) + ) + + widget.dispatch_event("on_update", 1) + assert widget.center_x == 200 diff --git a/util/update_quick_index.py b/util/update_quick_index.py index 3da9ea0ef4..6d193a6ada 100644 --- a/util/update_quick_index.py +++ b/util/update_quick_index.py @@ -65,6 +65,7 @@ 'widgets/text.py': ['GUI Widgets', 'gui_widgets.rst'], 'widgets/toggle.py': ['GUI Widgets', 'gui_widgets.rst'], 'gui/property.py': ['GUI Properties', 'gui_properties.rst'], + 'gui/transition.py': ['Transition', 'gui_transition.rst'], 'gui/style.py': ['GUI Style', 'gui_style.rst'], 'events/__init__.py': ['GUI Utility Functions', 'gui_utility.rst'],