""" Figment effect plugin — periodic SVG glyph overlay. Owns the figment lifecycle: timer, SVG selection, state machine. Delegates rendering to render_figment_overlay() in engine/layers.py. Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery convention in effects_plugins/__init__.py. NOT added to the EffectChain order — process() is a no-op. The overlay rendering is handled by scroll.py calling get_figment_state(). """ from __future__ import annotations import random from dataclasses import dataclass from enum import Enum, auto from pathlib import Path from engine import config from engine.effects.types import EffectConfig, EffectContext, EffectPlugin from engine.figment_render import rasterize_svg from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger from engine.themes import THEME_REGISTRY class FigmentPhase(Enum): REVEAL = auto() HOLD = auto() DISSOLVE = auto() @dataclass class FigmentState: phase: FigmentPhase progress: float rows: list[str] gradient: list[int] center_row: int center_col: int class FigmentEffect(EffectPlugin): name = "figment" config = EffectConfig( enabled=False, intensity=1.0, params={ "interval_secs": 60, "display_secs": 4.5, "figment_dir": "figments", }, ) def __init__( self, figment_dir: str | None = None, triggers: list[FigmentTrigger] | None = None, ): self.config = EffectConfig( enabled=False, intensity=1.0, params={ "interval_secs": 60, "display_secs": 4.5, "figment_dir": figment_dir or "figments", }, ) self._triggers = triggers or [] self._phase: FigmentPhase | None = None self._progress: float = 0.0 self._rows: list[str] = [] self._gradient: list[int] = [] self._center_row: int = 0 self._center_col: int = 0 self._timer: float = 0.0 self._last_svg: str | None = None self._svg_files: list[str] = [] self._scan_svgs() def _scan_svgs(self) -> None: figment_dir = Path(self.config.params["figment_dir"]) if figment_dir.is_dir(): self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg")) def process(self, buf: list[str], ctx: EffectContext) -> list[str]: return buf def configure(self, cfg: EffectConfig) -> None: # Preserve figment_dir if the new config doesn't supply one figment_dir = cfg.params.get( "figment_dir", self.config.params.get("figment_dir", "figments") ) self.config = cfg if "figment_dir" not in self.config.params: self.config.params["figment_dir"] = figment_dir self._scan_svgs() def trigger(self, w: int, h: int) -> None: """Manually trigger a figment display.""" if not self._svg_files: return # Pick a random SVG, avoid repeating candidates = [s for s in self._svg_files if s != self._last_svg] if not candidates: candidates = self._svg_files svg_path = random.choice(candidates) self._last_svg = svg_path # Rasterize try: self._rows = rasterize_svg(svg_path, w, h) except Exception: return # Pick random theme gradient theme_key = random.choice(list(THEME_REGISTRY.keys())) self._gradient = THEME_REGISTRY[theme_key].main_gradient # Center in viewport figment_h = len(self._rows) figment_w = max((len(r) for r in self._rows), default=0) self._center_row = max(0, (h - figment_h) // 2) self._center_col = max(0, (w - figment_w) // 2) # Start reveal phase self._phase = FigmentPhase.REVEAL self._progress = 0.0 def get_figment_state( self, frame_number: int, w: int, h: int ) -> FigmentState | None: """Tick the state machine and return current state, or None if idle.""" if not self.config.enabled: return None # Poll triggers for trig in self._triggers: cmd = trig.poll() if cmd is not None: self._handle_command(cmd, w, h) # Tick timer when idle if self._phase is None: self._timer += config.FRAME_DT interval = self.config.params.get("interval_secs", 60) if self._timer >= interval: self._timer = 0.0 self.trigger(w, h) # Tick animation — snapshot current phase/progress, then advance if self._phase is not None: # Capture the state at the start of this frame current_phase = self._phase current_progress = self._progress # Advance for next frame display_secs = self.config.params.get("display_secs", 4.5) phase_duration = display_secs / 3.0 self._progress += config.FRAME_DT / phase_duration if self._progress >= 1.0: self._progress = 0.0 if self._phase == FigmentPhase.REVEAL: self._phase = FigmentPhase.HOLD elif self._phase == FigmentPhase.HOLD: self._phase = FigmentPhase.DISSOLVE elif self._phase == FigmentPhase.DISSOLVE: self._phase = None return FigmentState( phase=current_phase, progress=current_progress, rows=self._rows, gradient=self._gradient, center_row=self._center_row, center_col=self._center_col, ) return None def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None: if cmd.action == FigmentAction.TRIGGER: self.trigger(w, h) elif cmd.action == FigmentAction.SET_INTENSITY and isinstance( cmd.value, (int, float) ): self.config.intensity = float(cmd.value) elif cmd.action == FigmentAction.SET_INTERVAL and isinstance( cmd.value, (int, float) ): self.config.params["interval_secs"] = float(cmd.value) elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str): if cmd.value in THEME_REGISTRY: self._gradient = THEME_REGISTRY[cmd.value].main_gradient elif cmd.action == FigmentAction.STOP: self._phase = None self._progress = 0.0