Source code for domonic.animation

from __future__ import annotations

"""
domonic.animation
=================

Web Animations-style helpers for domonic.

This module provides a practical Python surface for working with animation
timing, keyframes, playback state, and DOM-connected ``Element.animate(...)``
behaviour.
"""

import math
import re
from dataclasses import dataclass
from typing import Any

from domonic.events import Event, EventTarget
from domonic.lerpy import lerp
from domonic.lerpy.easing import Linear, Quad


def _coerce_timing_options(options: EffectTiming | dict[str, Any] | None) -> dict[str, Any]:
    if options is None:
        return {}
    if isinstance(options, EffectTiming):
        return options.to_dict()
    if isinstance(options, dict):
        return dict(options)
    if isinstance(options, (int, float)):
        return {"duration": float(options)}
    raise TypeError("Unsupported timing options")


def _normalize_iteration_count(value: Any) -> float:
    if value in (None, "", "Infinity", "infinity"):
        return math.inf if value in ("Infinity", "infinity") else 1.0
    return float(value)


def _parse_interpolable(value: Any) -> tuple[float, str] | None:
    if isinstance(value, (int, float)):
        return float(value), ""
    if isinstance(value, str):
        match = re.fullmatch(r"\s*(-?\d+(?:\.\d+)?)([a-zA-Z%]*)\s*", value)
        if match:
            return float(match.group(1)), match.group(2)
    return None


def _format_interpolated(value: float, unit: str) -> str | float:
    if unit:
        formatted = int(value) if float(value).is_integer() else round(value, 6)
        return f"{formatted}{unit}"
    return int(value) if float(value).is_integer() else value


def _resolve_easing(name: str):
    easing = (name or "linear").strip().lower()
    mapping = {
        "linear": Linear.easeIn,
        "ease": Quad.easeInOut,
        "ease-in": Quad.easeIn,
        "ease-out": Quad.easeOut,
        "ease-in-out": Quad.easeInOut,
    }
    return mapping.get(easing, Linear.easeIn)


[docs] @dataclass class EffectTiming: """Timing parameters shared by animation effects. This is the author-facing timing object that describes delay, duration, direction, fill mode, easing, and iteration behaviour. """ delay: float = 0.0 direction: str = "normal" duration: float = 0.0 easing: str = "linear" endDelay: float = 0.0 fill: str = "none" iterationStart: float = 0.0 iterations: float = 1.0 def __init__( self, delay: float = 0.0, direction: str = "normal", duration: float = 0.0, easing: str = "linear", endDelay: float = 0.0, fill: str = "none", iterationStart: float = 0.0, iterations: float = 1.0, ) -> None: self.delay = float(delay) self.direction = direction self.duration = float(duration) self.easing = easing self.endDelay = float(endDelay) self.fill = fill self.iterationStart = float(iterationStart) self.iterations = _normalize_iteration_count(iterations) def to_dict(self) -> dict[str, Any]: return { "delay": self.delay, "direction": self.direction, "duration": self.duration, "easing": self.easing, "endDelay": self.endDelay, "fill": self.fill, "iterationStart": self.iterationStart, "iterations": self.iterations, }
[docs] class ComputedEffectTiming(EffectTiming): """Resolved timing data derived from an effect plus a current local time.""" def __init__( self, timing: EffectTiming | None = None, *, activeDuration: float = 0.0, currentIteration: int | None = None, endTime: float = 0.0, progress: float | None = None, ) -> None: timing = timing or EffectTiming() super().__init__(**timing.to_dict()) self.activeDuration = activeDuration self.currentIteration = currentIteration self.endTime = endTime self.progress = progress
[docs] class AnimationPlaybackEvent(Event): """Event carrying playback timing information for animation lifecycle hooks.""" def __init__(self, _type: str, options: dict[str, Any] | None = None, *args, **kwargs) -> None: options = options or kwargs self.currentTime = options.get("currentTime") self.timelineTime = options.get("timelineTime") super().__init__(_type, options, *args, **kwargs)
[docs] class AnimationEffect: """Base class for effects that can be sampled over time and applied to targets.""" def __init__(self, timing: EffectTiming | dict[str, Any] | None = None) -> None: self._timing = EffectTiming(**_coerce_timing_options(timing)) def getTiming(self) -> EffectTiming: return EffectTiming(**self._timing.to_dict()) def updateTiming(self, options: EffectTiming | dict[str, Any] | None = None) -> None: if options is None: return updated = self._timing.to_dict() updated.update(_coerce_timing_options(options)) self._timing = EffectTiming(**updated) def getComputedTiming(self, local_time: float | None = None) -> ComputedEffectTiming: timing = self.getTiming() iterations = timing.iterations active_duration = math.inf if math.isinf(iterations) else timing.duration * iterations end_time = timing.delay + active_duration + timing.endDelay progress = None current_iteration = None if local_time is not None: active_time = local_time - timing.delay if active_time < 0: if timing.fill in {"backwards", "both"}: progress = 0.0 current_iteration = 0 elif math.isinf(active_duration) or active_time < active_duration: duration = timing.duration if timing.duration > 0 else 1.0 current_iteration = int(active_time // duration) if timing.duration > 0 else 0 simple_progress = 1.0 if timing.duration == 0 else (active_time % duration) / duration progress = self._directed_progress(simple_progress, current_iteration) elif active_duration == 0 or active_time == active_duration: progress = self._directed_progress(1.0, max(0, int(iterations) - 1 if not math.isinf(iterations) else 0)) current_iteration = None if math.isinf(iterations) else max(0, int(iterations) - 1) elif timing.fill in {"forwards", "both"}: progress = self._directed_progress(1.0, max(0, int(iterations) - 1 if not math.isinf(iterations) else 0)) current_iteration = None if math.isinf(iterations) else max(0, int(iterations) - 1) return ComputedEffectTiming( timing, activeDuration=active_duration, currentIteration=current_iteration, endTime=end_time, progress=progress, ) def _directed_progress(self, progress: float, iteration: int) -> float: direction = self._timing.direction if direction == "reverse": return 1.0 - progress if direction == "alternate" and iteration % 2 == 1: return 1.0 - progress if direction == "alternate-reverse": return progress if iteration % 2 == 1 else 1.0 - progress return progress def apply(self, local_time: float | None = None) -> None: raise NotImplementedError
[docs] class KeyframeEffect(AnimationEffect): """Keyframe-based effect bound to a target object or DOM element.""" def __init__( self, target: Any, keyframes: list[dict[str, Any]] | dict[str, Any], options: EffectTiming | dict[str, Any] | None = None, ) -> None: self.target = target self._keyframes = self._normalize_keyframes(keyframes) super().__init__(options) @staticmethod def _normalize_keyframes(keyframes: list[dict[str, Any]] | dict[str, Any]) -> list[dict[str, Any]]: frames = [dict(frame) for frame in (keyframes if isinstance(keyframes, list) else [keyframes])] if not frames: return [{"offset": 0.0}, {"offset": 1.0}] for index, frame in enumerate(frames): if "offset" in frame and frame["offset"] is not None: frame["offset"] = float(frame["offset"]) else: frame["offset"] = index / (len(frames) - 1) if len(frames) > 1 else 1.0 frames.sort(key=lambda frame: frame["offset"]) return frames def getKeyframes(self) -> list[dict[str, Any]]: return [dict(frame) for frame in self._keyframes] def _interpolate_value(self, prop: str, start: Any, end: Any, progress: float) -> Any: easing_fn = _resolve_easing(self._timing.easing) eased = easing_fn(progress, 0, 1, 1, 0, 0) start_value = _parse_interpolable(start) end_value = _parse_interpolable(end) if start_value is not None and end_value is not None and start_value[1] == end_value[1]: return _format_interpolated(lerp(start_value[0], end_value[0], eased), start_value[1]) return end if eased >= 0.5 else start def _apply_property(self, prop: str, value: Any) -> None: if hasattr(self.target, "style") and hasattr(self.target.style, "__dict__"): setattr(self.target.style, prop, value) return if hasattr(self.target, prop): setattr(self.target, prop, value) return if hasattr(self.target, "setAttribute"): self.target.setAttribute(prop, value) def apply(self, local_time: float | None = None) -> None: computed = self.getComputedTiming(local_time) if computed.progress is None: return progress = computed.progress before = self._keyframes[0] after = self._keyframes[-1] for index in range(len(self._keyframes) - 1): start = self._keyframes[index] end = self._keyframes[index + 1] if start["offset"] <= progress <= end["offset"]: before = start after = end break span = after["offset"] - before["offset"] local_progress = 1.0 if span == 0 else (progress - before["offset"]) / span properties = set(before.keys()) | set(after.keys()) properties.discard("offset") properties.discard("easing") properties.discard("composite") for prop in properties: start_value = before.get(prop, after.get(prop)) end_value = after.get(prop, before.get(prop)) value = self._interpolate_value(prop, start_value, end_value, local_progress) self._apply_property(prop, value)
[docs] class Animation(EventTarget): def __init__( self, effect: AnimationEffect | None = None, timeline: Any = None, *, id: str = "", ) -> None: super().__init__() self.effect = effect self.timeline = timeline self.id = id self.playbackRate: float = 1.0 self.startTime: float | None = None self._currentTime: float | None = None self.playState: str = "idle" @property def currentTime(self) -> float | None: if self.playState == "running" and self.startTime is not None and self.timeline is not None: self._currentTime = (self.timeline.currentTime - self.startTime) * self.playbackRate return self._currentTime @currentTime.setter def currentTime(self, value: float | None) -> None: self._currentTime = None if value is None else float(value) if self.effect is not None and self._currentTime is not None: self.effect.apply(self._currentTime) def play(self) -> None: if self.timeline is not None: self.startTime = self.timeline.currentTime - (self._currentTime or 0.0) self.playState = "running" if self._currentTime is None: self.currentTime = 0.0 def pause(self) -> None: self.currentTime = self.currentTime self.playState = "paused" def cancel(self) -> None: self.playState = "idle" self.startTime = None self._currentTime = None self.dispatchEvent( AnimationPlaybackEvent( "cancel", {"currentTime": None, "timelineTime": getattr(self.timeline, "currentTime", None)}, ) ) def finish(self) -> None: effect_time = 0.0 if self.effect is not None: effect_time = self.effect.getComputedTiming().activeDuration self.currentTime = effect_time self.playState = "finished" self.dispatchEvent( AnimationPlaybackEvent( "finish", {"currentTime": self.currentTime, "timelineTime": getattr(self.timeline, "currentTime", None)}, ) ) def reverse(self) -> None: self.playbackRate = -self.playbackRate self.play() def updatePlaybackRate(self, playback_rate: float) -> None: self.currentTime = self.currentTime self.playbackRate = float(playback_rate)