Source code for sugar.graphics.animator

# Copyright (C) 2007, Red Hat, Inc.
# Copyright (C) 2025 MostlyK
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., 59 Temple Place - Suite 330,
# Boston, MA 02111-1307, USA.
#
# SPDX-License-Identifier: LGPL-2.1-or-later

"""
Animator
====================

The animator module provides a simple framework to create animations in GTK4.

Example:
    Animate the size of a window::

        from gi.repository import Gtk
        from sugar.graphics.animator import Animator, Animation

        # Construct a window to animate
        w = Gtk.Window()
        w.connect('destroy', lambda w: app.quit())

        # Start the animation when the window is shown
        w.connect('realize', lambda self: animator.start())
        w.present()

        # Construct a 5 second animator
        animator = Animator(5, widget=w)

        # Create an animation subclass to animate the widget
        class SizeAnimation(Animation):
            def __init__(self):
                # Tell the animation to give us values between 20 and
                # 420 during the animation
                Animation.__init__(self, 20, 420)

            def next_frame(self, frame):
                size = int(frame)
                w.set_default_size(size, size)

        # Add the animation to the animator
        animation = SizeAnimation()
        animator.add(animation)

        # The animation runs inside a GLib main loop
        app.run()

STABLE.
"""

import time
import math
import gi
gi.require_version('Gtk', '4.0')

from gi.repository import GObject, GLib
import logging

EASE_OUT_EXPO = 0
EASE_IN_EXPO = 1


[docs] class Animator(GObject.GObject): """ The animator class manages the timing for calling the animations. The animations can be added using the `add` function and then started with the `start` function. If multiple animations are added, then they will be played back at the same time and rate as each other. The `completed` signal is emitted upon the completion of the animation and also when the `stop` function is called. Args: duration (float): the duration of the animation in seconds fps (int, optional): the number of animation callbacks to make per second (frames per second). This is used as fallback when frame clock is not available. easing (int): the desired easing mode, either `EASE_OUT_EXPO` or `EASE_IN_EXPO` widget (:class:`Gtk.Widget`): one of the widgets that the animation is acting on. If supplied, the animation will run on the frame clock of the widget for smoother animation. .. note:: When creating an animation, take into account the limited cpu power on some devices, such as the XO. Setting the fps too high can use significant cpu usage. """ __gsignals__ = { 'completed': (GObject.SignalFlags.RUN_FIRST, None, ([])), }
[docs] def __init__(self, duration, fps=20, easing=EASE_OUT_EXPO, widget=None): GObject.GObject.__init__(self) self._animations = [] self._duration = duration self._interval = 1.0 / fps self._easing = easing self._widget = widget self._timeout_sid = 0 self._tick_callback_id = 0 self._start_time = None self._completed = False
[docs] def add(self, animation): """ Add an animation to this animator. Args: animation (:class:`sugar.graphics.animator.Animation`): the animation instance to add """ self._animations.append(animation)
[docs] def remove_all(self): """ Remove all animations and stop this animator. """ self.stop() self._animations = []
[docs] def start(self): """ Start the animation running. This will stop and restart the animation if the animation is currently running. """ if self._timeout_sid or self._tick_callback_id: self.stop() self._start_time = time.time() self._completed = False # Using GTK4 frame clock for smoother animation if self._widget and hasattr(self._widget, 'add_tick_callback'): try: self._tick_callback_id = self._widget.add_tick_callback( self._tick_cb, None) logging.debug("Using GTK4 frame clock for animation") except Exception as e: logging.warning(f"Failed to use frame clock, falling back to timeout: {e}") self._use_timeout_fallback() else: self._use_timeout_fallback()
def _use_timeout_fallback(self): """Use GLib timeout as fallback when frame clock is not available.""" interval_ms = int(self._interval * 1000) self._timeout_sid = GLib.timeout_add(interval_ms, self._timeout_cb) logging.debug(f"Using timeout fallback with {interval_ms}ms interval")
[docs] def stop(self): """ Stop the animation and emit the `completed` signal. """ # Stop any active animation if self._tick_callback_id and self._widget: try: self._widget.remove_tick_callback(self._tick_callback_id) except Exception as e: logging.warning(f"Error removing tick callback: {e}") self._tick_callback_id = 0 if self._timeout_sid: GLib.source_remove(self._timeout_sid) self._timeout_sid = 0 # Call do_stop on all animations for animation in self._animations: animation.do_stop() # Emit completed signal if not already completed if not self._completed: self._completed = True self.emit('completed')
def _tick_cb(self, widget, frame_clock, user_data): """GTK4 frame clock callback.""" if self._start_time is None: self._start_time = time.time() return self._next_frame_cb() def _timeout_cb(self): """GLib timeout callback.""" return self._next_frame_cb() def _next_frame_cb(self): """Process the next animation frame.""" if self._completed: return GLib.SOURCE_REMOVE current_time = min(self._duration, time.time() - self._start_time) current_time = max(current_time, 0.0) for animation in self._animations: animation.do_frame(current_time, self._duration, self._easing) if current_time >= self._duration: self.stop() return GLib.SOURCE_REMOVE else: return GLib.SOURCE_CONTINUE
[docs] class Animation(object): """ The animation class is a base class for creating an animation. It should be subclassed. Subclasses should specify a `next_frame` function to set the required properties based on the animation progress. The range of the `frame` value passed to the `next_frame` function is defined by the `start` and `end` values. Args: start (float): the first `frame` value for the `next_frame` method end (float): the last `frame` value for the `next_frame` method .. code-block:: python # Create an animation subclass class MyAnimation(Animation): def __init__(self, thing): # Tell the animation to give us values between 0.0 and # 1.0 during the animation Animation.__init__(self, 0.0, 1.0) self._thing = thing def next_frame(self, frame): # Use the `frame` value to set properties self._thing.set_green_value(frame) """
[docs] def __init__(self, start, end): self.start = start self.end = end
[docs] def do_frame(self, t, duration, easing): """ This method is called by the animator class every frame. This method calculates the `frame` value to then call `next_frame`. Args: t (float): the current time elapsed of the animation in seconds duration (float): the length of the animation in seconds easing (int): the easing mode passed to the animator """ start = self.start change = self.end - self.start if t == duration: frame = self.end else: if easing == EASE_OUT_EXPO: frame = change * (-pow(2, -10 * t / duration) + 1) + start elif easing == EASE_IN_EXPO: frame = change * pow(2, 10 * (t / duration - 1)) + start else: frame = change * (t / duration) + start self.next_frame(frame)
[docs] def next_frame(self, frame): ''' This method is called every frame and should be overridden by subclasses. Args: frame (float): a value between `start` and `end` representing the current progress in the animation ''' pass
[docs] def do_stop(self): ''' This method is called whenever the animation is stopped, either due to the animation ending or being stopped by the animation. `next_frame` will not be called after do_stop, unless the animation is restarted. .. versionadded:: 0.109.0.3 This should be used in subclasses if they bind any signals. Eg. if they bind the draw signal for a widget: .. code-block:: python class SignalAnimation(Animation): def __init__(self, widget): Animation.__init__(self, 0, 1) self._draw_hid = None self._widget = widget def next_frame(self, frame): self._frame = frame if self._draw_hid is None: self._draw_hid = self._widget.connect_after( 'draw', self.__draw_cb) self._widget.queue_draw() def __draw_cb(self, widget, cr): cr.save() # Do the draw cr.restore() def do_stop(self): self._widget.disconnect(self._draw_hid) self._widget.queue_draw() ''' pass
[docs] class FadeAnimation(Animation): """ A convenience animation class for fading widgets in/out. Args: widget (Gtk.Widget): The widget to animate start_opacity (float): Starting opacity (0.0 to 1.0) end_opacity (float): Ending opacity (0.0 to 1.0) """
[docs] def __init__(self, widget, start_opacity=0.0, end_opacity=1.0): super().__init__(start_opacity, end_opacity) self._widget = widget
[docs] def next_frame(self, frame): """Update widget opacity.""" if self._widget: self._widget.set_opacity(frame)
[docs] class ScaleAnimation(Animation): """ A convenience animation class for scaling widgets. Args: widget (Gtk.Widget): The widget to animate start_scale (float): Starting scale factor end_scale (float): Ending scale factor """
[docs] def __init__(self, widget, start_scale=0.0, end_scale=1.0): super().__init__(start_scale, end_scale) self._widget = widget
[docs] def next_frame(self, frame): """Update widget scale.""" if self._widget: # Apply scale transform transform = self._widget.get_transform() if transform: transform = transform.scale(frame, frame) else: # Create new transform from gi.repository import Gsk transform = Gsk.Transform.new().scale(frame, frame) self._widget.set_transform(transform)
[docs] class MoveAnimation(Animation): """ A convenience animation class for moving widgets. Args: widget (Gtk.Widget): The widget to animate start_pos (tuple): Starting position (x, y) end_pos (tuple): Ending position (x, y) """
[docs] def __init__(self, widget, start_pos, end_pos): super().__init__(0.0, 1.0) self._widget = widget self._start_pos = start_pos self._end_pos = end_pos
[docs] def next_frame(self, frame): """Update widget position.""" if self._widget: x = self._start_pos[0] + (self._end_pos[0] - self._start_pos[0]) * frame y = self._start_pos[1] + (self._end_pos[1] - self._start_pos[1]) * frame # Apply translation transform from gi.repository import Gsk transform = Gsk.Transform.new().translate((x, y)) self._widget.set_transform(transform)
[docs] class ColorAnimation(Animation): """ A convenience animation class for animating colors. Args: start_color (tuple): Starting RGBA color (r, g, b, a) end_color (tuple): Ending RGBA color (r, g, b, a) callback (function): Function to call with interpolated color """
[docs] def __init__(self, start_color, end_color, callback): super().__init__(0.0, 1.0) self._start_color = start_color self._end_color = end_color self._callback = callback
[docs] def next_frame(self, frame): """Interpolate color and call callback.""" if self._callback: r = self._start_color[0] + (self._end_color[0] - self._start_color[0]) * frame g = self._start_color[1] + (self._end_color[1] - self._start_color[1]) * frame b = self._start_color[2] + (self._end_color[2] - self._start_color[2]) * frame a = self._start_color[3] + (self._end_color[3] - self._start_color[3]) * frame self._callback((r, g, b, a))