- 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>
14 KiB
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
- Load:
cairosvg.svg2png()converts SVG to PNG bytes in memory (no temp files) - Resize: PIL scales to fit terminal — width =
tw(), height =th() * 2pixels (each terminal row encodes 2 pixel rows via half-blocks) - Threshold: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
- Half-block encode: Walk pixel pairs top-to-bottom. For each 2-row pair, emit
█(both lit),▀(top only),▄(bottom only), or space (neither) - 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
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:
@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
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_DTaccumulation. 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:
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:
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 (defaultFalse— opt-in)intensity: float — scales strobe frequency and reveal/dissolve speedparams: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
class FigmentTrigger(Protocol):
def poll(self) -> FigmentCommand | None: ...
FigmentCommand
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:
# 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
FigmentEffectis found bydiscover_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