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)