From 525af4bc46625c0d4e24b22cfb6bb814e540eef0 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Thu, 19 Mar 2026 01:10:05 -0700 Subject: [PATCH] feat(figment): add FigmentEffect plugin with state machine and timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core figment plugin: timer-driven SVG selection, REVEAL → HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for overlay rendering. process() is a deliberate no-op; scroll.py will call get_figment_state() instead. Co-Authored-By: Claude Sonnet 4.6 --- effects_plugins/figment.py | 190 +++++++++++++++++++++++++++++++++++++ tests/test_figment.py | 155 ++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 effects_plugins/figment.py create mode 100644 tests/test_figment.py diff --git a/effects_plugins/figment.py b/effects_plugins/figment.py new file mode 100644 index 0000000..af10002 --- /dev/null +++ b/effects_plugins/figment.py @@ -0,0 +1,190 @@ +""" +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 diff --git a/tests/test_figment.py b/tests/test_figment.py new file mode 100644 index 0000000..b17116a --- /dev/null +++ b/tests/test_figment.py @@ -0,0 +1,155 @@ +"""Tests for the FigmentEffect plugin.""" + +import os +from enum import Enum +from unittest.mock import patch + +import pytest + +from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState +from engine.effects.types import EffectConfig, EffectContext + + +FIXTURE_SVG = os.path.join( + os.path.dirname(__file__), "fixtures", "test.svg" +) +FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + + +class TestFigmentPhase: + def test_is_enum(self): + assert issubclass(FigmentPhase, Enum) + + def test_has_all_phases(self): + assert hasattr(FigmentPhase, "REVEAL") + assert hasattr(FigmentPhase, "HOLD") + assert hasattr(FigmentPhase, "DISSOLVE") + + +class TestFigmentState: + def test_creation(self): + state = FigmentState( + phase=FigmentPhase.REVEAL, + progress=0.5, + rows=["█▀▄", " █ "], + gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231], + center_row=5, + center_col=10, + ) + assert state.phase == FigmentPhase.REVEAL + assert state.progress == 0.5 + assert len(state.rows) == 2 + + +class TestFigmentEffectInit: + def test_name(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + assert effect.name == "figment" + + def test_default_config(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + assert effect.config.enabled is False + assert effect.config.intensity == 1.0 + assert effect.config.params["interval_secs"] == 60 + assert effect.config.params["display_secs"] == 4.5 + + def test_process_is_noop(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + buf = ["line1", "line2"] + ctx = EffectContext( + terminal_width=80, + terminal_height=24, + scroll_cam=0, + ticker_height=20, + ) + result = effect.process(buf, ctx) + assert result == buf + assert result is buf + + def test_configure(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + new_cfg = EffectConfig(enabled=True, intensity=0.5) + effect.configure(new_cfg) + assert effect.config.enabled is True + assert effect.config.intensity == 0.5 + + +class TestFigmentStateMachine: + def test_idle_initially(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + effect.config.enabled = True + state = effect.get_figment_state(0, 80, 24) + # Timer hasn't fired yet, should be None (idle) + assert state is None + + def test_trigger_starts_reveal(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + effect.config.enabled = True + effect.trigger(80, 24) + state = effect.get_figment_state(1, 80, 24) + assert state is not None + assert state.phase == FigmentPhase.REVEAL + + def test_full_cycle(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + effect.config.enabled = True + effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s + + effect.trigger(40, 20) + + # Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it) + # With display_secs=0.15, each phase is 0.05s = 1 frame + state = effect.get_figment_state(1, 40, 20) + assert state is not None + assert state.phase == FigmentPhase.REVEAL + + # Advance enough frames to get through all phases + last_state = None + for frame in range(2, 100): + state = effect.get_figment_state(frame, 40, 20) + if state is None: + break + last_state = state + + # Should have completed the full cycle back to idle + assert state is None + + def test_timer_fires_at_interval(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + effect.config.enabled = True + effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps + + # Frame 0: idle + state = effect.get_figment_state(0, 40, 20) + assert state is None + + # Advance past interval (0.1s = 2 frames) + state = effect.get_figment_state(1, 40, 20) + state = effect.get_figment_state(2, 40, 20) + state = effect.get_figment_state(3, 40, 20) + # Timer should have fired by now + assert state is not None + + +class TestFigmentEdgeCases: + def test_empty_figment_dir(self, tmp_path): + effect = FigmentEffect(figment_dir=str(tmp_path)) + effect.config.enabled = True + effect.trigger(40, 20) + state = effect.get_figment_state(1, 40, 20) + # No SVGs available — should stay idle + assert state is None + + def test_missing_figment_dir(self): + effect = FigmentEffect(figment_dir="/nonexistent/path") + effect.config.enabled = True + effect.trigger(40, 20) + state = effect.get_figment_state(1, 40, 20) + assert state is None + + def test_disabled_ignores_trigger(self): + effect = FigmentEffect(figment_dir=FIGMENTS_DIR) + effect.config.enabled = False + effect.trigger(80, 24) + state = effect.get_figment_state(1, 80, 24) + assert state is None