6 Commits

Author SHA1 Message Date
451785df04 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:55:48 -07:00
473d12c756 build: add cairosvg optional dependency for figment mode
Also adds DYLD_LIBRARY_PATH to mise.toml for macOS Cairo discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:54:54 -07:00
5c038b1e2f Merge branch 'main' into feat/figment 2026-03-19 00:51:45 -07:00
dde5d77f43 docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:50:12 -07:00
ad45347a75 docs: address spec review feedback for figment mode
- 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>
2026-03-19 00:36:19 -07:00
064eaaee3d docs: add figment mode design spec
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>
2026-03-19 00:32:52 -07:00
12 changed files with 130 additions and 966 deletions

331
README.md
View File

@@ -2,34 +2,7 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.* > *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). **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. 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).
---
## Contents
- [Using](#using)
- [Run](#run)
- [Config](#config)
- [Display Modes](#display-modes)
- [Feeds](#feeds)
- [Fonts](#fonts)
- [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode)
- [Command & Control](#command--control-cc)
- [Internals](#internals)
- [How it works](#how-it-works)
- [Architecture](#architecture)
- [Development](#development)
- [Setup](#setup)
- [Tasks](#tasks)
- [Testing](#testing)
- [Linting](#linting)
- [Roadmap](#roadmap)
- [Performance](#performance)
- [Graphics](#graphics)
- [Cyberpunk Vibes](#cyberpunk-vibes)
- [Extensibility](#extensibility)
--- ---
@@ -42,10 +15,6 @@ python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode 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 --display websocket # web browser display only
python3 mainline.py --display both # terminal + web browser
python3 mainline.py --refresh # force re-fetch (bypass cache) python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file python3 mainline.py --font-file path.otf # use a specific font file
@@ -59,20 +28,7 @@ Or with uv:
uv run mainline.py uv run mainline.py
``` ```
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support. First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
### Command & Control (C&C)
Control mainline remotely using `cmdline.py`:
```bash
uv run cmdline.py # Interactive TUI
uv run cmdline.py /effects list # List all effects
uv run cmdline.py /effects stats # Show performance stats
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
```
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
### Config ### Config
@@ -83,33 +39,20 @@ All constants live in `engine/config.py`:
| `HEADLINE_LIMIT` | `1000` | Total headlines per session | | `HEADLINE_LIMIT` | `1000` | Total headlines per session |
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) | | `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike | | `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files | | `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
| `FONT_PATH` | first file in `FONT_DIR` | Active display font | | `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
| `FONT_PICKER` | `True` | Show interactive font picker at boot | | `FONT_INDEX` | `0` | Face index within a font collection file |
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
| `FONT_SZ` | `60` | Font render size (affects block density) | | `FONT_SZ` | `60` | Font render size (affects block density) |
| `RENDER_H` | `8` | Terminal rows per headline line | | `RENDER_H` | `8` | Terminal rows per headline line |
| `SSAA` | `4` | Super-sampling factor | | `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
| `SCROLL_DUR` | `5.625` | Seconds per headline | | `SCROLL_DUR` | `5.625` | Seconds per headline |
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) | | `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) | | `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed | | `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) | | `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
### Display Modes
Mainline supports multiple display backends:
- **Terminal** (`--display terminal`): ANSI terminal output (default)
- **WebSocket** (`--display websocket`): Stream to web browser clients
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
- **Both** (`--display both`): Terminal + WebSocket simultaneously
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
### Feeds ### Feeds
@@ -119,15 +62,30 @@ WebSocket mode serves a web client at http://localhost:8766 with ANSI color supp
### Fonts ### Fonts
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview. A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`. To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
### Color Schemes
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
On startup, an interactive picker presents all available color schemes:
```
[1] Verdant Green (white-hot → deep green)
[2] Molten Orange (white-hot → deep orange)
[3] Violet Purple (white-hot → deep purple)
```
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
### ntfy.sh ### ntfy.sh
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen. Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
To push a message: To push a message:
@@ -137,39 +95,6 @@ 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. 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 ## Internals
@@ -183,64 +108,93 @@ uv sync --extra figment # adds cairosvg
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts - Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame - 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 - 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 for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired - 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 ### Architecture
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
``` ```
engine/ engine/
__init__.py package marker __init__.py package marker
app.py main(), font picker TUI, boot sequence, C&C poller app.py main(), font picker TUI, boot sequence, signal handler
config.py constants, CLI flags, glyph tables config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient) render.py OTF → half-block pipeline (SSAA, gradient)
effects/ plugin architecture for visual effects effects.py noise, glitch_bar, fade, firehose
types.py EffectPlugin ABC, EffectConfig, EffectContext fetch.py RSS/Gutenberg fetching + cache load/save
registry.py effect registration and lookup ntfy.py NtfyPoller — standalone, zero internal deps
chain.py effect pipeline chaining mic.py MicMonitor — standalone, graceful fallback
controller.py handles /effects commands scroll.py stream() frame loop + message rendering
performance.py performance monitoring viewport.py terminal dimension tracking (tw/th)
legacy.py legacy functional effects frame.py scroll step calculation, timing
fetch.py RSS/Gutenberg fetching + cache load/save layers.py ticker zone, firehose, message overlay rendering
ntfy.py NtfyPoller — standalone, zero internal deps eventbus.py thread-safe event publishing for decoupled communication
mic.py MicMonitor — standalone, graceful fallback events.py event types and definitions
scroll.py stream() frame loop + message rendering controller.py coordinates ntfy/mic monitoring and event publishing
viewport.py terminal dimension tracking (tw/th) emitters.py background emitters for ntfy and mic
frame.py scroll step calculation, timing types.py type definitions and dataclasses
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
display/ Display backend system
__init__.py DisplayRegistry, get_monitor
backends/
terminal.py ANSI terminal display
websocket.py WebSocket server for browser clients
sixel.py Sixel graphics (pure Python)
null.py headless display for testing
multi.py forwards to multiple displays
benchmark.py performance benchmarking tool
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.
---
## Extending
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
### NtfyPoller
```python
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
poller.start()
# in your render loop:
msg = poller.get_active_message() # → (title, body, timestamp) or None
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
```
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
### MicMonitor
```python
from engine.mic import MicMonitor
mic = MicMonitor(threshold_db=50)
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
if result:
excess = mic.excess # dB above threshold, clamped to 0
db = mic.db # raw RMS dB level
```
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
### Render pipeline
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
```python
# planned — serve.py does not yet exist
from engine.render import render_line, big_wrap
from engine.fetch import fetch_all
headlines = fetch_all()
for h in headlines:
rows = big_wrap(h.text, font, width=800) # list of half-block rows
# threshold to 1-bit, pack bytes, serve over HTTP
```
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
--- ---
## Development ## Development
@@ -250,64 +204,35 @@ figments/ SVG assets for figment mode
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/). Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash ```bash
uv sync # minimal (no mic, no figment) uv sync # minimal (no mic)
uv sync --extra mic # with mic support (sounddevice + numpy) uv sync --all-extras # 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 uv sync --all-extras --group dev # full dev environment
``` ```
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
### Tasks ### Tasks
With [mise](https://mise.jdx.dev/): With [mise](https://mise.jdx.dev/):
```bash ```bash
mise run test # run test suite mise run test # run test suite
mise run test-cov # run with coverage report mise run test-cov # run with coverage report
mise run lint # ruff check
mise run lint # ruff check mise run lint-fix # ruff check --fix
mise run lint-fix # ruff check --fix mise run format # ruff format
mise run format # ruff format mise run run # uv run mainline.py
mise run run-poetry # uv run mainline.py --poetry
mise run run # terminal display mise run run-firehose # uv run mainline.py --firehose
mise run run-websocket # web display only
mise run run-sixel # sixel graphics
mise run run-both # terminal + web
mise run run-client # both + open browser
mise run cmd # C&C command interface
mise run cmd-stats # watch effects stats
mise run benchmark # run performance benchmarks
mise run benchmark-json # save as JSON
mise run topics-init # initialize ntfy topics
``` ```
### Testing ### Testing
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. Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
```bash ```bash
uv run pytest uv run pytest
uv run pytest --cov=engine --cov-report=term-missing uv run pytest --cov=engine --cov-report=term-missing
# Run with mise
mise run test
mise run test-cov
# Run performance benchmarks
mise run benchmark
mise run benchmark-json
# Run benchmark hook mode (for CI)
uv run python -m engine.benchmark --hook
``` ```
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
### Linting ### Linting
```bash ```bash
@@ -322,19 +247,17 @@ Pre-commit hooks run lint automatically via `hk`.
## Roadmap ## Roadmap
### Performance ### Performance
- Concurrent feed fetching with ThreadPoolExecutor - **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
- Background feed refresh daemon - **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
- Translation pre-fetch during boot - **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
### Graphics ### Graphics
- Matrix rain katakana underlay - **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
- CRT scanline simulation - **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
- Sixel/iTerm2 inline images - **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
- Parallax secondary column - **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
### Cyberpunk Vibes ### 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) - **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 - **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 - **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
@@ -348,4 +271,4 @@ Pre-commit hooks run lint automatically via `hk`.
--- ---
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.* *macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*

View File

@@ -1,200 +0,0 @@
"""
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,14 +413,6 @@ def main():
if config.FIREHOSE: if config.FIREHOSE:
boot_ln("Firehose", "ENGAGED", True) 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) time.sleep(0.4)
slow_print(" > STREAMING...\n") slow_print(" > STREAMING...\n")
time.sleep(0.2) time.sleep(0.2)

View File

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

View File

@@ -1,90 +0,0 @@
"""
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()

View File

@@ -1,36 +0,0 @@
"""
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,7 +57,9 @@ def render_message_overlay(
else: else:
msg_rows = msg_cache[1] 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) elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s) remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -256,101 +258,3 @@ def get_effect_chain() -> EffectChain | None:
if _effect_chain is None: if _effect_chain is None:
init_effects() init_effects()
return _effect_chain 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,7 +18,6 @@ from engine.frame import calculate_scroll_step
from engine.layers import ( from engine.layers import (
apply_glitch, apply_glitch,
process_effects, process_effects,
render_figment_overlay,
render_firehose, render_firehose,
render_message_overlay, render_message_overlay,
render_ticker_zone, render_ticker_zone,
@@ -54,18 +53,6 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
msg_cache = (None, None) msg_cache = (None, None)
frame_number = 0 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: while True:
if queued >= config.HEADLINE_LIMIT and not active: if queued >= config.HEADLINE_LIMIT and not active:
break break
@@ -136,13 +123,6 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
firehose_buf = render_firehose(items, w, fh, h) firehose_buf = render_firehose(items, w, fh, h)
buf.extend(firehose_buf) 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: if msg_overlay:
buf.extend(msg_overlay) buf.extend(msg_overlay)

View File

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

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

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

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