Source code for bruhanimate.sequence

"""Timed, factory-owned animation sequences, independent of presentation FPS."""

from collections.abc import Callable
from dataclasses import dataclass

from .bruhutil import Buffer
from .image import _CycleClock
from .player import Animation
from .resize import _resize, _size
from .scene import _seconds


[docs] @dataclass(frozen=True) class SequenceStep: """One animation factory and its simulation-second timing. factory must return a fresh, unowned Animation each time it is called. duration is positive; hold_duration freezes its final frame, then gap hides it. Early child completion also freezes its frame until the step expires. name labels the step for captions. Construction never calls the factory. """ factory: Callable[[], Animation] duration: float hold_duration: float = 0 gap: float = 0 name: str = "" def __post_init__(self) -> None: if not callable(self.factory): raise TypeError("factory must be callable") if not isinstance(self.name, str): raise TypeError("name must be a string") for key in ("duration", "hold_duration", "gap"): _seconds(getattr(self, key), key) if self.duration == 0: raise ValueError("duration must be positive") _seconds(self.total_duration, "total step duration") @property def total_duration(self) -> float: """Seconds including the frozen hold and blank gap.""" return self.duration + self.hold_duration + self.gap
[docs] class AnimationSequence: """Play timed steps, owning only the active child; optionally repeat forever. Factories run at start or entry into a step, never during construction/draw. Replaced children close before the next factory runs. close is idempotent, including before start and after failed startup. Do not reuse child instances. update(dt) uses non-negative simulation seconds. Large jumps skip unvisited steps/cycles without constructing them and update the destination child once with its elapsed time. This is scheduling, not a fixed-tick simulation replay. Child finished=True stops updates, but timing still follows SequenceStep. A finite sequence retains its last frame (or a blank final gap) when finished. Empty sequences finish immediately. Configuration is fixed at construction. Resize preserves the active child and forwards the latest viewport to future children before their first update/draw. No input is polled or automatically routed; pass a handler to Player explicitly if interactive content needs one. """
[docs] def __init__(self, *steps: SequenceStep, repeat: bool = False) -> None: if any(not isinstance(step, SequenceStep) for step in steps): raise TypeError("steps must be SequenceStep instances") if type(repeat) is not bool: raise TypeError("repeat must be a bool") self.steps = tuple(steps) self.repeat = repeat self._lengths = tuple(step.total_duration for step in steps) self._duration = sum(self._lengths) _seconds(self._duration, "total sequence duration") self._clock = _CycleClock(self._duration or 1, repeat, 0, 0) self._index = 0 self._offset = 0.0 self._current: Animation | None = None self._size: tuple[int, int] | None = None self._started = self._closed = False
@property def duration(self) -> float: """Seconds for one entire sequence, including holds and gaps.""" return self._duration @property def index(self) -> int: """Zero-based active step; zero for an empty sequence.""" return self._index @property def current(self) -> Animation | None: """Borrowed active child, or None before start/after close. Never close it.""" return self._current @property def phase_name(self) -> str: """Active step name; empty for an empty sequence.""" return self.steps[self.index].name if self.steps else "" @property def finished(self) -> bool: return not self.steps or (not self.repeat and self._clock.time >= self.duration) @property def visible(self) -> bool: if not self.steps or self._closed: return False step = self.steps[self.index] return step.gap == 0 or self._offset < step.duration + step.hold_duration def _release(self) -> None: child, self._current = self._current, None if child is not None: child.close() def _enter(self, index: int) -> None: try: self._release() self._index = index child = self.steps[index].factory() if not isinstance(child, Animation): raise TypeError("factory must return an Animation") self._current = child child.start() if self._size is not None: _resize(child, self._size) except BaseException: self.close() raise
[docs] def start(self) -> None: if self._closed: raise RuntimeError("AnimationSequence is closed") if not self._started: self._started = True if self.steps: self._enter(0)
[docs] def update(self, dt: float) -> None: _seconds(dt, "dt") if not self._started or self._closed: raise RuntimeError("AnimationSequence must be started before update") if self.finished: return before = self._clock.time self._clock.update(dt) offset = self._clock.time index = 0 while index < len(self.steps) - 1: tolerance = min(1e-9, self.steps[index].duration * 1e-6) if offset < self._lengths[index] - tolerance: break offset = max(0.0, offset - self._lengths[index]) index += 1 wrapped = self.repeat and ( self._clock.time < before or dt >= self.duration - before ) previous = self._offset if index != self.index or wrapped: self._enter(index) previous = 0 step = self.steps[index] duration = step.duration for boundary in (duration, duration + step.hold_duration): if abs(offset - boundary) < min(1e-9, duration * 1e-6): offset = boundary self._offset = offset elapsed = max(0.0, min(offset, duration) - min(previous, duration)) child = self._current if ( child is not None and elapsed > 0 and getattr(child, "finished", None) is not True ): child.update(elapsed)
[docs] def draw(self, buffer: Buffer) -> None: if self._current is not None and self.visible: self._current.draw(buffer)
[docs] def resize(self, size: tuple[int, int]) -> None: _size(size) self._size = size if self._current is not None: _resize(self._current, size)
[docs] def close(self) -> None: if not self._closed: self._closed = True self._release()