Hybrid plugin + overlay architecture for periodic SVG glyph display with theme-aware coloring and extensible input abstraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.1 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.
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.
Component Diagram
+-------------------+
| FigmentTrigger | (Protocol)
| - NtfyTrigger | (v1)
| - MqttTrigger | (future)
| - SerialTrigger | (future)
+--------+----------+
|
| FigmentCommand
v
+------------------+ +-----------------+ +----------------------+
| figment_render |<---| FigmentPlugin |--->| 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 |
| (5-line block) |
+-------------------+
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). Figment mode gracefully degrades if unavailable.
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.
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)
| 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. |
| Hold | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. |
| Dissolve | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. |
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: FigmentPlugin (Effect Plugin)
File: effects_plugins/figment.py
An EffectPlugin(ABC) subclass that owns the figment lifecycle.
Responsibilities
- Timer: Tracks elapsed frames. 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 is a no-op (returns buffer unchanged) since rendering is handled by the overlay. 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.
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
@dataclass
class FigmentCommand:
action: str # "trigger" | "set_intensity" | "set_interval" | "set_color" | "stop"
value: float | str | None = None
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 |
Integration
The FigmentPlugin 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
Any trigger can publish a FIGMENT_TRIGGER event to the EventBus, allowing other components to react (logging, multi-display sync).
Section 5: Scroll Loop Integration
Minimal change to engine/scroll.py — 5 lines following the ntfy overlay pattern:
# In stream() setup:
figment_plugin = registry.get("figment")
# In frame loop, after effects processing, before display.show():
if figment_plugin and figment_plugin.config.enabled:
figment_state = figment_plugin.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
Section 6: File Summary
New Files
| File | Purpose |
|---|---|
effects_plugins/figment.py |
FigmentPlugin — lifecycle, timer, state machine, SVG selection |
engine/figment_render.py |
SVG to half-block rasterization pipeline |
engine/figment_trigger.py |
FigmentTrigger protocol, FigmentCommand, NtfyTrigger adapter |
tests/test_figment.py |
Plugin lifecycle, state machine, timer, overlay rendering |
tests/fixtures/test.svg |
Minimal SVG for testing rasterization |
Modified Files
| File | Change |
|---|---|
engine/scroll.py |
5-line figment overlay integration |
engine/layers.py |
Add render_figment_overlay() function |
pyproject.toml |
Add cairosvg as optional dependency |
Testing Strategy
- Unit: State machine transitions, timer accuracy, SVG rasterization output, FigmentCommand parsing
- Integration: Plugin discovery, overlay rendering with mock terminal dimensions, C&C command handling
- Fixture: Minimal
test.svg(simple shape) for deterministic rasterization tests