Compare commits
10 Commits
451785df04
...
f40651b34d
| Author | SHA1 | Date | |
|---|---|---|---|
| f40651b34d | |||
| 4bd4fe5aac | |||
| ca1f2718f2 | |||
| 7a039cdfe0 | |||
| 733b4dd9ec | |||
| 14ed582454 | |||
| 550eca2b14 | |||
| 5f6cd295e1 | |||
| 706ee45e27 | |||
| 202dd6be9c |
96
README.md
96
README.md
@@ -2,7 +2,37 @@
|
|||||||
|
|
||||||
> *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).
|
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 --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 --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
|
||||||
@@ -50,6 +82,7 @@ All constants live in `engine/config.py`:
|
|||||||
| `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) |
|
| `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) |
|
||||||
|
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
| `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.
|
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
|
||||||
@@ -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 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; 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
|
||||||
|
|
||||||
@@ -131,12 +198,25 @@ engine/
|
|||||||
scroll.py stream() frame loop + message rendering
|
scroll.py stream() frame loop + message rendering
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
frame.py scroll step calculation, timing
|
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
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
events.py event types and definitions
|
events.py event types and definitions
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
emitters.py background emitters for ntfy and mic
|
emitters.py background emitters for ntfy and mic
|
||||||
types.py type definitions and dataclasses
|
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.
|
`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/).
|
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # minimal (no mic)
|
uv sync # minimal (no mic, no figment)
|
||||||
uv sync --all-extras # with mic support (sounddevice + numpy)
|
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
|
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/):
|
||||||
@@ -226,7 +310,7 @@ mise run run-firehose # uv run mainline.py --firehose
|
|||||||
|
|
||||||
### Testing
|
### 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
|
```bash
|
||||||
uv run pytest
|
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
|
- **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
|
||||||
|
|||||||
200
effects_plugins/figment.py
Normal file
200
effects_plugins/figment.py
Normal 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
|
||||||
@@ -413,6 +413,14 @@ 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)
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ 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"
|
||||||
|
|||||||
90
engine/figment_render.py
Normal file
90
engine/figment_render.py
Normal 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
36
engine/figment_trigger.py
Normal 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: ...
|
||||||
102
engine/layers.py
102
engine/layers.py
@@ -57,9 +57,7 @@ def render_message_overlay(
|
|||||||
else:
|
else:
|
||||||
msg_rows = msg_cache[1]
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
msg_rows = msg_gradient(
|
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||||
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)
|
||||||
@@ -258,3 +256,101 @@ 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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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,
|
||||||
@@ -53,6 +54,18 @@ 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
|
||||||
@@ -123,6 +136,13 @@ 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)
|
||||||
|
|
||||||
|
|||||||
151
tests/test_figment.py
Normal file
151
tests/test_figment.py
Normal 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
|
||||||
64
tests/test_figment_overlay.py
Normal file
64
tests/test_figment_overlay.py
Normal 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 == []
|
||||||
52
tests/test_figment_render.py
Normal file
52
tests/test_figment_render.py
Normal 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)
|
||||||
40
tests/test_figment_trigger.py
Normal file
40
tests/test_figment_trigger.py
Normal 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"
|
||||||
Reference in New Issue
Block a user