Source code for bruhanimate.motion

"""Transparent, simulation-timed accents in board coordinates."""

from collections.abc import Sequence
from math import floor, hypot
from typing import Literal

from .bruhutil import Buffer
from .grid import GridViewport, _glyph
from .image import _CycleClock
from .resize import _size
from .scene import _seconds
from .styling import TextStyle

Cell = tuple[int, int]
PathMode = Literal["pulse", "trace"]
RippleMetric = Literal["euclidean", "manhattan", "chebyshev"]


def _point(point: Cell, size: tuple[int, int]) -> Cell:
    if (
        not isinstance(point, tuple)
        or len(point) != 2
        or any(type(n) is not int for n in point)
    ):
        raise TypeError("points must be (column, row) integer tuples")
    if not (0 <= point[0] < size[0] and 0 <= point[1] < size[1]):
        raise ValueError("points must lie inside size=(columns, rows)")
    return point


class _Accent:
    def __init__(
        self,
        *,
        size: tuple[int, int],
        duration: float,
        hold_duration: float,
        repeat: bool,
        repeat_delay: float,
        x: int | None,
        y: int | None,
        spacing: tuple[int, int],
        glyph: str,
    ) -> None:
        _size(size)
        _size(spacing)
        for name, value in (("x", x), ("y", y)):
            if value is not None and type(value) is not int:
                raise TypeError(f"{name} must be an integer or None")
        _seconds(duration, "duration")
        _seconds(hold_duration, "hold_duration")
        if duration == 0:
            raise ValueError("duration must be positive")
        self.size, self.spacing, self.x, self.y = size, spacing, x, y
        self.duration, self.hold_duration = duration, hold_duration
        self.repeat, self.repeat_delay = repeat, repeat_delay
        self.glyph = _glyph(glyph)
        self._clock = _CycleClock(duration + hold_duration, repeat, 0, repeat_delay)

    @property
    def progress(self) -> float:
        """Travel/expansion fraction from zero to one, excluding hold and gap."""
        return min(1.0, self._clock.time / self.duration)

    @property
    def finished(self) -> bool:
        """A one-shot finishes after duration + hold_duration and paints nothing."""
        return not self.repeat and self._clock.time >= self._clock.duration

    @property
    def visible(self) -> bool:
        return not self.finished and self._clock.visible

    def start(self) -> None:
        """Inert; repeated starts preserve time. No resources or input are owned."""

    def close(self) -> None:
        """Inert and idempotent, including before startup."""

    def update(self, dt: float) -> None:
        """Advance by non-negative simulation seconds, independently of draw FPS."""
        self._clock.update(dt)

    def draw(self, buffer: Buffer) -> None:
        """Paint without advancing time; centered placement follows the viewport."""
        if self.visible:
            grid = GridViewport(
                buffer, *self.size, x=self.x, y=self.y, spacing=self.spacing
            )
            self._paint(grid)

    def _paint(self, grid: GridViewport) -> None:
        raise NotImplementedError


[docs] class PathEffect(_Accent): """Move a pulse along supplied board cells or progressively trace the route. points are copied, ordered (column, row) cells inside size=(columns, rows). Each interval between entries takes equal time; no interpolation, pathfinding or solver consumption is performed. Repeated points create timed pauses. mode='pulse' paints the current cell plus up to trail preceding cells; mode='trace' paints all visited cells. trail counts cells, not seconds. duration is positive simulation seconds from first to last cell; a one-cell path stays there. hold_duration retains the final state before disappearing. repeat adds a blank repeat_delay and starts again. Empty paths finish at once. x/y=None center the board like GridViewport; spacing separates glyph anchors. Resize recenters without changing time. Unpainted cells are transparent. Layer supplies masks/style/background preservation. No resources or input are owned; start/close are inert, update advances time, and draw is pure. """
[docs] def __init__( self, points: Sequence[Cell], *, size: tuple[int, int], duration: float = 2, mode: PathMode = "pulse", trail: int = 3, glyph: str = "*", hold_duration: float = 0, repeat: bool = False, repeat_delay: float = 1, x: int | None = None, y: int | None = None, spacing: tuple[int, int] = (1, 1), ) -> None: if mode not in ("pulse", "trace"): raise ValueError("mode must be 'pulse' or 'trace'") if type(trail) is not int: raise TypeError("trail must be an integer") if trail < 0: raise ValueError("trail must be non-negative") super().__init__( size=size, duration=duration, hold_duration=hold_duration, repeat=repeat, repeat_delay=repeat_delay, x=x, y=y, spacing=spacing, glyph=glyph, ) self.points = tuple(_point(point, size) for point in points) self.mode, self.trail = mode, trail
@property def finished(self) -> bool: return not self.points or super().finished @property def position(self) -> Cell | None: """Current board cell, or None when empty/finished/in a repeat gap.""" return self.points[self._index] if self.visible else None @property def _index(self) -> int: return min( len(self.points) - 1, floor(self.progress * (len(self.points) - 1) + 1e-9) ) def _paint(self, grid: GridViewport) -> None: first = 0 if self.mode == "trace" else max(0, self._index - self.trail) grid.path(self.points[first : self._index + 1], self.glyph)
[docs] class PulseEffect(_Accent): """Pulse a board marker through palette colors, then reveal lower layers. center is a board cell inside size. colors run dim-to-bright and back once per cycle; cycles is a positive integer within duration simulation seconds. This changes the marker's foreground, not terminal opacity. hold_duration holds its final dim color. See PathEffect for placement, repeat, resize, transparency and inert lifecycle contracts. Layer styling may override color. """
[docs] def __init__( self, center: Cell, *, size: tuple[int, int], duration: float = 1, cycles: int = 2, colors: tuple[int, ...] = (238, 244, 250, 255), glyph: str = "*", hold_duration: float = 0, repeat: bool = False, repeat_delay: float = 1, x: int | None = None, y: int | None = None, spacing: tuple[int, int] = (1, 1), ) -> None: if type(cycles) is not int: raise TypeError("cycles must be an integer") if cycles < 1: raise ValueError("cycles must be positive") if not colors: raise ValueError("colors must not be empty") self._styles = tuple(TextStyle(foreground=color) for color in colors) if any(color is None for color in colors): raise TypeError("colors must contain palette integers") super().__init__( size=size, duration=duration, hold_duration=hold_duration, repeat=repeat, repeat_delay=repeat_delay, x=x, y=y, spacing=spacing, glyph=glyph, ) self.center = _point(center, size) self.cycles, self.colors = cycles, tuple(colors)
def _paint(self, grid: GridViewport) -> None: phase = (self.progress * self.cycles) % 1 strength = 1 - abs(2 * phase - 1) index = min(len(self._styles) - 1, floor(strength * len(self._styles))) grid.put(*self.center, self._styles[index]._cell(self.glyph))
[docs] class RippleEffect(_Accent): """Expand a ring from a board cell, then reveal the underlying animation. radius and thickness are positive finite board-cell distances. metric chooses a circular, diamond, or square ring. The ring includes distances within half thickness of progress*radius; its initial frame includes the center. spacing=(2,1) often makes board circles look rounder in a terminal. Drawing visits only visible cell anchors, even for very large boards/radii. See PathEffect for timing, placement, repeat and inert lifecycle contracts. """
[docs] def __init__( self, center: Cell, *, size: tuple[int, int], radius: float = 5, duration: float = 1, thickness: float = 1, metric: RippleMetric = "euclidean", glyph: str = "*", hold_duration: float = 0, repeat: bool = False, repeat_delay: float = 1, x: int | None = None, y: int | None = None, spacing: tuple[int, int] = (1, 1), ) -> None: for name, value in (("radius", radius), ("thickness", thickness)): _seconds(value, name) if value == 0: raise ValueError(f"{name} must be positive") if metric not in ("euclidean", "manhattan", "chebyshev"): raise ValueError("metric must be 'euclidean', 'manhattan', or 'chebyshev'") super().__init__( size=size, duration=duration, hold_duration=hold_duration, repeat=repeat, repeat_delay=repeat_delay, x=x, y=y, spacing=spacing, glyph=glyph, ) self.center = _point(center, size) self.radius, self.thickness, self.metric = radius, thickness, metric
def _paint(self, grid: GridViewport) -> None: left, top, right, bottom = grid._clip() sx, sy = grid.spacing first_x, first_y = ( max(0, -((grid.x - left) // sx)), max(0, -((grid.y - top) // sy)), ) last_x = min(grid.columns, (right - 1 - grid.x) // sx + 1) last_y = min(grid.rows, (bottom - 1 - grid.y) // sy + 1) for y in range(first_y, last_y): for x in range(first_x, last_x): dx, dy = abs(x - self.center[0]), abs(y - self.center[1]) distance = ( hypot(dx, dy) if self.metric == "euclidean" else dx + dy if self.metric == "manhattan" else max(dx, dy) ) if abs(distance - self.progress * self.radius) <= self.thickness / 2: grid.put(x, y, self.glyph)