10 Commits

Author SHA1 Message Date
f40651b34d feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:11:24 -07:00
4bd4fe5aac docs: add table of contents to README 2026-03-19 11:44:00 -07:00
ca1f2718f2 docs: update README for figment mode
- Add figment mode to intro paragraph
- New Figment Mode section: enabling, assets, trigger protocol, deps
- Architecture table updated with engine/figment_render.py,
  figment_trigger.py, and effects_plugins/ directory breakdown
- Dev setup: separate uv sync --extras entries (mic vs figment)
- Testing section: mentions figment test coverage and Cairo skip
- Roadmap: adds figment follow-up items (CLI flag, intensity wiring,
  ntfy trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:34:18 -07:00
7a039cdfe0 test: add pytest.importorskip for cairosvg-dependent tests
Gracefully skips figment tests when system Cairo library is unavailable
instead of crashing with opaque OSError during test collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:23:35 -07:00
733b4dd9ec style: apply ruff lint fixes and formatting to figment modules
Fixes: unused imports, import sorting, unused variable, overly broad
exception type in test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:09:46 -07:00
14ed582454 feat(figment): integrate figment overlay into scroll loop
Wire render_figment_overlay() into stream() between the effects chain
and the ntfy message overlay, with optional cairosvg import guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:49:59 -07:00
550eca2b14 feat(figment): add render_figment_overlay() to layers.py
Implements phase-aware (REVEAL/HOLD/DISSOLVE) ANSI cursor-positioning overlay
renderer for figment glyphs, with deterministic shuffle seeding and gradient
coloring via _color_codes_to_ansi(). Includes 6 TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 08:43:48 -07:00
5f6cd295e1 feat(figment): add FigmentEffect plugin with state machine and timer
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 <noreply@anthropic.com>
2026-03-19 01:10:05 -07:00
706ee45e27 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 01:03:39 -07:00
202dd6be9c feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:59:29 -07:00
12 changed files with 873 additions and 28 deletions

View File

@@ -2,7 +2,37 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
---
## Contents
- [Using](#using)
- [Run](#run)
- [Config](#config)
- [Feeds](#feeds)
- [Fonts](#fonts)
- [Color Schemes](#color-schemes)
- [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode)
- [Internals](#internals)
- [How it works](#how-it-works)
- [Architecture](#architecture)
- [Extending](#extending)
- [NtfyPoller](#ntfypoller)
- [MicMonitor](#micmonitor)
- [Render pipeline](#render-pipeline)
- [Development](#development)
- [Setup](#setup)
- [Tasks](#tasks)
- [Testing](#testing)
- [Linting](#linting)
- [Roadmap](#roadmap)
- [Performance](#performance)
- [Graphics](#graphics)
- [Cyberpunk Vibes](#cyberpunk-vibes)
- [Extensibility](#extensibility)
---
@@ -15,6 +45,8 @@ python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode
python3 mainline.py --figment # enable periodic SVG glyph overlays
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file
@@ -50,6 +82,7 @@ All constants live in `engine/config.py`:
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
@@ -95,6 +128,39 @@ curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
### Figment Mode
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
**Enable it** with the `--figment` flag:
```bash
uv run mainline.py --figment # glyph every 60 seconds (default)
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
```
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
```python
from engine.figment_trigger import FigmentAction, FigmentCommand
class MyTrigger:
def poll(self):
if some_condition:
return FigmentCommand(action=FigmentAction.TRIGGER)
return None
```
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
```bash
uv sync --extra figment # adds cairosvg
```
---
## Internals
@@ -109,6 +175,7 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
### Architecture
@@ -131,12 +198,25 @@ engine/
scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay rendering
layers.py ticker zone, firehose, message + figment overlay rendering
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses
themes.py THEME_REGISTRY — gradient color definitions
effects_plugins/
__init__.py plugin discovery (ABC issubclass scan)
noise.py NoiseEffect — random character noise
glitch.py GlitchEffect — horizontal glitch bars
fade.py FadeEffect — edge fade zones
firehose.py FirehoseEffect — dense bottom ticker strip
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
figments/ SVG assets for figment mode
```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
@@ -204,11 +284,15 @@ See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server +
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # minimal (no mic)
uv sync --all-extras # with mic support (sounddevice + numpy)
uv sync # minimal (no mic, no figment)
uv sync --extras mic # with mic support (sounddevice + numpy)
uv sync --extra figment # with figment mode (cairosvg + system Cairo)
uv sync --all-extras # all optional features
uv sync --all-extras --group dev # full dev environment
```
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
### Tasks
With [mise](https://mise.jdx.dev/):
@@ -226,7 +310,7 @@ mise run run-firehose # uv run mainline.py --firehose
### Testing
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
```bash
uv run pytest
@@ -258,6 +342,8 @@ Pre-commit hooks run lint automatically via `hk`.
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
### Cyberpunk Vibes
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index

200
effects_plugins/figment.py Normal file
View File

@@ -0,0 +1,200 @@
"""
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

View File

@@ -413,6 +413,14 @@ def main():
if config.FIREHOSE:
boot_ln("Firehose", "ENGAGED", True)
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect # noqa: F401
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
except (ImportError, OSError):
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
time.sleep(0.4)
slow_print(" > STREAMING...\n")
time.sleep(0.2)

View File

@@ -196,6 +196,8 @@ MODE = (
else "news"
)
FIREHOSE = "--firehose" in sys.argv
FIGMENT = "--figment" in sys.argv
FIGMENT_INTERVAL = _arg_int("--figment-interval", 60) # seconds between appearances
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"

90
engine/figment_render.py Normal file
View File

@@ -0,0 +1,90 @@
"""
SVG to half-block terminal art rasterization.
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
"""
from __future__ import annotations
import os
import sys
from io import BytesIO
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
# /usr/local/lib (Intel), which are not in dyld's default search path.
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
break
import cairosvg
from PIL import Image
_cache: dict[tuple[str, int, int], list[str]] = {}
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
"""Convert SVG file to list of half-block terminal rows (uncolored).
Args:
svg_path: Path to SVG file.
width: Target terminal width in columns.
height: Target terminal height in rows.
Returns:
List of strings, one per terminal row, containing block characters.
"""
cache_key = (svg_path, width, height)
if cache_key in _cache:
return _cache[cache_key]
# SVG -> PNG in memory
png_bytes = cairosvg.svg2png(
url=svg_path,
output_width=width,
output_height=height * 2, # 2 pixel rows per terminal row
)
# PNG -> greyscale PIL image
# Composite RGBA onto white background so transparent areas become white (255)
# and drawn pixels retain their luminance values.
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = background.convert("L")
data = img.tobytes()
pix_w = width
pix_h = height * 2
# White (255) = empty space, dark (< threshold) = filled pixel
threshold = 128
# Half-block encode: walk pixel pairs
rows: list[str] = []
for y in range(0, pix_h, 2):
row: list[str] = []
for x in range(pix_w):
top = data[y * pix_w + x] < threshold
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
_cache[cache_key] = rows
return rows
def clear_cache() -> None:
"""Clear the rasterization cache (e.g., on terminal resize)."""
_cache.clear()

36
engine/figment_trigger.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Figment trigger protocol and command types.
Defines the extensible input abstraction for triggering figment displays
from any control surface (ntfy, MQTT, serial, etc.).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
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
class FigmentTrigger(Protocol):
"""Protocol for figment trigger sources.
Any input source (ntfy, MQTT, serial) can implement this
to trigger and control figment displays.
"""
def poll(self) -> FigmentCommand | None: ...

View File

@@ -57,9 +57,7 @@ def render_message_overlay(
else:
msg_rows = msg_cache[1]
msg_rows = msg_gradient(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
)
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -258,3 +256,101 @@ def get_effect_chain() -> EffectChain | None:
if _effect_chain is None:
init_effects()
return _effect_chain
def render_figment_overlay(
figment_state,
w: int,
h: int,
) -> list[str]:
"""Render figment overlay as ANSI cursor-positioning commands.
Args:
figment_state: FigmentState with phase, progress, rows, gradient, centering.
w: terminal width
h: terminal height
Returns:
List of ANSI strings to append to display buffer.
"""
from engine.render import _color_codes_to_ansi
rows = figment_state.rows
if not rows:
return []
phase = figment_state.phase
progress = figment_state.progress
gradient = figment_state.gradient
center_row = figment_state.center_row
center_col = figment_state.center_col
cols = _color_codes_to_ansi(gradient)
# Build a list of non-space cell positions
cell_positions = []
for r_idx, row in enumerate(rows):
for c_idx, ch in enumerate(row):
if ch != " ":
cell_positions.append((r_idx, c_idx))
n_cells = len(cell_positions)
if n_cells == 0:
return []
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
shuffled = list(cell_positions)
rng.shuffle(shuffled)
# Phase-dependent visibility
from effects_plugins.figment import FigmentPhase
if phase == FigmentPhase.REVEAL:
visible_count = int(n_cells * progress)
visible = set(shuffled[:visible_count])
elif phase == FigmentPhase.HOLD:
visible = set(cell_positions)
# Strobe: dim some cells periodically
if int(progress * 20) % 3 == 0:
dim_count = int(n_cells * 0.3)
visible -= set(shuffled[:dim_count])
elif phase == FigmentPhase.DISSOLVE:
remaining_count = int(n_cells * (1.0 - progress))
visible = set(shuffled[:remaining_count])
else:
visible = set(cell_positions)
# Build overlay commands
overlay: list[str] = []
n_cols = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
for r_idx, row in enumerate(rows):
scr_row = center_row + r_idx + 1 # 1-indexed
if scr_row < 1 or scr_row > h:
continue
line_buf: list[str] = []
has_content = False
for c_idx, ch in enumerate(row):
scr_col = center_col + c_idx + 1
if scr_col < 1 or scr_col > w:
continue
if ch != " " and (r_idx, c_idx) in visible:
# Apply gradient color
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
line_buf.append(f"{cols[idx]}{ch}{RST}")
has_content = True
else:
line_buf.append(" ")
if has_content:
line_str = "".join(line_buf).rstrip()
if line_str.strip():
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
return overlay

View File

@@ -18,6 +18,7 @@ from engine.frame import calculate_scroll_step
from engine.layers import (
apply_glitch,
process_effects,
render_figment_overlay,
render_firehose,
render_message_overlay,
render_ticker_zone,
@@ -53,6 +54,18 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
msg_cache = (None, None)
frame_number = 0
# Figment overlay (optional — requires cairosvg)
figment = None
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect
figment = FigmentEffect()
figment.config.enabled = True
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
except (ImportError, OSError):
pass
while True:
if queued >= config.HEADLINE_LIMIT and not active:
break
@@ -123,6 +136,13 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
firehose_buf = render_firehose(items, w, fh, h)
buf.extend(firehose_buf)
# Figment overlay (between effects and ntfy message)
if figment and figment.config.enabled:
figment_state = figment.get_figment_state(frame_number, w, h)
if figment_state is not None:
figment_buf = render_figment_overlay(figment_state, w, h)
buf.extend(figment_buf)
if msg_overlay:
buf.extend(msg_overlay)

151
tests/test_figment.py Normal file
View File

@@ -0,0 +1,151 @@
"""Tests for the FigmentEffect plugin."""
import os
from enum import Enum
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
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
for frame in range(2, 100):
state = effect.get_figment_state(frame, 40, 20)
if state is None:
break
# 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

View File

@@ -0,0 +1,64 @@
"""Tests for render_figment_overlay in engine.layers."""
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from effects_plugins.figment import FigmentPhase, FigmentState
from engine.layers import render_figment_overlay
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
return FigmentState(
phase=phase,
progress=progress,
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
center_row=10,
center_col=37,
)
class TestRenderFigmentOverlay:
def test_returns_list_of_strings(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
assert isinstance(result, list)
assert all(isinstance(s, str) for s in result)
def test_contains_ansi_positioning(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
# Should contain cursor positioning escape codes
assert any("\033[" in s for s in result)
def test_reveal_phase_partial(self):
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
result = render_figment_overlay(state, 80, 24)
# At progress 0.0, very few cells should be visible
# Result should still be a valid list
assert isinstance(result, list)
def test_hold_phase_full(self):
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
result = render_figment_overlay(state, 80, 24)
# During hold, content should be present
assert len(result) > 0
def test_dissolve_phase(self):
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
result = render_figment_overlay(state, 80, 24)
# At high dissolve progress, most cells are gone
assert isinstance(result, list)
def test_empty_rows(self):
state = FigmentState(
phase=FigmentPhase.HOLD,
progress=0.5,
rows=[],
gradient=[46] * 12,
center_row=0,
center_col=0,
)
result = render_figment_overlay(state, 80, 24)
assert result == []

View File

@@ -0,0 +1,52 @@
"""Tests for engine.figment_render module."""
import os
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from engine.figment_render import rasterize_svg
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
class TestRasterizeSvg:
def test_returns_list_of_strings(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert isinstance(rows, list)
assert all(isinstance(r, str) for r in rows)
def test_output_height_matches_terminal_height(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert len(rows) == 20
def test_output_contains_block_characters(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
all_chars = "".join(rows)
block_chars = {"", "", ""}
assert any(ch in all_chars for ch in block_chars)
def test_different_sizes_produce_different_output(self):
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
assert len(rows_small) == 10
assert len(rows_large) == 40
def test_nonexistent_file_raises(self):
import pytest
with pytest.raises((FileNotFoundError, OSError)):
rasterize_svg("/nonexistent/file.svg", 40, 20)
class TestRasterizeCache:
def test_cache_returns_same_result(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
assert rows1 == rows2
def test_cache_invalidated_by_size_change(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
assert len(rows1) != len(rows2)

View File

@@ -0,0 +1,40 @@
"""Tests for engine.figment_trigger module."""
from enum import Enum
from engine.figment_trigger import FigmentAction, FigmentCommand
class TestFigmentAction:
def test_is_enum(self):
assert issubclass(FigmentAction, Enum)
def test_has_trigger(self):
assert FigmentAction.TRIGGER.value == "trigger"
def test_has_set_intensity(self):
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
def test_has_set_interval(self):
assert FigmentAction.SET_INTERVAL.value == "set_interval"
def test_has_set_color(self):
assert FigmentAction.SET_COLOR.value == "set_color"
def test_has_stop(self):
assert FigmentAction.STOP.value == "stop"
class TestFigmentCommand:
def test_trigger_command(self):
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
assert cmd.action == FigmentAction.TRIGGER
assert cmd.value is None
def test_set_intensity_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
assert cmd.value == 0.8
def test_set_color_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
assert cmd.value == "orange"