Compare commits
18 Commits
ac9b47f668
...
feat/figme
| Author | SHA1 | Date | |
|---|---|---|---|
| a25b80d4a6 | |||
| 3a1aa975d1 | |||
| d5e5f39404 | |||
| 2bfd3a01da | |||
| 4cf316c280 | |||
| 79d271c42b | |||
| 525af4bc46 | |||
| 085f150cb0 | |||
| 0b6e2fae74 | |||
| 6864ad84c6 | |||
| acb42ea140 | |||
| 7014a9d5cd | |||
| 2cc8dbfc02 | |||
| f1d5162488 | |||
| 9f61226779 | |||
| 9415e18679 | |||
| 0819f8d160 | |||
| edd1416407 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
287
README.md
287
README.md
@@ -2,7 +2,34 @@
|
|||||||
|
|
||||||
> *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)
|
||||||
|
- [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,6 +42,10 @@ 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
|
||||||
@@ -28,7 +59,20 @@ Or with uv:
|
|||||||
uv run mainline.py
|
uv run mainline.py
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
@@ -39,20 +83,33 @@ 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 (overridden by picker or `--font-file`) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||||
| `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 (render at 4× then downsample) |
|
| `SSAA` | `4` | Super-sampling factor |
|
||||||
| `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) |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
||||||
| `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
|
||||||
|
|
||||||
@@ -62,30 +119,15 @@ All constants live in `engine/config.py`:
|
|||||||
|
|
||||||
### Fonts
|
### Fonts
|
||||||
|
|
||||||
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.
|
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
||||||
|
|
||||||
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.
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
||||||
|
|
||||||
### 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 for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -95,6 +137,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
|
||||||
@@ -108,93 +183,64 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
|||||||
- 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; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
- 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
|
||||||
|
- 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, signal handler
|
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||||
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.py noise, glitch_bar, fade, firehose
|
effects/ plugin architecture for visual effects
|
||||||
|
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
||||||
|
registry.py effect registration and lookup
|
||||||
|
chain.py effect pipeline chaining
|
||||||
|
controller.py handles /effects commands
|
||||||
|
performance.py performance monitoring
|
||||||
|
legacy.py legacy functional effects
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
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
|
||||||
|
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
|
||||||
@@ -204,11 +250,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 --extra 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/):
|
||||||
@@ -216,23 +266,48 @@ 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`, 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
|
||||||
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
|
||||||
@@ -247,17 +322,19 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
- Concurrent feed fetching with ThreadPoolExecutor
|
||||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
- Background feed refresh daemon
|
||||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
- Translation pre-fetch during boot
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
- Matrix rain katakana underlay
|
||||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
- CRT scanline simulation
|
||||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
- Sixel/iTerm2 inline images
|
||||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
- Parallax secondary column
|
||||||
|
|
||||||
### 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
|
||||||
@@ -271,4 +348,4 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||||
|
|||||||
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Figment Mode Design Spec
|
||||||
|
|
||||||
|
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
||||||
|
|
||||||
|
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
||||||
|
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
||||||
|
- Random color from existing theme gradients (green, orange, purple)
|
||||||
|
- Configurable interval and duration via C&C
|
||||||
|
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Multi-figment simultaneous display (one at a time)
|
||||||
|
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
||||||
|
- Custom color palettes beyond existing themes
|
||||||
|
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
||||||
|
|
||||||
|
## Architecture: Hybrid Plugin + Overlay
|
||||||
|
|
||||||
|
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
||||||
|
|
||||||
|
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
||||||
|
|
||||||
|
### Component Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------+
|
||||||
|
| FigmentTrigger | (Protocol)
|
||||||
|
| - NtfyTrigger | (v1)
|
||||||
|
| - MqttTrigger | (future)
|
||||||
|
| - SerialTrigger | (future)
|
||||||
|
+--------+----------+
|
||||||
|
|
|
||||||
|
| FigmentCommand
|
||||||
|
v
|
||||||
|
+------------------+ +-----------------+ +----------------------+
|
||||||
|
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
||||||
|
| .py | | (EffectPlugin) | | overlay() in |
|
||||||
|
| | | | | layers.py |
|
||||||
|
| SVG -> PIL -> | | Timer, state | | |
|
||||||
|
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
||||||
|
| | | selection | | commands for overlay |
|
||||||
|
+------------------+ +-----------------+ +----------------------+
|
||||||
|
|
|
||||||
|
| get_figment_state()
|
||||||
|
v
|
||||||
|
+-------------------+
|
||||||
|
| scroll.py |
|
||||||
|
+-------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Section 1: SVG Rasterization
|
||||||
|
|
||||||
|
**File: `engine/figment_render.py`**
|
||||||
|
|
||||||
|
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
||||||
|
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
||||||
|
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
||||||
|
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
||||||
|
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
||||||
|
|
||||||
|
### Dependency
|
||||||
|
|
||||||
|
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
||||||
|
|
||||||
|
### Key Function
|
||||||
|
|
||||||
|
```python
|
||||||
|
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||||
|
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Section 2: Figment Overlay Rendering
|
||||||
|
|
||||||
|
**Integration point: `engine/layers.py`**
|
||||||
|
|
||||||
|
New function following the `render_message_overlay()` pattern.
|
||||||
|
|
||||||
|
### FigmentState Dataclass
|
||||||
|
|
||||||
|
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class FigmentState:
|
||||||
|
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
||||||
|
progress: float # 0.0 to 1.0 within current phase
|
||||||
|
rows: list[str] # rasterized half-block rows (uncolored)
|
||||||
|
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
||||||
|
center_row: int # top row for centering in viewport
|
||||||
|
center_col: int # left column for centering in viewport
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
||||||
|
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Phases (~4.5 seconds total)
|
||||||
|
|
||||||
|
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
||||||
|
|
||||||
|
| Phase | Duration | Behavior |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
||||||
|
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
||||||
|
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
||||||
|
|
||||||
|
### Positioning
|
||||||
|
|
||||||
|
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
||||||
|
|
||||||
|
## Section 3: FigmentEffect (Effect Plugin)
|
||||||
|
|
||||||
|
**File: `effects_plugins/figment.py`**
|
||||||
|
|
||||||
|
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
||||||
|
|
||||||
|
### Chain Exclusion
|
||||||
|
|
||||||
|
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
||||||
|
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
||||||
|
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
||||||
|
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
||||||
|
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
||||||
|
|
||||||
|
### State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
idle ──(timer fires or trigger received)──> reveal
|
||||||
|
reveal ──(progress >= 1.0)──> hold
|
||||||
|
hold ──(progress >= 1.0)──> dissolve
|
||||||
|
dissolve ──(progress >= 1.0)──> idle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
||||||
|
"""Tick the state machine and return current state, or None if idle."""
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
||||||
|
|
||||||
|
### Scroll Loop Access
|
||||||
|
|
||||||
|
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
|
||||||
|
plugin = registry.get("figment")
|
||||||
|
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
||||||
|
|
||||||
|
### EffectConfig
|
||||||
|
|
||||||
|
- `enabled`: bool (default `False` — opt-in)
|
||||||
|
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
||||||
|
- `params`:
|
||||||
|
- `interval_secs`: 60 (time between figments)
|
||||||
|
- `display_secs`: 4.5 (total animation duration)
|
||||||
|
- `figment_dir`: "figments" (SVG source directory)
|
||||||
|
|
||||||
|
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
||||||
|
|
||||||
|
## Section 4: Input Abstraction (FigmentTrigger)
|
||||||
|
|
||||||
|
**File: `engine/figment_trigger.py`**
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FigmentTrigger(Protocol):
|
||||||
|
def poll(self) -> FigmentCommand | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### FigmentCommand
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FigmentAction(Enum):
|
||||||
|
TRIGGER = "trigger"
|
||||||
|
SET_INTENSITY = "set_intensity"
|
||||||
|
SET_INTERVAL = "set_interval"
|
||||||
|
SET_COLOR = "set_color"
|
||||||
|
STOP = "stop"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentCommand:
|
||||||
|
action: FigmentAction
|
||||||
|
value: float | str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
||||||
|
|
||||||
|
### Adapters
|
||||||
|
|
||||||
|
| Adapter | Transport | Dependency | Status |
|
||||||
|
|---------|-----------|------------|--------|
|
||||||
|
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
||||||
|
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
||||||
|
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
||||||
|
|
||||||
|
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
||||||
|
|
||||||
|
### EventBus Bridge
|
||||||
|
|
||||||
|
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
||||||
|
|
||||||
|
## Section 5: Scroll Loop Integration
|
||||||
|
|
||||||
|
Minimal change to `engine/scroll.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In stream() setup (with safe import):
|
||||||
|
try:
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
_plugin = registry.get("figment")
|
||||||
|
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
||||||
|
except ImportError:
|
||||||
|
figment = None
|
||||||
|
|
||||||
|
# In frame loop, after effects processing, before ntfy message overlay:
|
||||||
|
if figment and figment.config.enabled:
|
||||||
|
figment_state = figment.get_figment_state(frame_number)
|
||||||
|
if figment_state is not None:
|
||||||
|
figment_overlay = render_figment_overlay(figment_state, w, h)
|
||||||
|
buf.extend(figment_overlay)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay Priority
|
||||||
|
|
||||||
|
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
||||||
|
- Ntfy messages always appear on top of figments (higher priority)
|
||||||
|
- Existing glitch/noise effects run over the ticker underneath the figment
|
||||||
|
|
||||||
|
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
||||||
|
|
||||||
|
## Section 6: Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
||||||
|
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
||||||
|
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
||||||
|
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
||||||
|
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
||||||
|
|
||||||
|
## Section 7: File Summary
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
||||||
|
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
||||||
|
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
||||||
|
| `figments/` | SVG source directory (ships with sample SVGs) |
|
||||||
|
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
||||||
|
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
||||||
|
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
||||||
|
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
||||||
|
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
||||||
|
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
||||||
|
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
||||||
|
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
||||||
|
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
||||||
|
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
||||||
@@ -5,6 +5,7 @@ PLUGIN_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_plugins():
|
def discover_plugins():
|
||||||
from engine.effects.registry import get_registry
|
from engine.effects.registry import get_registry
|
||||||
|
from engine.effects.types import EffectPlugin
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
imported = {}
|
imported = {}
|
||||||
@@ -22,8 +23,8 @@ def discover_plugins():
|
|||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
isinstance(attr, type)
|
isinstance(attr, type)
|
||||||
and hasattr(attr, "name")
|
and issubclass(attr, EffectPlugin)
|
||||||
and hasattr(attr, "process")
|
and attr is not EffectPlugin
|
||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import random
|
|||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
class FadeEffect:
|
class FadeEffect(EffectPlugin):
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class FirehoseEffect:
|
class FirehoseEffect(EffectPlugin):
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
class GlitchEffect:
|
class GlitchEffect(EffectPlugin):
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class NoiseEffect:
|
class NoiseEffect(EffectPlugin):
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -8,10 +9,11 @@ class EffectContext:
|
|||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
ticker_height: int
|
ticker_height: int
|
||||||
mic_excess: float
|
camera_x: int = 0
|
||||||
grad_offset: float
|
mic_excess: float = 0.0
|
||||||
frame_number: int
|
grad_offset: float = 0.0
|
||||||
has_message: bool
|
frame_number: int = 0
|
||||||
|
has_message: bool = False
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,15 +24,42 @@ class EffectConfig:
|
|||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin:
|
class EffectPlugin(ABC):
|
||||||
name: str
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
@abstractmethod
|
||||||
raise NotImplementedError
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
@abstractmethod
|
||||||
raise NotImplementedError
|
def configure(self, config: EffectConfig) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def create_effect_context(
|
||||||
|
terminal_width: int = 80,
|
||||||
|
terminal_height: int = 24,
|
||||||
|
scroll_cam: int = 0,
|
||||||
|
ticker_height: int = 0,
|
||||||
|
camera_x: int = 0,
|
||||||
|
mic_excess: float = 0.0,
|
||||||
|
grad_offset: float = 0.0,
|
||||||
|
frame_number: int = 0,
|
||||||
|
has_message: bool = False,
|
||||||
|
items: list | None = None,
|
||||||
|
) -> EffectContext:
|
||||||
|
"""Factory function to create EffectContext with sensible defaults."""
|
||||||
|
return EffectContext(
|
||||||
|
terminal_width=terminal_width,
|
||||||
|
terminal_height=terminal_height,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_height,
|
||||||
|
camera_x=camera_x,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class EventType(Enum):
|
|||||||
NTFY_MESSAGE = auto()
|
NTFY_MESSAGE = auto()
|
||||||
STREAM_START = auto()
|
STREAM_START = auto()
|
||||||
STREAM_END = auto()
|
STREAM_END = auto()
|
||||||
|
FIGMENT_TRIGGER = auto()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -65,3 +66,12 @@ class StreamEvent:
|
|||||||
event_type: EventType
|
event_type: EventType
|
||||||
headline_count: int = 0
|
headline_count: int = 0
|
||||||
timestamp: datetime | None = None
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentTriggerEvent:
|
||||||
|
"""Event emitted when a figment is triggered."""
|
||||||
|
|
||||||
|
action: str
|
||||||
|
value: float | str | None = None
|
||||||
|
timestamp: datetime | None = None
|
||||||
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 577.362 577.362"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_21_">
|
||||||
|
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
|
||||||
|
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
|
||||||
|
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
|
||||||
|
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
|
||||||
|
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
|
||||||
|
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
|
||||||
|
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
|
||||||
|
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
|
||||||
|
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
|
||||||
|
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
|
||||||
|
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
|
||||||
|
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
|
||||||
|
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
|
||||||
|
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
|
||||||
|
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
|
||||||
|
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
|
||||||
|
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
|
||||||
|
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
|
||||||
|
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
|
||||||
|
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
|
||||||
|
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 559.731 559.731"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_36_">
|
||||||
|
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
|
||||||
|
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
|
||||||
|
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
|
||||||
|
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
|
||||||
|
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
|
||||||
|
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
|
||||||
|
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
|
||||||
|
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
|
||||||
|
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
|
||||||
|
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
|
||||||
|
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
|
||||||
|
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
|
||||||
|
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
|
||||||
|
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
|
||||||
|
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
|
||||||
|
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
|
||||||
|
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
|
||||||
|
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
|
||||||
|
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
|
||||||
|
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
|
||||||
|
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
|
||||||
|
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
|
||||||
|
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
|
||||||
|
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
|
||||||
|
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
|
||||||
|
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
|
||||||
|
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
|
||||||
|
/>
|
||||||
|
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
|
||||||
|
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
|
||||||
|
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
|
||||||
|
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
|
||||||
|
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
|
||||||
|
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
|
||||||
|
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
|
||||||
|
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
|
||||||
|
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
|
||||||
|
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
|
||||||
|
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
|
||||||
|
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
|
||||||
|
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
|
||||||
|
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
|
||||||
|
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
|
||||||
|
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
|
||||||
|
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
|
||||||
|
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
|
||||||
|
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
|
||||||
|
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
|
||||||
|
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 589.748 589.748"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_2_">
|
||||||
|
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
|
||||||
|
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
|
||||||
|
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
|
||||||
|
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
|
||||||
|
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
|
||||||
|
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
|
||||||
|
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
|
||||||
|
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
|
||||||
|
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
|
||||||
|
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
|
||||||
|
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
|
||||||
|
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
|
||||||
|
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
|
||||||
|
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
|
||||||
|
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
|
||||||
|
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
|
||||||
|
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
|
||||||
|
/>
|
||||||
|
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
|
||||||
|
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
|
||||||
|
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
|
||||||
|
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
|
||||||
|
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
|
||||||
|
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
|
||||||
|
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
|
||||||
|
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
|
||||||
|
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
|
||||||
|
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
|
||||||
|
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
|
||||||
|
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
|
||||||
|
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
|
||||||
|
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
|
||||||
|
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
|
||||||
|
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
|
||||||
|
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
|
||||||
|
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
|
||||||
|
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
|
||||||
|
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
|
||||||
|
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
|
||||||
|
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
|
||||||
|
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
|
||||||
|
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
|
||||||
|
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
|
||||||
|
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
|
||||||
|
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
|
||||||
|
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
|
||||||
|
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
|
||||||
|
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
|
||||||
|
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
|
||||||
|
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
|
||||||
|
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
|
||||||
|
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
|
||||||
|
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
|
||||||
|
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
|
||||||
|
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
|
||||||
|
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
|
||||||
|
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
|
||||||
|
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
|
||||||
|
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
|
||||||
|
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
|
||||||
|
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
|
||||||
|
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
|
||||||
|
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
|
||||||
|
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
|
||||||
|
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
|
||||||
|
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
|
||||||
|
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
|
||||||
|
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
|
||||||
|
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
|
||||||
|
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
|
||||||
|
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
|
||||||
|
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
|
||||||
|
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
|
||||||
|
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
|
||||||
|
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
|
||||||
|
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
|
||||||
|
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
|
||||||
|
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
|
||||||
|
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
|
||||||
|
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
|
||||||
|
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
|
||||||
|
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
|
||||||
|
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
|
||||||
|
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
|
||||||
|
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
|
||||||
|
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
|
||||||
|
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
|
||||||
|
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
|
||||||
|
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
|
||||||
|
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
|
||||||
|
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
|
||||||
|
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
|
||||||
|
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
|
||||||
|
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
|
||||||
|
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
|
||||||
|
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
|
||||||
|
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
|
||||||
|
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
|
||||||
|
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -1,3 +1,7 @@
|
|||||||
|
[env]
|
||||||
|
_.path = ["/opt/homebrew/lib"]
|
||||||
|
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
python = "3.12"
|
python = "3.12"
|
||||||
hk = "latest"
|
hk = "latest"
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
]
|
]
|
||||||
|
figment = [
|
||||||
|
"cairosvg>=2.7.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
|
|||||||
3
tests/fixtures/test.svg
vendored
Normal file
3
tests/fixtures/test.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||||
|
<rect x="10" y="10" width="80" height="80" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 155 B |
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