- Rename FigmentPlugin to FigmentEffect (discovery convention) - Define FigmentState dataclass and FigmentPhase enum - Clarify chain exclusion (no-op process, not in chain order) - Add isinstance() downcast for type-safe scroll.py access - Use FigmentAction enum instead of string literals - Add Error Handling section (missing deps, empty dir, resize) - Add Goals, Out of Scope sections - Split tests per module (test_figment_render, test_figment_trigger) - Add FIGMENT_TRIGGER to modified files (events.py) - Document timing formula (progress += FRAME_DT / phase_duration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
309 lines
14 KiB
Markdown
309 lines
14 KiB
Markdown
# 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
|