# Figment Mode Design Spec > Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control. ## Overview Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients. The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time. ## Goals - Display SVG figments as half-block terminal art overlaid on the running ticker - Three-phase animation: progressive reveal, strobing hold, dissolve - Random color from existing theme gradients (green, orange, purple) - Configurable interval and duration via C&C - Extensible input abstraction for physical device control (MQTT, serial, etc.) ## Out of Scope - Multi-figment simultaneous display (one at a time) - SVG animation support (static SVGs only; animation comes from the overlay phases) - Custom color palettes beyond existing themes - MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters) ## Architecture: Hybrid Plugin + Overlay The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management. **Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain. ### Component Diagram ``` +-------------------+ | FigmentTrigger | (Protocol) | - NtfyTrigger | (v1) | - MqttTrigger | (future) | - SerialTrigger | (future) +--------+----------+ | | FigmentCommand v +------------------+ +-----------------+ +----------------------+ | figment_render |<---| FigmentEffect |--->| render_figment_ | | .py | | (EffectPlugin) | | overlay() in | | | | | | layers.py | | SVG -> PIL -> | | Timer, state | | | | half-block cache | | machine, SVG | | ANSI cursor-position | | | | selection | | commands for overlay | +------------------+ +-----------------+ +----------------------+ | | get_figment_state() v +-------------------+ | scroll.py | +-------------------+ ``` ## Section 1: SVG Rasterization **File: `engine/figment_render.py`** Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts. ### Pipeline 1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files) 2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks) 3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible 4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither) 5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize ### Dependency `cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry. ### Key Function ```python def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]: """Convert SVG file to list of half-block terminal rows (uncolored).""" ``` ## Section 2: Figment Overlay Rendering **Integration point: `engine/layers.py`** New function following the `render_message_overlay()` pattern. ### FigmentState Dataclass Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer: ```python @dataclass class FigmentState: phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE progress: float # 0.0 to 1.0 within current phase rows: list[str] # rasterized half-block rows (uncolored) gradient: list[int] # 12-color ANSI 256 gradient from chosen theme center_row: int # top row for centering in viewport center_col: int # left column for centering in viewport ``` ### Function Signature ```python def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]: """Return ANSI cursor-positioning commands for the current figment frame.""" ``` ### Animation Phases (~4.5 seconds total) Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete. | Phase | Duration | Behavior | |-------|----------|----------| | **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. | | **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. | | **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. | ### Color A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages. ### Positioning Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works. ## Section 3: FigmentEffect (Effect Plugin) **File: `effects_plugins/figment.py`** An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention. ### Chain Exclusion `FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain. ### Responsibilities - **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment. - **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown. - **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0). - **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time. - **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration. ### State Machine ``` idle ──(timer fires or trigger received)──> reveal reveal ──(progress >= 1.0)──> hold hold ──(progress >= 1.0)──> dissolve dissolve ──(progress >= 1.0)──> idle ``` ### Interface The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via: ```python def get_figment_state(self, frame_number: int) -> FigmentState | None: """Tick the state machine and return current state, or None if idle.""" ``` This mirrors the `ntfy_poller.get_active_message()` pattern. ### Scroll Loop Access `scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry: ```python from effects_plugins.figment import FigmentEffect plugin = registry.get("figment") figment = plugin if isinstance(plugin, FigmentEffect) else None ``` This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`. ### EffectConfig - `enabled`: bool (default `False` — opt-in) - `intensity`: float — scales strobe frequency and reveal/dissolve speed - `params`: - `interval_secs`: 60 (time between figments) - `display_secs`: 4.5 (total animation duration) - `figment_dir`: "figments" (SVG source directory) Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`. ## Section 4: Input Abstraction (FigmentTrigger) **File: `engine/figment_trigger.py`** ### Protocol ```python class FigmentTrigger(Protocol): def poll(self) -> FigmentCommand | None: ... ``` ### FigmentCommand ```python class FigmentAction(Enum): TRIGGER = "trigger" SET_INTENSITY = "set_intensity" SET_INTERVAL = "set_interval" SET_COLOR = "set_color" STOP = "stop" @dataclass class FigmentCommand: action: FigmentAction value: float | str | None = None ``` Uses an enum for consistency with `EventType` in `engine/events.py`. ### Adapters | Adapter | Transport | Dependency | Status | |---------|-----------|------------|--------| | `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 | | `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future | | `SerialTrigger` | USB serial | `pyserial` (optional) | Future | **NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API. ### Integration The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone. ### EventBus Bridge A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync). ## Section 5: Scroll Loop Integration Minimal change to `engine/scroll.py`: ```python # In stream() setup (with safe import): try: from effects_plugins.figment import FigmentEffect _plugin = registry.get("figment") figment = _plugin if isinstance(_plugin, FigmentEffect) else None except ImportError: figment = None # In frame loop, after effects processing, before ntfy message overlay: if figment and figment.config.enabled: figment_state = figment.get_figment_state(frame_number) if figment_state is not None: figment_overlay = render_figment_overlay(figment_state, w, h) buf.extend(figment_overlay) ``` ### Overlay Priority Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means: - Ntfy messages always appear on top of figments (higher priority) - Existing glitch/noise effects run over the ticker underneath the figment Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering. ## Section 6: Error Handling | Scenario | Behavior | |----------|----------| | `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` | | `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state | | `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` | | Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning | | Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update | ## Section 7: File Summary ### New Files | File | Purpose | |------|---------| | `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase | | `engine/figment_render.py` | SVG to half-block rasterization pipeline | | `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter | | `figments/` | SVG source directory (ships with sample SVGs) | | `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer | | `tests/test_figment_render.py` | SVG rasterization, caching, edge cases | | `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter | | `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests | ### Modified Files | File | Change | |------|--------| | `engine/scroll.py` | Figment overlay integration (setup + per-frame block) | | `engine/layers.py` | Add `render_figment_overlay()` function | | `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass | | `pyproject.toml` | Add `cairosvg` as optional dependency | ## Testing Strategy - **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage - **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on` - **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation - **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests