forked from genewildish/Mainline
Compare commits
44 Commits
f136bd75f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 42aa6f16cc | |||
| a25b80d4a6 | |||
| 3a1aa975d1 | |||
| d5e5f39404 | |||
| 2bfd3a01da | |||
| 4cf316c280 | |||
| 79d271c42b | |||
| 525af4bc46 | |||
| 085f150cb0 | |||
| 0b6e2fae74 | |||
| 6864ad84c6 | |||
| acb42ea140 | |||
| 7014a9d5cd | |||
| 2cc8dbfc02 | |||
| f1d5162488 | |||
| 9f61226779 | |||
| 9415e18679 | |||
| 0819f8d160 | |||
| edd1416407 | |||
| ac9b47f668 | |||
| b149825bcb | |||
| 1b29e91f9d | |||
| 001158214c | |||
| 31f5d9f171 | |||
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee | |||
| 8b696c96ce | |||
| 72d21459ca | |||
| 58dbbbdba7 | |||
| 7ff78c66ed | |||
| 2229ccdea4 | |||
| f13e89f823 | |||
| 4228400c43 | |||
| 05cc475858 | |||
| cfd7e8931e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
316
README.md
316
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 a white-hot → deep green gradient. 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,15 +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/`.
|
||||||
|
|
||||||
### 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:
|
||||||
|
|
||||||
@@ -80,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
|
||||||
@@ -93,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
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
registry.py effect registration and lookup
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
chain.py effect pipeline chaining
|
||||||
scroll.py stream() frame loop + message rendering
|
controller.py handles /effects commands
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
performance.py performance monitoring
|
||||||
frame.py scroll step calculation, timing
|
legacy.py legacy functional effects
|
||||||
layers.py ticker zone, firehose, message overlay rendering
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
eventbus.py thread-safe event publishing for decoupled communication
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
events.py event types and definitions
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
scroll.py stream() frame loop + message rendering
|
||||||
emitters.py background emitters for ntfy and mic
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
types.py type definitions and dataclasses
|
frame.py scroll step calculation, timing
|
||||||
|
layers.py ticker zone, firehose, message + figment overlay rendering
|
||||||
|
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
|
||||||
|
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
|
||||||
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
|
events.py event types and definitions
|
||||||
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
|
emitters.py background emitters for ntfy and mic
|
||||||
|
types.py type definitions and dataclasses
|
||||||
|
themes.py THEME_REGISTRY — gradient color definitions
|
||||||
|
display/ Display backend system
|
||||||
|
__init__.py DisplayRegistry, get_monitor
|
||||||
|
backends/
|
||||||
|
terminal.py ANSI terminal display
|
||||||
|
websocket.py WebSocket server for browser clients
|
||||||
|
sixel.py Sixel graphics (pure Python)
|
||||||
|
null.py headless display for testing
|
||||||
|
multi.py forwards to multiple displays
|
||||||
|
benchmark.py performance benchmarking tool
|
||||||
|
|
||||||
|
effects_plugins/
|
||||||
|
__init__.py plugin discovery (ABC issubclass scan)
|
||||||
|
noise.py NoiseEffect — random character noise
|
||||||
|
glitch.py GlitchEffect — horizontal glitch bars
|
||||||
|
fade.py FadeEffect — edge fade zones
|
||||||
|
firehose.py FirehoseEffect — dense bottom ticker strip
|
||||||
|
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
|
||||||
|
|
||||||
|
figments/ SVG assets for figment mode
|
||||||
```
|
```
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extending
|
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
|
|
||||||
|
|
||||||
### NtfyPoller
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
|
||||||
poller.start()
|
|
||||||
|
|
||||||
# in your render loop:
|
|
||||||
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
|
||||||
if msg:
|
|
||||||
title, body, ts = msg
|
|
||||||
render_my_message(title, body) # visualizer-specific
|
|
||||||
```
|
|
||||||
|
|
||||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
|
|
||||||
|
|
||||||
### MicMonitor
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.mic import MicMonitor
|
|
||||||
|
|
||||||
mic = MicMonitor(threshold_db=50)
|
|
||||||
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
|
|
||||||
if result:
|
|
||||||
excess = mic.excess # dB above threshold, clamped to 0
|
|
||||||
db = mic.db # raw RMS dB level
|
|
||||||
```
|
|
||||||
|
|
||||||
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
|
|
||||||
|
|
||||||
### Render pipeline
|
|
||||||
|
|
||||||
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# planned — serve.py does not yet exist
|
|
||||||
from engine.render import render_line, big_wrap
|
|
||||||
from engine.fetch import fetch_all
|
|
||||||
|
|
||||||
headlines = fetch_all()
|
|
||||||
for h in headlines:
|
|
||||||
rows = big_wrap(h.text, font, width=800) # list of half-block rows
|
|
||||||
# threshold to 1-bit, pack bytes, serve over HTTP
|
|
||||||
```
|
|
||||||
|
|
||||||
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -189,35 +250,64 @@ 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/):
|
||||||
|
|
||||||
```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-fix # ruff check --fix
|
mise run lint # ruff check
|
||||||
mise run format # ruff format
|
mise run lint-fix # ruff check --fix
|
||||||
mise run run # uv run mainline.py
|
mise run format # ruff format
|
||||||
mise run run-poetry # uv run mainline.py --poetry
|
|
||||||
mise run run-firehose # uv run mainline.py --firehose
|
mise run run # terminal display
|
||||||
|
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
|
||||||
@@ -232,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
|
||||||
@@ -256,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.*
|
||||||
|
|||||||
250
cmdline.py
Normal file
250
cmdline.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python cmdline.py # Interactive TUI mode
|
||||||
|
python cmdline.py --help # Show help
|
||||||
|
python cmdline.py /effects list # Send single command via ntfy
|
||||||
|
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||||
|
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||||
|
|
||||||
|
The TUI mode provides:
|
||||||
|
- Arrow keys to navigate command history
|
||||||
|
- Tab completion for commands
|
||||||
|
- Auto-refresh for performance stats
|
||||||
|
|
||||||
|
C&C works like a serial port:
|
||||||
|
1. Send command to ntfy_cc_topic
|
||||||
|
2. Mainline receives, processes, responds to same topic
|
||||||
|
3. Cmdline polls for response
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
||||||
|
|
||||||
|
try:
|
||||||
|
CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
|
||||||
|
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
|
||||||
|
except AttributeError:
|
||||||
|
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyResponsePoller:
|
||||||
|
"""Polls ntfy for command responses."""
|
||||||
|
|
||||||
|
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||||
|
self.cmd_topic = cmd_topic
|
||||||
|
self.resp_topic = resp_topic
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_id = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _build_url(self) -> str:
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
parsed = urlparse(self.resp_topic)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||||
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def send_and_wait(self, cmd: str) -> str:
|
||||||
|
"""Send command and wait for response."""
|
||||||
|
url = self.cmd_topic.replace("/json", "")
|
||||||
|
data = cmd.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-cmdline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error sending command: {e}"
|
||||||
|
|
||||||
|
return self._wait_for_response(cmd)
|
||||||
|
|
||||||
|
def _wait_for_response(self, expected_cmd: str = "") -> str:
|
||||||
|
"""Poll for response message."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < self.timeout:
|
||||||
|
try:
|
||||||
|
url = self._build_url()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
for line in resp:
|
||||||
|
try:
|
||||||
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
self._last_id = data.get("id")
|
||||||
|
msg = data.get("message", "")
|
||||||
|
if msg:
|
||||||
|
return msg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
return "Timeout waiting for response"
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_COMMANDS = """Available commands:
|
||||||
|
/effects list - List all effects and status
|
||||||
|
/effects <name> on - Enable an effect
|
||||||
|
/effects <name> off - Disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
|
/help - Show this help
|
||||||
|
/quit - Exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def print_header():
|
||||||
|
w = 60
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print(f"\033[1;1H", end="")
|
||||||
|
print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
|
||||||
|
)
|
||||||
|
print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_response(response: str, is_error: bool = False) -> None:
|
||||||
|
"""Print response with nice formatting."""
|
||||||
|
print()
|
||||||
|
if is_error:
|
||||||
|
print(f" \033[1;38;5;196m✗ Error\033[0m")
|
||||||
|
print(f" \033[38;5;196m{'─' * 40}\033[0m")
|
||||||
|
else:
|
||||||
|
print(f" \033[1;38;5;82m✓ Response\033[0m")
|
||||||
|
print(f" \033[38;5;37m{'─' * 40}\033[0m")
|
||||||
|
|
||||||
|
for line in response.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_mode():
|
||||||
|
"""Interactive TUI for sending commands."""
|
||||||
|
import readline
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.startswith("/"):
|
||||||
|
if cmd == "/quit" or cmd == "/exit":
|
||||||
|
print(f"\n \033[1;38;5;245mGoodbye!{RST}\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
if cmd == "/help":
|
||||||
|
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" \033[38;5;245m⟳ Sending to mainline...{RST}")
|
||||||
|
result = poller.send_and_wait(cmd)
|
||||||
|
print_response(result, is_error=result.startswith("Error"))
|
||||||
|
else:
|
||||||
|
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Mainline command-line interface",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=AVAILABLE_COMMANDS,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Command to send (e.g., /effects list)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--watch",
|
||||||
|
"-w",
|
||||||
|
action="store_true",
|
||||||
|
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
return interactive_mode()
|
||||||
|
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
if args.watch and "/effects stats" in args.command:
|
||||||
|
import signal
|
||||||
|
|
||||||
|
def handle_sigterm(*_):
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(f"\033[2J\033[1;1H", end="")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}"
|
||||||
|
)
|
||||||
|
print(f" \033[38;5;37m{'─' * 44}{RST}")
|
||||||
|
for line in result.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
time.sleep(2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# Color Scheme Switcher Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||||
|
|
||||||
|
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Purpose | Change Type |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||||
|
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||||
|
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||||
|
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||||
|
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||||
|
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Theme Data Module
|
||||||
|
|
||||||
|
### Task 1: Create themes.py with Theme class and registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `engine/themes.py`
|
||||||
|
- Test: `tests/test_themes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for Theme class**
|
||||||
|
|
||||||
|
Create `tests/test_themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Test color themes and registry."""
|
||||||
|
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_construction():
|
||||||
|
"""Theme stores name and gradient lists."""
|
||||||
|
main = ["\033[1;38;5;231m"] * 12
|
||||||
|
msg = ["\033[1;38;5;225m"] * 12
|
||||||
|
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||||
|
|
||||||
|
assert theme.name == "Test Green"
|
||||||
|
assert theme.main_gradient == main
|
||||||
|
assert theme.message_gradient == msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradient_length():
|
||||||
|
"""Each gradient must have exactly 12 ANSI codes."""
|
||||||
|
for theme_id, theme in THEME_REGISTRY.items():
|
||||||
|
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||||
|
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes():
|
||||||
|
"""Registry contains green, orange, purple."""
|
||||||
|
assert len(THEME_REGISTRY) == 3
|
||||||
|
assert "green" in THEME_REGISTRY
|
||||||
|
assert "orange" in THEME_REGISTRY
|
||||||
|
assert "purple" in THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_valid():
|
||||||
|
"""get_theme returns Theme object for valid ID."""
|
||||||
|
theme = get_theme("green")
|
||||||
|
assert isinstance(theme, Theme)
|
||||||
|
assert theme.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_invalid():
|
||||||
|
"""get_theme raises KeyError for invalid ID."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
get_theme("invalid_theme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_green_theme_unchanged():
|
||||||
|
"""Green theme uses original green → magenta colors."""
|
||||||
|
green_theme = get_theme("green")
|
||||||
|
# First color should be white (bold)
|
||||||
|
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||||
|
# Last deep green
|
||||||
|
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||||
|
# Message gradient is magenta
|
||||||
|
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: FAIL (module doesn't exist)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||||
|
|
||||||
|
Create `engine/themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Color theme definitions and registry."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
"""Initialize theme with display name and gradient lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Display name (e.g., "Verdant Green")
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||||
|
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||||
|
# Each gradient: white → primary/complementary, 12 steps total
|
||||||
|
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||||
|
|
||||||
|
_GREEN_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_GREEN_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;215m", # pale orange-white
|
||||||
|
"\033[38;5;209m", # bright orange
|
||||||
|
"\033[38;5;208m", # vibrant orange
|
||||||
|
"\033[38;5;202m", # orange
|
||||||
|
"\033[38;5;166m", # dark orange
|
||||||
|
"\033[38;5;130m", # burnt orange
|
||||||
|
"\033[38;5;94m", # rust
|
||||||
|
"\033[38;5;58m", # dark rust
|
||||||
|
"\033[38;5;94m", # rust (hold)
|
||||||
|
"\033[2;38;5;94m", # dim rust
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;33m", # bright blue
|
||||||
|
"\033[38;5;27m", # blue
|
||||||
|
"\033[38;5;21m", # deep blue
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;18m", # navy
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[2;38;5;18m", # dim navy
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale purple-white
|
||||||
|
"\033[38;5;177m", # bright purple
|
||||||
|
"\033[38;5;171m", # vibrant purple
|
||||||
|
"\033[38;5;165m", # purple
|
||||||
|
"\033[38;5;135m", # medium purple
|
||||||
|
"\033[38;5;129m", # purple
|
||||||
|
"\033[38;5;93m", # dark purple
|
||||||
|
"\033[38;5;57m", # deep purple
|
||||||
|
"\033[38;5;57m", # deep purple (hold)
|
||||||
|
"\033[2;38;5;57m", # dim deep purple
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;226m", # pale yellow-white
|
||||||
|
"\033[38;5;226m", # bright yellow
|
||||||
|
"\033[38;5;220m", # yellow
|
||||||
|
"\033[38;5;220m", # yellow (hold)
|
||||||
|
"\033[38;5;184m", # dark yellow
|
||||||
|
"\033[38;5;184m", # dark yellow (hold)
|
||||||
|
"\033[38;5;178m", # olive-yellow
|
||||||
|
"\033[38;5;178m", # olive-yellow (hold)
|
||||||
|
"\033[38;5;172m", # golden
|
||||||
|
"\033[2;38;5;172m", # dim golden
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme(
|
||||||
|
name="Verdant Green",
|
||||||
|
main_gradient=_GREEN_MAIN,
|
||||||
|
message_gradient=_GREEN_MESSAGE,
|
||||||
|
),
|
||||||
|
"orange": Theme(
|
||||||
|
name="Molten Orange",
|
||||||
|
main_gradient=_ORANGE_MAIN,
|
||||||
|
message_gradient=_ORANGE_MESSAGE,
|
||||||
|
),
|
||||||
|
"purple": Theme(
|
||||||
|
name="Violet Purple",
|
||||||
|
main_gradient=_PURPLE_MAIN,
|
||||||
|
message_gradient=_PURPLE_MESSAGE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id: str) -> Theme:
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id not found in registry
|
||||||
|
"""
|
||||||
|
if theme_id not in THEME_REGISTRY:
|
||||||
|
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: PASS (all 6 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/themes.py tests/test_themes.py
|
||||||
|
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||||
|
|
||||||
|
- Define Theme class to encapsulate name and main/message gradients
|
||||||
|
- Create THEME_REGISTRY with green, orange, purple themes
|
||||||
|
- Each gradient has 12 ANSI 256-color codes finalized
|
||||||
|
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||||
|
- Add get_theme() lookup with error handling
|
||||||
|
- Add comprehensive unit tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Config Integration
|
||||||
|
|
||||||
|
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/config.py:1-30`
|
||||||
|
- Test: `tests/test_config.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for config changes**
|
||||||
|
|
||||||
|
Add to `tests/test_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_active_theme_initially_none():
|
||||||
|
"""ACTIVE_THEME is None before initialization."""
|
||||||
|
# This test may fail if config is already initialized
|
||||||
|
# We'll set it to None first for testing
|
||||||
|
import engine.config
|
||||||
|
engine.config.ACTIVE_THEME = None
|
||||||
|
assert engine.config.ACTIVE_THEME is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_green():
|
||||||
|
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
set_active_theme("green")
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME is not None
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
assert config.ACTIVE_THEME == get_theme("green")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_default():
|
||||||
|
"""set_active_theme() with no args defaults to green."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
set_active_theme()
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_invalid():
|
||||||
|
"""set_active_theme() with invalid ID raises KeyError."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
set_active_theme("invalid")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: FAIL (functions don't exist yet)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||||
|
|
||||||
|
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is invalid
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DELETED:
|
||||||
|
# GRAD_COLS = [...]
|
||||||
|
# MSG_GRAD_COLS = [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 4 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify existing config tests still pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (all existing + new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/config.py tests/test_config.py
|
||||||
|
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||||
|
|
||||||
|
- Add ACTIVE_THEME global (initialized to None)
|
||||||
|
- Add set_active_theme(theme_id) function with green default
|
||||||
|
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||||
|
- Add comprehensive tests for theme setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Render Pipeline Integration
|
||||||
|
|
||||||
|
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/render.py:15-220`
|
||||||
|
- Test: `tests/test_render.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||||
|
|
||||||
|
Add to `tests/test_render.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||||
|
from engine import config, render
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
# Set orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
# Create simple rows
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Result should start with first color from orange main gradient
|
||||||
|
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
# Clear active theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Should not crash and should return something
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_green_gradient_length():
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
from engine import render
|
||||||
|
|
||||||
|
colors = render._default_green_gradient()
|
||||||
|
assert len(colors) == 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Expected: FAIL (function signature doesn't match)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
"""
|
||||||
|
Render rows through a left-to-right color sweep.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text rows to colorize
|
||||||
|
offset: Gradient position offset (for animation)
|
||||||
|
cols: Optional list of color codes. If None, uses active theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of colorized rows
|
||||||
|
"""
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... rest of function unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||||
|
|
||||||
|
Add to `engine/render.py` before `lr_gradient()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (original colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 3 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full render test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing tests may need adjustment for mocking)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/render.py tests/test_render.py
|
||||||
|
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||||
|
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||||
|
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||||
|
- Add tests for theme-aware and fallback gradient rendering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Message Gradient Integration
|
||||||
|
|
||||||
|
### Task 4: Update scroll.py to use message gradient from config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/scroll.py:85-95`
|
||||||
|
- Test: existing `tests/test_scroll.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||||
|
|
||||||
|
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||||
|
|
||||||
|
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||||
|
|
||||||
|
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||||
|
|
||||||
|
Old code:
|
||||||
|
```python
|
||||||
|
# Some variation of:
|
||||||
|
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
```
|
||||||
|
|
||||||
|
New code:
|
||||||
|
```python
|
||||||
|
from engine import config
|
||||||
|
msg_cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else render._default_magenta_gradient()
|
||||||
|
)
|
||||||
|
rows = lr_gradient(rows, offset, msg_cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in scroll.py:
|
||||||
|
```python
|
||||||
|
rows = render.msg_gradient(rows, offset)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing scroll tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_scroll.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing functionality unchanged)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/scroll.py engine/render.py
|
||||||
|
git commit -m "feat: update scroll.py to use theme message gradient
|
||||||
|
|
||||||
|
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||||
|
- Use fallback magenta gradient when theme not initialized
|
||||||
|
- Ensure ntfy messages render in complementary color from selected theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Color Picker UI
|
||||||
|
|
||||||
|
### Task 5: Create pick_color_theme() function in app.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/app.py:1-300`
|
||||||
|
- Test: manual/integration (interactive)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu."""
|
||||||
|
import sys
|
||||||
|
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create pick_color_theme() function**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add after helper function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use green
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... existing signal handler setup ...
|
||||||
|
|
||||||
|
pick_color_theme() # NEW LINE - before font picker
|
||||||
|
pick_font_face()
|
||||||
|
|
||||||
|
# ... rest of main unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- See color theme picker menu before font picker
|
||||||
|
- Can navigate with ↑/↓ or j/k
|
||||||
|
- Can select with Enter or q
|
||||||
|
- Selected theme applies to scrolling headlines
|
||||||
|
- Can select different themes and see colors change
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||||
|
|
||||||
|
Run: `echo "" | python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- No color picker menu shown
|
||||||
|
- Defaults to green theme
|
||||||
|
- App runs without error
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/app.py
|
||||||
|
git commit -m "feat: add pick_color_theme() UI and integration
|
||||||
|
|
||||||
|
- Create _draw_color_picker() to render menu
|
||||||
|
- Create pick_color_theme() function mirroring font picker pattern
|
||||||
|
- Integrate into main() before font picker
|
||||||
|
- Fallback to green theme in non-interactive environments
|
||||||
|
- Support arrow keys and j/k navigation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: Integration & Validation
|
||||||
|
|
||||||
|
### Task 6: End-to-end testing and cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: All modified files
|
||||||
|
- Verify: App functionality
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
|
||||||
|
Expected: PASS (all tests, including new ones)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
Run: `ruff check engine/ mainline.py`
|
||||||
|
|
||||||
|
Expected: No errors (fix any style issues)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual integration test - green theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Verdant Green" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in green → deep green
|
||||||
|
- ntfy messages render in magenta gradient
|
||||||
|
- Both work correctly during streaming
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual integration test - orange theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Molten Orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in orange → deep orange
|
||||||
|
- ntfy messages render in blue gradient
|
||||||
|
- Colors are visually distinct from green
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual integration test - purple theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Violet Purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in purple → deep purple
|
||||||
|
- ntfy messages render in yellow gradient
|
||||||
|
- Colors are visually distinct from green and orange
|
||||||
|
|
||||||
|
- [ ] **Step 6: Test poetry mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --poetry`
|
||||||
|
|
||||||
|
Then select "orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Poetry mode works with color picker
|
||||||
|
- Colors apply to poetry rendering
|
||||||
|
|
||||||
|
- [ ] **Step 7: Test code mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --code`
|
||||||
|
|
||||||
|
Then select "purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Code mode works with color picker
|
||||||
|
- Colors apply to code rendering
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify acceptance criteria**
|
||||||
|
|
||||||
|
✓ Color picker displays 3 theme options at startup
|
||||||
|
✓ Selection applies to all headline and message gradients
|
||||||
|
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
✓ No persistence between runs (each run picks fresh)
|
||||||
|
✓ Non-TTY environments default to green without error
|
||||||
|
✓ Architecture supports future random/animation modes
|
||||||
|
✓ All gradient color codes finalized with no TBD values
|
||||||
|
|
||||||
|
- [ ] **Step 9: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: color scheme switcher implementation complete
|
||||||
|
|
||||||
|
Closes color-pick feature with:
|
||||||
|
- Three selectable color themes (green, orange, purple)
|
||||||
|
- Interactive menu at startup (mirrors font picker UI)
|
||||||
|
- Complementary colors for ntfy message queue
|
||||||
|
- Fallback to green in non-interactive environments
|
||||||
|
- All tests passing, manual validation complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Create feature branch PR summary**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Color Scheme Switcher
|
||||||
|
|
||||||
|
Implements interactive color theme selection for Mainline news ticker.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||||
|
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||||
|
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||||
|
- Fresh theme selection each run (no persistence)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||||
|
- `engine/render.py` (theme-aware gradients)
|
||||||
|
- `engine/scroll.py` (message gradient integration)
|
||||||
|
- `engine/app.py` (pick_color_theme UI)
|
||||||
|
- `tests/test_themes.py` (new theme tests)
|
||||||
|
- `README.md` (documentation)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
All met. App fully tested and ready for merge.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||||
|
- [ ] Full suite: `pytest tests/ -v`
|
||||||
|
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||||
|
- [ ] Manual: Green theme selection
|
||||||
|
- [ ] Manual: Orange theme selection
|
||||||
|
- [ ] Manual: Purple theme selection
|
||||||
|
- [ ] Manual: Poetry mode with colors
|
||||||
|
- [ ] Manual: Code mode with colors
|
||||||
|
- [ ] Manual: Non-TTY fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||||
|
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||||
|
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||||
|
- Message gradients use complementary colors; lookup in scroll.py
|
||||||
|
- Each gradient has 12 colors; verify length in tests
|
||||||
|
- No persistence; fresh picker each run
|
||||||
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
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Code Scroll Mode — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Branch:** feat/code-scroll
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Mirror the existing `--poetry` mode pattern as closely as possible
|
||||||
|
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
||||||
|
- No changes to `scroll.py` or the render pipeline
|
||||||
|
- The item tuple shape `(text, src, ts)` is unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `engine/fetch_code.py`
|
||||||
|
|
||||||
|
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
|
||||||
|
1. Glob `engine/*.py` in sorted order
|
||||||
|
2. For each file:
|
||||||
|
a. Read source text
|
||||||
|
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
||||||
|
c. Iterate source lines (1-indexed). Skip if:
|
||||||
|
- The stripped line is empty
|
||||||
|
- The stripped line starts with `#`
|
||||||
|
d. For each kept line emit:
|
||||||
|
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
||||||
|
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
||||||
|
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
||||||
|
3. Return `(items, len(items), 0)`
|
||||||
|
|
||||||
|
**Scope label rules:**
|
||||||
|
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
||||||
|
- `ClassDef` → `name` (no parens)
|
||||||
|
- No enclosing node → `<module>`
|
||||||
|
|
||||||
|
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### `engine/config.py`
|
||||||
|
|
||||||
|
Extend `MODE` detection to recognise `--code`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MODE = (
|
||||||
|
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code" if "--code" in sys.argv
|
||||||
|
else "news"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `engine/app.py`
|
||||||
|
|
||||||
|
**Subtitle line** — extend the subtitle dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_subtitle = {
|
||||||
|
"poetry": "literary consciousness stream",
|
||||||
|
"code": "source consciousness stream",
|
||||||
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif config.MODE == "code":
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, line_count, _ = fetch_code()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||||
|
```
|
||||||
|
|
||||||
|
No cache save/load — local source files are read instantly and change only on disk writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/*.py (sorted)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
fetch_code()
|
||||||
|
│ ast.parse → scope map
|
||||||
|
│ filter blank + comment lines
|
||||||
|
│ emit (line, scope(), engine.module)
|
||||||
|
▼
|
||||||
|
items: List[Tuple[str, str, str]]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
stream(items, ntfy, mic) ← unchanged
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
next_headline() shuffles + recycles automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
||||||
|
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New file: `tests/test_fetch_code.py`
|
||||||
|
|
||||||
|
| Test | Assertion |
|
||||||
|
|------|-----------|
|
||||||
|
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
||||||
|
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
||||||
|
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
||||||
|
|
||||||
|
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 mainline.py --code # source consciousness mode
|
||||||
|
uv run mainline.py --code
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Syntax highlighting / token-aware coloring (can be added later)
|
||||||
|
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
||||||
|
- Caching code items to disk
|
||||||
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Color Scheme Switcher Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Status:** Revised after review
|
||||||
|
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||||
|
|
||||||
|
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Functional:**
|
||||||
|
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||||
|
2. Main headline gradient uses the selected primary color (white → color)
|
||||||
|
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||||
|
4. Selection is fresh each run (no persistence)
|
||||||
|
5. Design supports future "random rotation" mode without refactoring
|
||||||
|
|
||||||
|
**Complementary colors (precise opposites):**
|
||||||
|
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||||
|
- Orange (38;5;208) → Blue (38;5;21)
|
||||||
|
- Purple (38;5;129) → Yellow (38;5;226)
|
||||||
|
|
||||||
|
**Non-functional:**
|
||||||
|
- Reuse the existing font picker pattern for UI consistency
|
||||||
|
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||||
|
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||||
|
- Font picker UI remains hardcoded green for visual continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Module: `engine/themes.py`
|
||||||
|
|
||||||
|
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient # white → primary color
|
||||||
|
self.message_gradient = message_gradient # white → complementary
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Registry:**
|
||||||
|
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||||
|
|
||||||
|
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright color
|
||||||
|
"\033[38;5;40m", # color
|
||||||
|
"\033[38;5;34m", # medium color
|
||||||
|
"\033[38;5;28m", # dark color
|
||||||
|
"\033[38;5;22m", # deep color
|
||||||
|
"\033[2;38;5;22m", # dim deep color
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finalized color codes:**
|
||||||
|
|
||||||
|
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||||
|
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||||
|
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||||
|
|
||||||
|
**Orange (primary: 208, complementary: 21)**
|
||||||
|
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||||
|
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||||
|
|
||||||
|
**Purple (primary: 129, complementary: 226)**
|
||||||
|
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||||
|
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||||
|
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/config.py`
|
||||||
|
|
||||||
|
**New globals:**
|
||||||
|
```python
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||||
|
```
|
||||||
|
|
||||||
|
**New function:**
|
||||||
|
```python
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Called by `app.pick_color_theme()` with user selection
|
||||||
|
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||||
|
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||||
|
|
||||||
|
**Removal:**
|
||||||
|
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/render.py`
|
||||||
|
|
||||||
|
**Updated gradient access in existing functions:**
|
||||||
|
|
||||||
|
Current pattern (will be removed):
|
||||||
|
```python
|
||||||
|
GRAD_COLS = [...] # hardcoded green
|
||||||
|
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||||
|
```
|
||||||
|
|
||||||
|
New pattern — update `lr_gradient()` function:
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient())
|
||||||
|
# ... rest of function unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Define fallback:**
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (current colors)."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||||
|
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||||
|
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||||
|
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message gradient handling:**
|
||||||
|
|
||||||
|
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||||
|
```python
|
||||||
|
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
# Use:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
or define a helper:
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/app.py`
|
||||||
|
|
||||||
|
**New function: `pick_color_theme()`**
|
||||||
|
|
||||||
|
Mirrors `pick_font_face()` pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use default
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker (similar to font picker)
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||||
|
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement in `main()`:**
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... signal handler setup ...
|
||||||
|
pick_color_theme() # NEW — before title/subtitle
|
||||||
|
pick_font_face()
|
||||||
|
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User starts: mainline.py
|
||||||
|
↓
|
||||||
|
main() called
|
||||||
|
↓
|
||||||
|
pick_color_theme()
|
||||||
|
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||||
|
→ If not TTY: silently call config.set_active_theme("green")
|
||||||
|
↓
|
||||||
|
pick_font_face() — renders in hardcoded green UI colors
|
||||||
|
↓
|
||||||
|
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||||
|
↓
|
||||||
|
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||||
|
↓
|
||||||
|
On exit: no persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Initialization Guarantee
|
||||||
|
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||||
|
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||||
|
2. It's called before any rendering happens
|
||||||
|
3. Default fallback ensures non-TTY environments don't crash
|
||||||
|
|
||||||
|
### Module Independence
|
||||||
|
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||||
|
|
||||||
|
### Color Code Finalization
|
||||||
|
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||||
|
|
||||||
|
### Theme ID Naming
|
||||||
|
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||||
|
|
||||||
|
### Terminal Resize Handling
|
||||||
|
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||||
|
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||||
|
2. Once a theme is selected, the menu closes and rendering begins
|
||||||
|
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||||
|
|
||||||
|
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
1. **Unit tests** (`tests/test_themes.py`):
|
||||||
|
- Verify Theme class construction
|
||||||
|
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||||
|
- Confirm gradient lists have correct length (12)
|
||||||
|
|
||||||
|
2. **Integration tests** (`tests/test_render.py`):
|
||||||
|
- Mock `config.ACTIVE_THEME` to each theme
|
||||||
|
- Verify `lr_gradient()` uses correct colors
|
||||||
|
- Verify fallback works when `ACTIVE_THEME` is None
|
||||||
|
|
||||||
|
3. **Existing tests:**
|
||||||
|
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||||
|
- Use pytest fixtures to set theme per test case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||||
|
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||||
|
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||||
|
- `tests/test_themes.py` (new unit tests)
|
||||||
|
- `tests/test_render.py` (update mocking strategy)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. ✓ Color picker displays 3 theme options at startup
|
||||||
|
2. ✓ Selection applies to all headline and message gradients
|
||||||
|
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
5. ✓ No persistence between runs
|
||||||
|
6. ✓ Non-TTY environments default to green without error
|
||||||
|
7. ✓ Architecture supports future random/animation modes
|
||||||
|
8. ✓ All gradient color codes finalized with no TBD values
|
||||||
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
|
||||||
36
effects_plugins/__init__.py
Normal file
36
effects_plugins/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins():
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
from engine.effects.types import EffectPlugin
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||||
|
if file_path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = file_path.stem
|
||||||
|
if module_name in ("base", "types"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||||
|
for attr_name in dir(module):
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and issubclass(attr, EffectPlugin)
|
||||||
|
and attr is not EffectPlugin
|
||||||
|
and attr_name.endswith("Effect")
|
||||||
|
):
|
||||||
|
plugin = attr()
|
||||||
|
registry.register(plugin)
|
||||||
|
imported[plugin.name] = plugin
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return imported
|
||||||
58
effects_plugins/fade.py
Normal file
58
effects_plugins/fade.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class FadeEffect(EffectPlugin):
|
||||||
|
name = "fade"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||||
|
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
if r >= ctx.ticker_height:
|
||||||
|
continue
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = (
|
||||||
|
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||||
|
if bot_zone > 0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
row_fade = min(top_f, bot_f) * intensity
|
||||||
|
|
||||||
|
if row_fade < 1.0 and result[r].strip():
|
||||||
|
result[r] = self._fade_line(result[r], row_fade)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _fade_line(self, s: str, fade: float) -> str:
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ""
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == " ":
|
||||||
|
result.append(" ")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
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
|
||||||
72
effects_plugins/firehose.py
Normal file
72
effects_plugins/firehose.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class FirehoseEffect(EffectPlugin):
|
||||||
|
name = "firehose"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
if firehose_h <= 0 or not ctx.items:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
h = ctx.terminal_height
|
||||||
|
|
||||||
|
for fr in range(firehose_h):
|
||||||
|
scr_row = h - firehose_h + fr + 1
|
||||||
|
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||||
|
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35 * intensity:
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55 * intensity:
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78 * intensity:
|
||||||
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = "".join(
|
||||||
|
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||||
|
)
|
||||||
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
37
effects_plugins/glitch.py
Normal file
37
effects_plugins/glitch.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchEffect(EffectPlugin):
|
||||||
|
name = "glitch"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||||
|
glitch_prob = glitch_prob * intensity
|
||||||
|
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||||
|
n_hits = int(n_hits * intensity)
|
||||||
|
|
||||||
|
if random.random() < glitch_prob:
|
||||||
|
for _ in range(min(n_hits, len(result))):
|
||||||
|
gi = random.randint(0, len(result) - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _glitch_bar(self, w: int) -> str:
|
||||||
|
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
36
effects_plugins/noise.py
Normal file
36
effects_plugins/noise.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseEffect(EffectPlugin):
|
||||||
|
name = "noise"
|
||||||
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
probability = intensity * 0.15
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
cy = ctx.scroll_cam + r
|
||||||
|
if random.random() < probability:
|
||||||
|
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate_noise(self, w: int, cy: int) -> str:
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
@@ -10,7 +10,7 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render, themes
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
@@ -65,6 +65,30 @@ def _read_picker_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
||||||
|
selected: Index of currently selected theme (0-2)
|
||||||
|
"""
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
||||||
|
)
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
def _normalize_preview_rows(rows):
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
non_empty = [r for r in rows if r.strip()]
|
non_empty = [r for r in rows if r.strip()]
|
||||||
@@ -131,6 +155,50 @@ def _draw_font_picker(faces, selected):
|
|||||||
print(f" {shown}")
|
print(f" {shown}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
||||||
|
|
||||||
|
Displays a menu of available themes and lets user select with arrow keys.
|
||||||
|
Non-interactive environments (piped stdin, CI) silently default to green.
|
||||||
|
"""
|
||||||
|
# Non-interactive fallback
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
def pick_font_face():
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
if not config.FONT_PICKER:
|
if not config.FONT_PICKER:
|
||||||
@@ -262,6 +330,7 @@ def main():
|
|||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_color_theme()
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
@@ -272,11 +341,10 @@ def main():
|
|||||||
time.sleep(0.07)
|
time.sleep(0.07)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
_subtitle = (
|
_subtitle = {
|
||||||
"literary consciousness stream"
|
"poetry": "literary consciousness stream",
|
||||||
if config.MODE == "poetry"
|
"code": "source consciousness stream",
|
||||||
else "digital consciousness stream"
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
)
|
|
||||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
print()
|
print()
|
||||||
@@ -297,6 +365,15 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||||
save_cache(items)
|
save_cache(items)
|
||||||
|
elif config.MODE == "code":
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, line_count, _ = fetch_code()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||||
else:
|
else:
|
||||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -336,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)
|
||||||
|
|||||||
@@ -188,8 +188,16 @@ def set_config(config: Config) -> None:
|
|||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
MODE = (
|
||||||
|
"poetry"
|
||||||
|
if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code"
|
||||||
|
if "--code" in sys.argv
|
||||||
|
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"
|
||||||
@@ -231,3 +239,26 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||||
|
Defaults to "green"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
|||||||
102
engine/display.py
Normal file
102
engine/display.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Display output abstraction - allows swapping output backends.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
- init(width, height): Initialize display with terminal dimensions
|
||||||
|
- show(buffer): Render buffer (list of strings) to display
|
||||||
|
- clear(): Clear the display
|
||||||
|
- cleanup(): Shutdown display
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sys.stdout.buffer.write("".join(buffer).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
42
engine/effects/__init__.py
Normal file
42
engine/effects/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from engine.effects.chain import EffectChain
|
||||||
|
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||||
|
from engine.effects.legacy import (
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain():
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EffectChain",
|
||||||
|
"EffectRegistry",
|
||||||
|
"EffectConfig",
|
||||||
|
"EffectContext",
|
||||||
|
"PipelineConfig",
|
||||||
|
"get_registry",
|
||||||
|
"set_registry",
|
||||||
|
"get_effect_chain",
|
||||||
|
"get_monitor",
|
||||||
|
"set_monitor",
|
||||||
|
"PerformanceMonitor",
|
||||||
|
"handle_effects_command",
|
||||||
|
"show_effects_menu",
|
||||||
|
"fade_line",
|
||||||
|
"firehose_line",
|
||||||
|
"glitch_bar",
|
||||||
|
"noise",
|
||||||
|
"next_headline",
|
||||||
|
"vis_trunc",
|
||||||
|
]
|
||||||
71
engine/effects/chain.py
Normal file
71
engine/effects/chain.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry
|
||||||
|
from engine.effects.types import EffectContext
|
||||||
|
|
||||||
|
|
||||||
|
class EffectChain:
|
||||||
|
def __init__(
|
||||||
|
self, registry: EffectRegistry, monitor: PerformanceMonitor | None = None
|
||||||
|
):
|
||||||
|
self._registry = registry
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._monitor = monitor
|
||||||
|
|
||||||
|
def _get_monitor(self) -> PerformanceMonitor:
|
||||||
|
if self._monitor is not None:
|
||||||
|
return self._monitor
|
||||||
|
return get_monitor()
|
||||||
|
|
||||||
|
def set_order(self, names: list[str]) -> None:
|
||||||
|
self._order = list(names)
|
||||||
|
|
||||||
|
def get_order(self) -> list[str]:
|
||||||
|
return self._order.copy()
|
||||||
|
|
||||||
|
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||||
|
if name not in self._registry.list_all():
|
||||||
|
return False
|
||||||
|
if position is None:
|
||||||
|
self._order.append(name)
|
||||||
|
else:
|
||||||
|
self._order.insert(position, name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_effect(self, name: str) -> bool:
|
||||||
|
if name in self._order:
|
||||||
|
self._order.remove(name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reorder(self, new_order: list[str]) -> bool:
|
||||||
|
all_plugins = set(self._registry.list_all().keys())
|
||||||
|
if not all(name in all_plugins for name in new_order):
|
||||||
|
return False
|
||||||
|
self._order = list(new_order)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
monitor = self._get_monitor()
|
||||||
|
frame_number = ctx.frame_number
|
||||||
|
monitor.start_frame(frame_number)
|
||||||
|
|
||||||
|
frame_start = time.perf_counter()
|
||||||
|
result = list(buf)
|
||||||
|
for name in self._order:
|
||||||
|
plugin = self._registry.get(name)
|
||||||
|
if plugin and plugin.config.enabled:
|
||||||
|
chars_in = sum(len(line) for line in result)
|
||||||
|
effect_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = plugin.process(result, ctx)
|
||||||
|
except Exception:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
elapsed = time.perf_counter() - effect_start
|
||||||
|
chars_out = sum(len(line) for line in result)
|
||||||
|
monitor.record_effect(name, elapsed * 1000, chars_in, chars_out)
|
||||||
|
|
||||||
|
total_elapsed = time.perf_counter() - frame_start
|
||||||
|
monitor.end_frame(frame_number, total_elapsed * 1000)
|
||||||
|
return result
|
||||||
144
engine/effects/controller.py
Normal file
144
engine/effects/controller.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from engine.effects.performance import get_monitor
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
_effect_chain_ref = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effect_chain():
|
||||||
|
global _effect_chain_ref
|
||||||
|
if _effect_chain_ref is not None:
|
||||||
|
return _effect_chain_ref
|
||||||
|
try:
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_effect_chain_ref(chain) -> None:
|
||||||
|
global _effect_chain_ref
|
||||||
|
_effect_chain_ref = chain
|
||||||
|
|
||||||
|
|
||||||
|
def handle_effects_command(cmd: str) -> str:
|
||||||
|
"""Handle /effects command from NTFY message.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/effects list - list all effects and their status
|
||||||
|
/effects <name> on - enable an effect
|
||||||
|
/effects <name> off - disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - set intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||||
|
/effects stats - show performance statistics
|
||||||
|
"""
|
||||||
|
parts = cmd.strip().split()
|
||||||
|
if not parts or parts[0] != "/effects":
|
||||||
|
return "Unknown command"
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
if len(parts) == 1 or parts[1] == "list":
|
||||||
|
result = ["Effects:"]
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
result.append(f" {name}: {status} (intensity={intensity})")
|
||||||
|
if chain:
|
||||||
|
result.append(f"Order: {chain.get_order()}")
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
if parts[1] == "stats":
|
||||||
|
return _format_stats()
|
||||||
|
|
||||||
|
if parts[1] == "reorder" and len(parts) >= 3:
|
||||||
|
new_order = parts[2].split(",")
|
||||||
|
if chain and chain.reorder(new_order):
|
||||||
|
return f"Reordered pipeline: {new_order}"
|
||||||
|
return "Failed to reorder pipeline"
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
|
return "Usage: /effects <name> on|off|intensity <value>"
|
||||||
|
|
||||||
|
effect_name = parts[1]
|
||||||
|
action = parts[2]
|
||||||
|
|
||||||
|
if effect_name not in registry.list_all():
|
||||||
|
return f"Unknown effect: {effect_name}"
|
||||||
|
|
||||||
|
if action == "on":
|
||||||
|
registry.enable(effect_name)
|
||||||
|
return f"Enabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "off":
|
||||||
|
registry.disable(effect_name)
|
||||||
|
return f"Disabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "intensity" and len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
value = float(parts[3])
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
return "Intensity must be between 0.0 and 1.0"
|
||||||
|
plugin = registry.get(effect_name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.intensity = value
|
||||||
|
return f"Set {effect_name} intensity to {value}"
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid intensity value"
|
||||||
|
|
||||||
|
return f"Unknown action: {action}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stats() -> str:
|
||||||
|
monitor = get_monitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
|
||||||
|
if "error" in stats:
|
||||||
|
return stats["error"]
|
||||||
|
|
||||||
|
lines = ["Performance Stats:"]
|
||||||
|
|
||||||
|
pipeline = stats["pipeline"]
|
||||||
|
lines.append(
|
||||||
|
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats["effects"]:
|
||||||
|
lines.append(" Per-effect (avg ms):")
|
||||||
|
for name, effect_stats in stats["effects"].items():
|
||||||
|
lines.append(
|
||||||
|
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def show_effects_menu() -> str:
|
||||||
|
"""Generate effects menu text for display."""
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||||
|
"",
|
||||||
|
"Effects:",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||||
|
|
||||||
|
if chain:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Controls:")
|
||||||
|
lines.append(" /effects <name> on|off")
|
||||||
|
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||||
|
lines.append(" /effects reorder name1,name2,...")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
103
engine/effects/performance.py
Normal file
103
engine/effects/performance.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectTiming:
|
||||||
|
name: str
|
||||||
|
duration_ms: float
|
||||||
|
buffer_chars_in: int
|
||||||
|
buffer_chars_out: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameTiming:
|
||||||
|
frame_number: int
|
||||||
|
total_ms: float
|
||||||
|
effects: list[EffectTiming]
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceMonitor:
|
||||||
|
"""Collects and stores performance metrics for effect pipeline."""
|
||||||
|
|
||||||
|
def __init__(self, max_frames: int = 60):
|
||||||
|
self._max_frames = max_frames
|
||||||
|
self._frames: deque[FrameTiming] = deque(maxlen=max_frames)
|
||||||
|
self._current_frame: list[EffectTiming] = []
|
||||||
|
|
||||||
|
def start_frame(self, frame_number: int) -> None:
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
def record_effect(
|
||||||
|
self, name: str, duration_ms: float, chars_in: int, chars_out: int
|
||||||
|
) -> None:
|
||||||
|
self._current_frame.append(
|
||||||
|
EffectTiming(
|
||||||
|
name=name,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
buffer_chars_in=chars_in,
|
||||||
|
buffer_chars_out=chars_out,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def end_frame(self, frame_number: int, total_ms: float) -> None:
|
||||||
|
self._frames.append(
|
||||||
|
FrameTiming(
|
||||||
|
frame_number=frame_number,
|
||||||
|
total_ms=total_ms,
|
||||||
|
effects=self._current_frame,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
if not self._frames:
|
||||||
|
return {"error": "No timing data available"}
|
||||||
|
|
||||||
|
total_times = [f.total_ms for f in self._frames]
|
||||||
|
avg_total = sum(total_times) / len(total_times)
|
||||||
|
min_total = min(total_times)
|
||||||
|
max_total = max(total_times)
|
||||||
|
|
||||||
|
effect_stats: dict[str, dict] = {}
|
||||||
|
for frame in self._frames:
|
||||||
|
for effect in frame.effects:
|
||||||
|
if effect.name not in effect_stats:
|
||||||
|
effect_stats[effect.name] = {"times": [], "total_chars": 0}
|
||||||
|
effect_stats[effect.name]["times"].append(effect.duration_ms)
|
||||||
|
effect_stats[effect.name]["total_chars"] += effect.buffer_chars_out
|
||||||
|
|
||||||
|
for name, stats in effect_stats.items():
|
||||||
|
times = stats["times"]
|
||||||
|
stats["avg_ms"] = sum(times) / len(times)
|
||||||
|
stats["min_ms"] = min(times)
|
||||||
|
stats["max_ms"] = max(times)
|
||||||
|
del stats["times"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_count": len(self._frames),
|
||||||
|
"pipeline": {
|
||||||
|
"avg_ms": avg_total,
|
||||||
|
"min_ms": min_total,
|
||||||
|
"max_ms": max_total,
|
||||||
|
},
|
||||||
|
"effects": effect_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._frames.clear()
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
|
||||||
|
_monitor: PerformanceMonitor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor() -> PerformanceMonitor:
|
||||||
|
global _monitor
|
||||||
|
if _monitor is None:
|
||||||
|
_monitor = PerformanceMonitor()
|
||||||
|
return _monitor
|
||||||
|
|
||||||
|
|
||||||
|
def set_monitor(monitor: PerformanceMonitor) -> None:
|
||||||
|
global _monitor
|
||||||
|
_monitor = monitor
|
||||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from engine.effects.types import EffectConfig, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class EffectRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._plugins: dict[str, EffectPlugin] = {}
|
||||||
|
self._discovered: bool = False
|
||||||
|
|
||||||
|
def register(self, plugin: EffectPlugin) -> None:
|
||||||
|
self._plugins[plugin.name] = plugin
|
||||||
|
|
||||||
|
def get(self, name: str) -> EffectPlugin | None:
|
||||||
|
return self._plugins.get(name)
|
||||||
|
|
||||||
|
def list_all(self) -> dict[str, EffectPlugin]:
|
||||||
|
return self._plugins.copy()
|
||||||
|
|
||||||
|
def list_enabled(self) -> list[EffectPlugin]:
|
||||||
|
return [p for p in self._plugins.values() if p.config.enabled]
|
||||||
|
|
||||||
|
def enable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.configure(config)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_enabled(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
return plugin.config.enabled if plugin else False
|
||||||
|
|
||||||
|
|
||||||
|
_registry: EffectRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> EffectRegistry:
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = EffectRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def set_registry(registry: EffectRegistry) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
68
engine/effects/types.py
Normal file
68
engine/effects/types.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectContext:
|
||||||
|
terminal_width: int
|
||||||
|
terminal_height: int
|
||||||
|
scroll_cam: int
|
||||||
|
ticker_height: int
|
||||||
|
camera_x: int = 0
|
||||||
|
mic_excess: float = 0.0
|
||||||
|
grad_offset: float = 0.0
|
||||||
|
frame_number: int = 0
|
||||||
|
has_message: bool = False
|
||||||
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
intensity: float = 1.0
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPlugin(ABC):
|
||||||
|
name: str
|
||||||
|
config: EffectConfig
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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
|
||||||
|
class PipelineConfig:
|
||||||
|
order: list[str] = field(default_factory=list)
|
||||||
|
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||||
@@ -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
|
||||||
|
|||||||
67
engine/fetch_code.py
Normal file
67
engine/fetch_code.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
||||||
|
as scroll items. Used by --code mode.
|
||||||
|
Depends on: nothing (stdlib only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ENGINE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_map(source: str) -> dict[int, str]:
|
||||||
|
"""Return {line_number: scope_label} for every line in source.
|
||||||
|
|
||||||
|
Nodes are sorted by range size descending so inner scopes overwrite
|
||||||
|
outer ones, guaranteeing the narrowest enclosing scope wins.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
span = end - node.lineno
|
||||||
|
nodes.append((span, node))
|
||||||
|
|
||||||
|
# Largest range first → inner scopes overwrite on second pass
|
||||||
|
nodes.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
scope = {}
|
||||||
|
for _, node in nodes:
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
label = node.name
|
||||||
|
else:
|
||||||
|
label = f"{node.name}()"
|
||||||
|
for ln in range(node.lineno, end + 1):
|
||||||
|
scope[ln] = label
|
||||||
|
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_code():
|
||||||
|
"""Read engine/*.py and return (items, line_count, 0).
|
||||||
|
|
||||||
|
Each item is (text, src, ts) where:
|
||||||
|
text = the code line (rstripped, indentation preserved)
|
||||||
|
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
||||||
|
ts = dotted module path, e.g. 'engine.scroll'
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
||||||
|
module = f"engine.{path.stem}"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
scope = _scope_map(source)
|
||||||
|
for lineno, raw in enumerate(source.splitlines(), start=1):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
label = scope.get(lineno, "<module>")
|
||||||
|
items.append((raw.rstrip(), label, module))
|
||||||
|
|
||||||
|
return items, len(items), 0
|
||||||
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: ...
|
||||||
163
engine/layers.py
163
engine/layers.py
@@ -10,13 +10,15 @@ from datetime import datetime
|
|||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.effects import (
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectContext,
|
||||||
fade_line,
|
fade_line,
|
||||||
firehose_line,
|
firehose_line,
|
||||||
glitch_bar,
|
glitch_bar,
|
||||||
noise,
|
noise,
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
from engine.render import big_wrap, lr_gradient, msg_gradient
|
||||||
from engine.terminal import RST, W_COOL
|
from engine.terminal import RST, W_COOL
|
||||||
|
|
||||||
MSG_META = "\033[38;5;245m"
|
MSG_META = "\033[38;5;245m"
|
||||||
@@ -55,9 +57,7 @@ def render_message_overlay(
|
|||||||
else:
|
else:
|
||||||
msg_rows = msg_cache[1]
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
msg_rows = lr_gradient_opposite(
|
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)
|
||||||
@@ -199,3 +199,158 @@ def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
|||||||
fline = firehose_line(items, w)
|
fline = firehose_line(items, w)
|
||||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
return buf
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
_effect_chain = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_effects() -> None:
|
||||||
|
"""Initialize effect plugins and chain."""
|
||||||
|
global _effect_chain
|
||||||
|
from engine.effects import EffectChain, get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
import effects_plugins
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
_effect_chain = chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_effects(
|
||||||
|
buf: list[str],
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
mic_excess: float,
|
||||||
|
grad_offset: float,
|
||||||
|
frame_number: int,
|
||||||
|
has_message: bool,
|
||||||
|
items: list,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Process buffer through effect chain."""
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_h,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return _effect_chain.process(buf, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain() -> EffectChain | None:
|
||||||
|
"""Get the effect chain instance."""
|
||||||
|
global _effect_chain
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
return _effect_chain
|
||||||
|
|
||||||
|
|
||||||
|
def render_figment_overlay(
|
||||||
|
figment_state,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Render figment overlay as ANSI cursor-positioning commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI strings to append to display buffer.
|
||||||
|
"""
|
||||||
|
from engine.render import _color_codes_to_ansi
|
||||||
|
|
||||||
|
rows = figment_state.rows
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
phase = figment_state.phase
|
||||||
|
progress = figment_state.progress
|
||||||
|
gradient = figment_state.gradient
|
||||||
|
center_row = figment_state.center_row
|
||||||
|
center_col = figment_state.center_col
|
||||||
|
|
||||||
|
cols = _color_codes_to_ansi(gradient)
|
||||||
|
|
||||||
|
# Build a list of non-space cell positions
|
||||||
|
cell_positions = []
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
if ch != " ":
|
||||||
|
cell_positions.append((r_idx, c_idx))
|
||||||
|
|
||||||
|
n_cells = len(cell_positions)
|
||||||
|
if n_cells == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
||||||
|
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
||||||
|
shuffled = list(cell_positions)
|
||||||
|
rng.shuffle(shuffled)
|
||||||
|
|
||||||
|
# Phase-dependent visibility
|
||||||
|
from effects_plugins.figment import FigmentPhase
|
||||||
|
|
||||||
|
if phase == FigmentPhase.REVEAL:
|
||||||
|
visible_count = int(n_cells * progress)
|
||||||
|
visible = set(shuffled[:visible_count])
|
||||||
|
elif phase == FigmentPhase.HOLD:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
# Strobe: dim some cells periodically
|
||||||
|
if int(progress * 20) % 3 == 0:
|
||||||
|
dim_count = int(n_cells * 0.3)
|
||||||
|
visible -= set(shuffled[:dim_count])
|
||||||
|
elif phase == FigmentPhase.DISSOLVE:
|
||||||
|
remaining_count = int(n_cells * (1.0 - progress))
|
||||||
|
visible = set(shuffled[:remaining_count])
|
||||||
|
else:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
|
||||||
|
# Build overlay commands
|
||||||
|
overlay: list[str] = []
|
||||||
|
n_cols = len(cols)
|
||||||
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
scr_row = center_row + r_idx + 1 # 1-indexed
|
||||||
|
if scr_row < 1 or scr_row > h:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_buf: list[str] = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
scr_col = center_col + c_idx + 1
|
||||||
|
if scr_col < 1 or scr_col > w:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch != " " and (r_idx, c_idx) in visible:
|
||||||
|
# Apply gradient color
|
||||||
|
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
||||||
|
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
||||||
|
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
||||||
|
has_content = True
|
||||||
|
else:
|
||||||
|
line_buf.append(" ")
|
||||||
|
|
||||||
|
if has_content:
|
||||||
|
line_str = "".join(line_buf).rstrip()
|
||||||
|
if line_str.strip():
|
||||||
|
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
|||||||
131
engine/render.py
131
engine/render.py
@@ -15,38 +15,72 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
|||||||
from engine.terminal import RST
|
from engine.terminal import RST
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
|
||||||
# Left → right: white-hot leading edge fades to near-black
|
|
||||||
GRAD_COLS = [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
MSG_GRAD_COLS = [
|
def _color_codes_to_ansi(color_codes):
|
||||||
"\033[1;38;5;231m", # white
|
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
Args:
|
||||||
"\033[38;5;201m", # bright magenta
|
color_codes: List of 12 integers (256-color palette codes)
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
Returns:
|
||||||
"\033[38;5;125m", # dark magenta
|
List of ANSI escape code strings
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
"""
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
if not color_codes or len(color_codes) != 12:
|
||||||
"\033[2;38;5;235m", # near black
|
# Fallback to default green if invalid
|
||||||
]
|
return _default_green_gradient()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, code in enumerate(color_codes):
|
||||||
|
if i < 2:
|
||||||
|
# Bold for first 2 (bright leading edge)
|
||||||
|
result.append(f"\033[1;38;5;{code}m")
|
||||||
|
elif i < 10:
|
||||||
|
# Normal for middle 8
|
||||||
|
result.append(f"\033[38;5;{code}m")
|
||||||
|
else:
|
||||||
|
# Dim for last 2 (dark trailing edge)
|
||||||
|
result.append(f"\033[2;38;5;{code}m")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
@@ -189,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
def lr_gradient(rows, offset=0.0, cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
cols = grad_cols or GRAD_COLS
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
if config.ACTIVE_THEME:
|
||||||
|
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
||||||
|
else:
|
||||||
|
cols = _default_green_gradient()
|
||||||
n = len(cols)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -213,7 +253,30 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
return lr_gradient(rows, offset, _default_magenta_gradient())
|
||||||
|
|
||||||
|
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||||
|
|
||||||
|
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||||
|
falling back to default magenta if no theme is set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text strings to colorize
|
||||||
|
offset: Gradient offset (0.0-1.0) for animation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows with ANSI color codes applied
|
||||||
|
"""
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
cols = (
|
||||||
|
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
|||||||
@@ -4,33 +4,43 @@ Orchestrates viewport, frame timing, and layers.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
|
from engine.display import (
|
||||||
|
Display,
|
||||||
|
TerminalDisplay,
|
||||||
|
)
|
||||||
|
from engine.display import (
|
||||||
|
get_monitor as _get_display_monitor,
|
||||||
|
)
|
||||||
from engine.frame import calculate_scroll_step
|
from engine.frame import calculate_scroll_step
|
||||||
from engine.layers import (
|
from engine.layers import (
|
||||||
apply_glitch,
|
apply_glitch,
|
||||||
|
process_effects,
|
||||||
|
render_figment_overlay,
|
||||||
render_firehose,
|
render_firehose,
|
||||||
render_message_overlay,
|
render_message_overlay,
|
||||||
render_ticker_zone,
|
render_ticker_zone,
|
||||||
)
|
)
|
||||||
from engine.terminal import CLR
|
|
||||||
from engine.viewport import th, tw
|
from engine.viewport import th, tw
|
||||||
|
|
||||||
|
USE_EFFECT_CHAIN = True
|
||||||
|
|
||||||
def stream(items, ntfy_poller, mic_monitor):
|
|
||||||
|
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
|
if display is None:
|
||||||
|
display = TerminalDisplay()
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
sys.stdout.write(CLR)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
|
display.init(w, h)
|
||||||
|
display.clear()
|
||||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
ticker_view_h = h - fh
|
ticker_view_h = h - fh
|
||||||
GAP = 3
|
GAP = 3
|
||||||
@@ -42,6 +52,19 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
msg_cache = (None, None)
|
msg_cache = (None, None)
|
||||||
|
frame_number = 0
|
||||||
|
|
||||||
|
# Figment overlay (optional — requires cairosvg)
|
||||||
|
figment = None
|
||||||
|
if config.FIGMENT:
|
||||||
|
try:
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
|
||||||
|
figment = FigmentEffect()
|
||||||
|
figment.config.enabled = True
|
||||||
|
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
|
||||||
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if queued >= config.HEADLINE_LIMIT and not active:
|
if queued >= config.HEADLINE_LIMIT and not active:
|
||||||
@@ -93,19 +116,46 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
buf.extend(ticker_buf)
|
buf.extend(ticker_buf)
|
||||||
|
|
||||||
mic_excess = mic_monitor.excess
|
mic_excess = mic_monitor.excess
|
||||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
render_start = time.perf_counter()
|
||||||
|
|
||||||
firehose_buf = render_firehose(items, w, fh, h)
|
if USE_EFFECT_CHAIN:
|
||||||
buf.extend(firehose_buf)
|
buf = process_effects(
|
||||||
|
buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
scroll_cam,
|
||||||
|
ticker_h,
|
||||||
|
mic_excess,
|
||||||
|
grad_offset,
|
||||||
|
frame_number,
|
||||||
|
msg is not None,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||||
|
firehose_buf = render_firehose(items, w, fh, h)
|
||||||
|
buf.extend(firehose_buf)
|
||||||
|
|
||||||
|
# Figment overlay (between effects and ntfy message)
|
||||||
|
if figment and figment.config.enabled:
|
||||||
|
figment_state = figment.get_figment_state(frame_number, w, h)
|
||||||
|
if figment_state is not None:
|
||||||
|
figment_buf = render_figment_overlay(figment_state, w, h)
|
||||||
|
buf.extend(figment_buf)
|
||||||
|
|
||||||
if msg_overlay:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
sys.stdout.buffer.write("".join(buf).encode())
|
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||||
sys.stdout.flush()
|
monitor = _get_display_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars = sum(len(line) for line in buf)
|
||||||
|
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||||
|
|
||||||
|
display.show(buf)
|
||||||
|
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
|
frame_number += 1
|
||||||
|
|
||||||
sys.stdout.write(CLR)
|
display.cleanup()
|
||||||
sys.stdout.flush()
|
|
||||||
|
|||||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Theme definitions with color gradients for terminal rendering.
|
||||||
|
|
||||||
|
This module is data-only and does not import config or render
|
||||||
|
to prevent circular dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Represents a color theme with two gradients."""
|
||||||
|
|
||||||
|
def __init__(self, name, main_gradient, message_gradient):
|
||||||
|
"""Initialize a theme with name and color gradients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Theme identifier string
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||||
|
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||||
|
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||||
|
|
||||||
|
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||||
|
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||||
|
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id):
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object matching the ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in registry
|
||||||
|
"""
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
@@ -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 |
BIN
fonts/Kapiler.otf
Normal file
BIN
fonts/Kapiler.otf
Normal file
Binary file not shown.
BIN
fonts/Kapiler.ttf
Normal file
BIN
fonts/Kapiler.ttf
Normal file
Binary file not shown.
@@ -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 |
@@ -83,3 +83,35 @@ class TestStreamControllerCleanup:
|
|||||||
controller.cleanup()
|
controller.cleanup()
|
||||||
|
|
||||||
mock_mic_instance.stop.assert_called_once()
|
mock_mic_instance.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerWarmup:
|
||||||
|
"""Tests for StreamController topic warmup."""
|
||||||
|
|
||||||
|
def test_warmup_topics_idempotent(self):
|
||||||
|
"""warmup_topics can be called multiple times."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert mock_urlopen.call_count >= 3
|
||||||
|
|
||||||
|
def test_warmup_topics_sets_flag(self):
|
||||||
|
"""warmup_topics sets the warmed flag."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen"):
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert StreamController._topics_warmed is True
|
||||||
|
|
||||||
|
def test_warmup_topics_skips_after_first(self):
|
||||||
|
"""warmup_topics skips after first call."""
|
||||||
|
StreamController._topics_warmed = True
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
mock_urlopen.assert_not_called()
|
||||||
|
|||||||
79
tests/test_display.py
Normal file
79
tests/test_display.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.display import NullDisplay, TerminalDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayProtocol:
|
||||||
|
"""Test that display backends satisfy the Display protocol."""
|
||||||
|
|
||||||
|
def test_terminal_display_is_display(self):
|
||||||
|
"""TerminalDisplay satisfies Display protocol."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
def test_null_display_is_display(self):
|
||||||
|
"""NullDisplay satisfies Display protocol."""
|
||||||
|
display = NullDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDisplay:
|
||||||
|
"""Tests for TerminalDisplay class."""
|
||||||
|
|
||||||
|
def test_init_sets_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_show_returns_none(self):
|
||||||
|
"""show returns None after writing to stdout."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.width = 80
|
||||||
|
display.height = 24
|
||||||
|
display.show(["line1", "line2"])
|
||||||
|
|
||||||
|
def test_clear_does_not_error(self):
|
||||||
|
"""clear works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_not_error(self):
|
||||||
|
"""cleanup works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNullDisplay:
|
||||||
|
"""Tests for NullDisplay class."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores dimensions."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.init(100, 50)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 50
|
||||||
|
|
||||||
|
def test_show_does_nothing(self):
|
||||||
|
"""show discards buffer without error."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.show(["line1", "line2", "line3"])
|
||||||
|
|
||||||
|
def test_clear_does_nothing(self):
|
||||||
|
"""clear does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_nothing(self):
|
||||||
|
"""cleanup does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.cleanup()
|
||||||
427
tests/test_effects.py
Normal file
427
tests/test_effects.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class MockEffect:
|
||||||
|
name = "mock"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processed = False
|
||||||
|
self.last_ctx = None
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
self.processed = True
|
||||||
|
self.last_ctx = ctx
|
||||||
|
return buf + ["processed"]
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectConfig:
|
||||||
|
def test_defaults(self):
|
||||||
|
cfg = EffectConfig()
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.intensity == 1.0
|
||||||
|
assert cfg.params == {}
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.intensity == 0.5
|
||||||
|
assert cfg.params == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectContext:
|
||||||
|
def test_defaults(self):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
assert ctx.terminal_width == 80
|
||||||
|
assert ctx.terminal_height == 24
|
||||||
|
assert ctx.ticker_height == 20
|
||||||
|
assert ctx.items == []
|
||||||
|
|
||||||
|
def test_with_items(self):
|
||||||
|
items = [("Title", "Source", "12:00")]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
assert ctx.items == items
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectRegistry:
|
||||||
|
def test_init_empty(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert len(registry.list_all()) == 0
|
||||||
|
|
||||||
|
def test_register(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
assert "mock" in registry.list_all()
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
retrieved = registry.get("mock")
|
||||||
|
assert retrieved is effect
|
||||||
|
|
||||||
|
def test_get_nonexistent(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_enable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
registry.enable("mock")
|
||||||
|
assert effect.config.enabled is True
|
||||||
|
|
||||||
|
def test_disable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
registry.disable("mock")
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
|
||||||
|
def test_list_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
|
||||||
|
class EnabledEffect:
|
||||||
|
name = "enabled_effect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
class DisabledEffect:
|
||||||
|
name = "disabled_effect"
|
||||||
|
config = EffectConfig(enabled=False, intensity=1.0)
|
||||||
|
|
||||||
|
registry.register(EnabledEffect())
|
||||||
|
registry.register(DisabledEffect())
|
||||||
|
enabled = registry.list_enabled()
|
||||||
|
assert len(enabled) == 1
|
||||||
|
assert enabled[0].name == "enabled_effect"
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||||
|
registry.configure("mock", new_config)
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
assert effect.config.intensity == 0.3
|
||||||
|
|
||||||
|
def test_is_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
assert registry.is_enabled("mock") is True
|
||||||
|
assert registry.is_enabled("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectChain:
|
||||||
|
def test_init(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
assert chain.get_order() == []
|
||||||
|
|
||||||
|
def test_set_order(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2"])
|
||||||
|
assert chain.get_order() == ["effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_add_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.add_effect("test_effect")
|
||||||
|
assert "test_effect" in chain.get_order()
|
||||||
|
|
||||||
|
def test_add_effect_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.add_effect("nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_remove_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
chain.remove_effect("test_effect")
|
||||||
|
assert "test_effect" not in chain.get_order()
|
||||||
|
|
||||||
|
def test_reorder(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
effect3 = MockEffect()
|
||||||
|
effect3.name = "effect3"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
registry.register(effect3)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2", "effect3"])
|
||||||
|
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||||
|
assert result is True
|
||||||
|
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_reorder_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "effect1"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.reorder(["effect1", "nonexistent"])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_process_empty_chain(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == buf
|
||||||
|
|
||||||
|
def test_process_with_effects(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1", "line2", "processed"]
|
||||||
|
assert effect.processed is True
|
||||||
|
assert effect.last_ctx is ctx
|
||||||
|
|
||||||
|
def test_process_disabled_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1"]
|
||||||
|
assert effect.processed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectsExports:
|
||||||
|
def test_all_exports_are_importable(self):
|
||||||
|
"""Verify all exports in __all__ can actually be imported."""
|
||||||
|
import engine.effects as effects_module
|
||||||
|
|
||||||
|
for name in effects_module.__all__:
|
||||||
|
getattr(effects_module, name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceMonitor:
|
||||||
|
def test_empty_stats(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
def test_record_and_retrieve(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||||
|
monitor.end_frame(1, 2.0)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["frame_count"] == 1
|
||||||
|
assert "test_effect" in stats["effects"]
|
||||||
|
|
||||||
|
def test_multiple_frames(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=3)
|
||||||
|
for i in range(5):
|
||||||
|
monitor.start_frame(i)
|
||||||
|
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||||
|
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||||
|
monitor.end_frame(i, 1.5)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert stats["frame_count"] == 3
|
||||||
|
assert "effect1" in stats["effects"]
|
||||||
|
assert "effect2" in stats["effects"]
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test", 1.0, 100, 100)
|
||||||
|
monitor.end_frame(1, 1.0)
|
||||||
|
|
||||||
|
monitor.reset()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectPipelinePerformance:
|
||||||
|
def test_pipeline_stays_within_frame_budget(self):
|
||||||
|
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyEffect:
|
||||||
|
name = "dummy"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
return [line * 2 for line in buf]
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(DummyEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=10)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["dummy"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 20
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["pipeline"]["max_ms"] < 33.0
|
||||||
|
|
||||||
|
def test_individual_effects_performance(self):
|
||||||
|
"""Verify individual effects don't exceed 10ms per frame."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SlowEffect:
|
||||||
|
name = "slow"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
result = []
|
||||||
|
for line in buf:
|
||||||
|
result.append(line)
|
||||||
|
result.append(line + line)
|
||||||
|
return result
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(SlowEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=5)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["slow"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 10
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||||
117
tests/test_effects_controller.py
Normal file
117
tests/test_effects_controller.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects.controller module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.effects.controller import (
|
||||||
|
handle_effects_command,
|
||||||
|
set_effect_chain_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleEffectsCommand:
|
||||||
|
"""Tests for handle_effects_command function."""
|
||||||
|
|
||||||
|
def test_list_effects(self):
|
||||||
|
"""list command returns formatted effects list."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.enabled = True
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain.return_value.get_order.return_value = ["noise"]
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects list")
|
||||||
|
|
||||||
|
assert "noise: ON" in result
|
||||||
|
assert "intensity=0.5" in result
|
||||||
|
|
||||||
|
def test_enable_effect(self):
|
||||||
|
"""enable command calls registry.enable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise on")
|
||||||
|
|
||||||
|
assert "Enabled: noise" in result
|
||||||
|
mock_registry.return_value.enable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_disable_effect(self):
|
||||||
|
"""disable command calls registry.disable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise off")
|
||||||
|
|
||||||
|
assert "Disabled: noise" in result
|
||||||
|
mock_registry.return_value.disable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_set_intensity(self):
|
||||||
|
"""intensity command sets plugin intensity."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 0.8")
|
||||||
|
|
||||||
|
assert "intensity to 0.8" in result
|
||||||
|
assert mock_plugin.config.intensity == 0.8
|
||||||
|
|
||||||
|
def test_invalid_intensity_range(self):
|
||||||
|
"""intensity outside 0.0-1.0 returns error."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 1.5")
|
||||||
|
|
||||||
|
assert "between 0.0 and 1.0" in result
|
||||||
|
|
||||||
|
def test_reorder_pipeline(self):
|
||||||
|
"""reorder command calls chain.reorder."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_registry.return_value.list_all.return_value = {}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain_instance = MagicMock()
|
||||||
|
mock_chain_instance.reorder.return_value = True
|
||||||
|
mock_chain.return_value = mock_chain_instance
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects reorder noise,fade")
|
||||||
|
|
||||||
|
assert "Reordered pipeline" in result
|
||||||
|
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
||||||
|
|
||||||
|
def test_unknown_command(self):
|
||||||
|
"""unknown command returns error."""
|
||||||
|
result = handle_effects_command("/unknown")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
def test_non_effects_command(self):
|
||||||
|
"""non-effects command returns error."""
|
||||||
|
result = handle_effects_command("not a command")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetEffectChainRef:
|
||||||
|
"""Tests for set_effect_chain_ref function."""
|
||||||
|
|
||||||
|
def test_sets_global_ref(self):
|
||||||
|
"""set_effect_chain_ref updates global reference."""
|
||||||
|
mock_chain = MagicMock()
|
||||||
|
set_effect_chain_ref(mock_chain)
|
||||||
|
|
||||||
|
from engine.effects.controller import _get_effect_chain
|
||||||
|
|
||||||
|
result = _get_effect_chain()
|
||||||
|
assert result == mock_chain
|
||||||
35
tests/test_fetch_code.py
Normal file
35
tests/test_fetch_code.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_shape():
|
||||||
|
items, line_count, ignored = fetch_code()
|
||||||
|
assert isinstance(items, list)
|
||||||
|
assert line_count == len(items)
|
||||||
|
assert ignored == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_are_tuples():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
assert items, "expected at least one code line"
|
||||||
|
for item in items:
|
||||||
|
assert isinstance(item, tuple) and len(item) == 3
|
||||||
|
text, src, ts = item
|
||||||
|
assert isinstance(text, str)
|
||||||
|
assert isinstance(src, str)
|
||||||
|
assert isinstance(ts, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_comment_lines_excluded():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
for text, _, _ in items:
|
||||||
|
assert text.strip(), "blank line should have been filtered"
|
||||||
|
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_path_format():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
pattern = re.compile(r"^engine\.\w+$")
|
||||||
|
for _, _, ts in items:
|
||||||
|
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
||||||
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"
|
||||||
301
tests/test_render.py
Normal file
301
tests/test_render.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.render module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultGradients:
|
||||||
|
"""Tests for default gradient fallback functions."""
|
||||||
|
|
||||||
|
def test_default_green_gradient_length(self):
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_green_gradient_is_list(self):
|
||||||
|
"""_default_green_gradient returns a list."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_green_gradient_all_strings(self):
|
||||||
|
"""_default_green_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_length(self):
|
||||||
|
"""_default_magenta_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_is_list(self):
|
||||||
|
"""_default_magenta_gradient returns a list."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_all_strings(self):
|
||||||
|
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientUsesActiveTheme:
|
||||||
|
"""Tests for lr_gradient using active theme."""
|
||||||
|
|
||||||
|
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||||
|
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (cols=None)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(self):
|
||||||
|
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (should use fallback)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||||
|
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||||
|
# Custom gradient
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
# Should use the provided cols
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||||
|
"""lr_gradient accepts cols as keyword argument."""
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
# Call with cols as keyword
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientBasicFunctionality:
|
||||||
|
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||||
|
|
||||||
|
def test_lr_gradient_colors_non_space_chars(self):
|
||||||
|
"""lr_gradient colors non-space characters."""
|
||||||
|
rows = ["hello"]
|
||||||
|
|
||||||
|
# Set a theme for the test
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Result should have ANSI codes
|
||||||
|
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_preserves_spaces(self):
|
||||||
|
"""lr_gradient preserves spaces in output."""
|
||||||
|
rows = ["a b c"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved (not colored)
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_empty_rows(self):
|
||||||
|
"""lr_gradient handles empty rows correctly."""
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_multiple_rows(self):
|
||||||
|
"""lr_gradient handles multiple rows."""
|
||||||
|
rows = ["row1", "row2", "row3"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
|
||||||
|
class TestMsgGradient:
|
||||||
|
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||||
|
|
||||||
|
def test_msg_gradient_uses_active_theme(self):
|
||||||
|
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using theme's message_gradient
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes from the message gradient
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_fallback_when_no_theme(self):
|
||||||
|
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using default magenta
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_returns_colored_rows(self):
|
||||||
|
"""msg_gradient returns properly colored rows with animation offset."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
rows = ["NTFY", "ALERT"]
|
||||||
|
|
||||||
|
# Call with offset
|
||||||
|
result = render.msg_gradient(rows, offset=0.5)
|
||||||
|
|
||||||
|
# Should return same number of rows
|
||||||
|
assert len(result) == 2
|
||||||
|
# Both should be colored
|
||||||
|
assert all("\033[" in r for r in result)
|
||||||
|
# Should not be the original text
|
||||||
|
assert result != rows
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||||
|
"""msg_gradient produces different colors for different themes."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = ["TEST"]
|
||||||
|
|
||||||
|
# Get result with green theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result_green = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Get result with orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Results should be different (different message gradients)
|
||||||
|
assert result_green != result_orange
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_preserves_spacing(self):
|
||||||
|
"""msg_gradient preserves spaces in rows."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("purple")
|
||||||
|
rows = ["M E S S A G E"]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_empty_rows(self):
|
||||||
|
"""msg_gradient handles empty rows correctly."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Empty row should stay empty
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
169
tests/test_themes.py
Normal file
169
tests/test_themes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.themes module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeConstruction:
|
||||||
|
"""Tests for Theme class initialization."""
|
||||||
|
|
||||||
|
def test_theme_construction(self):
|
||||||
|
"""Theme stores name and gradients correctly."""
|
||||||
|
main_grad = ["color1", "color2", "color3"]
|
||||||
|
msg_grad = ["msg1", "msg2", "msg3"]
|
||||||
|
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||||
|
|
||||||
|
assert theme.name == "test_theme"
|
||||||
|
assert theme.main_gradient == main_grad
|
||||||
|
assert theme.message_gradient == msg_grad
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradientLength:
|
||||||
|
"""Tests for gradient length validation."""
|
||||||
|
|
||||||
|
def test_gradient_length_green(self):
|
||||||
|
"""Green theme has exactly 12 colors in each gradient."""
|
||||||
|
green = themes.THEME_REGISTRY["green"]
|
||||||
|
assert len(green.main_gradient) == 12
|
||||||
|
assert len(green.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_orange(self):
|
||||||
|
"""Orange theme has exactly 12 colors in each gradient."""
|
||||||
|
orange = themes.THEME_REGISTRY["orange"]
|
||||||
|
assert len(orange.main_gradient) == 12
|
||||||
|
assert len(orange.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_purple(self):
|
||||||
|
"""Purple theme has exactly 12 colors in each gradient."""
|
||||||
|
purple = themes.THEME_REGISTRY["purple"]
|
||||||
|
assert len(purple.main_gradient) == 12
|
||||||
|
assert len(purple.message_gradient) == 12
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeRegistry:
|
||||||
|
"""Tests for THEME_REGISTRY dictionary."""
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes(self):
|
||||||
|
"""Registry contains exactly three themes: green, orange, purple."""
|
||||||
|
assert len(themes.THEME_REGISTRY) == 3
|
||||||
|
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||||
|
|
||||||
|
def test_registry_values_are_themes(self):
|
||||||
|
"""All registry values are Theme instances."""
|
||||||
|
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||||
|
assert isinstance(theme, themes.Theme)
|
||||||
|
assert theme.name == theme_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTheme:
|
||||||
|
"""Tests for get_theme function."""
|
||||||
|
|
||||||
|
def test_get_theme_valid_green(self):
|
||||||
|
"""get_theme('green') returns correct green Theme."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert isinstance(green, themes.Theme)
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
def test_get_theme_valid_orange(self):
|
||||||
|
"""get_theme('orange') returns correct orange Theme."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert isinstance(orange, themes.Theme)
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
def test_get_theme_valid_purple(self):
|
||||||
|
"""get_theme('purple') returns correct purple Theme."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert isinstance(purple, themes.Theme)
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
def test_get_theme_invalid(self):
|
||||||
|
"""get_theme with invalid ID raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme("invalid_theme")
|
||||||
|
|
||||||
|
def test_get_theme_invalid_none(self):
|
||||||
|
"""get_theme with None raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreenTheme:
|
||||||
|
"""Tests for green theme specific values."""
|
||||||
|
|
||||||
|
def test_green_theme_unchanged(self):
|
||||||
|
"""Green theme maintains original color sequence."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||||
|
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||||
|
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
assert green.main_gradient == expected_main
|
||||||
|
assert green.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_green_theme_name(self):
|
||||||
|
"""Green theme has correct name."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrangeTheme:
|
||||||
|
"""Tests for orange theme specific values."""
|
||||||
|
|
||||||
|
def test_orange_theme_unchanged(self):
|
||||||
|
"""Orange theme maintains original color sequence."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||||
|
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||||
|
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
assert orange.main_gradient == expected_main
|
||||||
|
assert orange.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_orange_theme_name(self):
|
||||||
|
"""Orange theme has correct name."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurpleTheme:
|
||||||
|
"""Tests for purple theme specific values."""
|
||||||
|
|
||||||
|
def test_purple_theme_unchanged(self):
|
||||||
|
"""Purple theme maintains original color sequence."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||||
|
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||||
|
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
assert purple.main_gradient == expected_main
|
||||||
|
assert purple.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_purple_theme_name(self):
|
||||||
|
"""Purple theme has correct name."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeDataOnly:
|
||||||
|
"""Tests to ensure themes module has no problematic imports."""
|
||||||
|
|
||||||
|
def test_themes_module_imports(self):
|
||||||
|
"""themes module should be data-only without config/render imports."""
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(themes)
|
||||||
|
# Verify no imports of config or render (look for actual import statements)
|
||||||
|
lines = source.split('\n')
|
||||||
|
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||||
|
# Filter out empty and comment lines
|
||||||
|
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||||
|
# Should have no import lines
|
||||||
|
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||||
Reference in New Issue
Block a user