Compare commits
8 Commits
a25b80d4a6
...
effects_pl
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fb06e0be | |||
| 020eb663ea | |||
| dd282653b5 | |||
| 35e5a71930 | |||
| 3551cc249f | |||
| fba183526a | |||
| 7193e7487b | |||
| b5d6eeedc0 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,4 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.DS_Store
|
coverage.xml
|
||||||
|
|||||||
159
AGENTS.md
159
AGENTS.md
@@ -22,13 +22,37 @@ uv sync
|
|||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Development
|
||||||
mise run test # Run tests
|
mise run test # Run tests
|
||||||
mise run test-v # Run tests verbose
|
mise run test-v # Run tests verbose
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run lint # Run ruff linter
|
mise run lint # Run ruff linter
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run format # Run ruff formatter
|
mise run format # Run ruff formatter
|
||||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
mise run ci # Full CI pipeline
|
||||||
|
|
||||||
|
# Runtime
|
||||||
|
mise run run # Interactive terminal mode (news)
|
||||||
|
mise run run-poetry # Interactive terminal mode (poetry)
|
||||||
|
mise run run-firehose # Dense headline mode
|
||||||
|
|
||||||
|
# Daemon mode (recommended for long-running)
|
||||||
|
mise run daemon # Start mainline in background
|
||||||
|
mise run daemon-stop # Stop daemon
|
||||||
|
mise run daemon-restart # Restart daemon
|
||||||
|
|
||||||
|
# Command & Control
|
||||||
|
mise run cmd # Interactive CLI
|
||||||
|
mise run cmd "/cmd" # Send single command
|
||||||
|
mise run cmd-stats # Watch performance stats
|
||||||
|
mise run topics-init # Initialize ntfy topics
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
mise run install # Install dependencies
|
||||||
|
mise run sync # Sync dependencies
|
||||||
|
mise run sync-all # Sync with all extras
|
||||||
|
mise run clean # Clean cache files
|
||||||
|
mise run clobber # Aggressive cleanup (git clean -fdx + caches)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
## Git Hooks
|
||||||
@@ -108,3 +132,136 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay)
|
||||||
|
|
||||||
|
## Operating Modes
|
||||||
|
|
||||||
|
Mainline can run in two modes:
|
||||||
|
|
||||||
|
### 1. Standalone Mode (Original)
|
||||||
|
Run directly as a terminal application with interactive pickers:
|
||||||
|
```bash
|
||||||
|
mise run run # news stream
|
||||||
|
mise run run-poetry # poetry mode
|
||||||
|
mise run run-firehose # dense headline mode
|
||||||
|
```
|
||||||
|
This runs the full interactive experience with font picker and effects picker at startup.
|
||||||
|
|
||||||
|
### 2. Daemon + Command Mode (Recommended for Long-Running)
|
||||||
|
|
||||||
|
The recommended approach for persistent displays:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the daemon (headless rendering)
|
||||||
|
mise run daemon
|
||||||
|
|
||||||
|
# Send commands via ntfy
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise off"
|
||||||
|
mise run cmd "/effects stats"
|
||||||
|
|
||||||
|
# Watch mode (continuous stats polling)
|
||||||
|
mise run cmd-stats
|
||||||
|
|
||||||
|
# Stop the daemon
|
||||||
|
mise run daemon-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
|
||||||
|
- **Daemon**: Runs `mainline.py` in the background, renders to terminal
|
||||||
|
- **C&C Topics**: Uses separate ntfy topics (like UART serial):
|
||||||
|
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
|
||||||
|
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
|
||||||
|
- **Topics are auto-warmed** on first daemon start
|
||||||
|
|
||||||
|
#### 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.5 - Set effect intensity (0.0-1.0)
|
||||||
|
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Effects Plugin System
|
||||||
|
|
||||||
|
The effects system is implemented as a plugin architecture in `engine/effects/`.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `effects/types.py` | `EffectConfig`, `EffectContext` dataclasses and `EffectPlugin` protocol |
|
||||||
|
| `effects/registry.py` | Plugin discovery and management (`EffectRegistry`) |
|
||||||
|
| `effects/chain.py` | Ordered pipeline execution (`EffectChain`) |
|
||||||
|
| `effects_plugins/*.py` | Externalized effect plugins |
|
||||||
|
|
||||||
|
### Creating a New Effect
|
||||||
|
|
||||||
|
Create a file in `effects_plugins/` with a class ending in `Effect`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext
|
||||||
|
|
||||||
|
class MyEffect:
|
||||||
|
name = "myeffect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
# Process buffer and return modified buffer
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
```
|
||||||
|
|
||||||
|
### NTFY Commands
|
||||||
|
|
||||||
|
Send commands via `cmdline.py` or directly to the C&C topic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using cmdline tool (recommended)
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise on"
|
||||||
|
mise run cmd "/effects noise intensity 0.5"
|
||||||
|
mise run cmd "/effects reorder noise,glitch,fade,firehose"
|
||||||
|
|
||||||
|
# Or directly via curl
|
||||||
|
curl -d "/effects list" https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The cmdline tool polls the response topic for the daemon's reply.
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: A new feature
|
||||||
|
- `fix`: A bug fix
|
||||||
|
- `docs`: Documentation only changes
|
||||||
|
- `style`: Changes that don't affect code meaning (formatting)
|
||||||
|
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Changes to build process, dependencies, etc.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(effects): add plugin architecture for visual effects
|
||||||
|
fix(layers): resolve glitch effect not applying on empty buffer
|
||||||
|
docs(AGENTS.md): add effects plugin system documentation
|
||||||
|
test(effects): add tests for EffectChain pipeline ordering
|
||||||
|
```
|
||||||
|
|||||||
396
README.md
396
README.md
@@ -2,50 +2,17 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
|
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contents
|
## Run
|
||||||
|
|
||||||
- [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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Using
|
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 mainline.py # news stream
|
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
|
||||||
@@ -53,28 +20,51 @@ python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
|||||||
python3 mainline.py --font-index 1 # select face index within a collection
|
python3 mainline.py --font-index 1 # select face index within a collection
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with uv:
|
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daemon Mode (Recommended for Long-Running)
|
||||||
|
|
||||||
|
For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run mainline.py
|
# Start the daemon (runs in background, auto-warms ntfy topics)
|
||||||
|
mise run daemon
|
||||||
|
|
||||||
|
# Send commands via cmdline
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise off"
|
||||||
|
mise run cmd "/effects noise intensity 0.5"
|
||||||
|
|
||||||
|
# Watch performance stats continuously
|
||||||
|
mise run cmd-stats
|
||||||
|
|
||||||
|
# Stop the daemon
|
||||||
|
mise run daemon-stop
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
### How It Works
|
||||||
|
|
||||||
### Command & Control (C&C)
|
- **Topics**: Uses separate ntfy topics for serial-like communication:
|
||||||
|
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
|
||||||
|
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
|
||||||
|
- Topics are automatically created on first daemon start
|
||||||
|
|
||||||
Control mainline remotely using `cmdline.py`:
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```
|
||||||
uv run cmdline.py # Interactive TUI
|
/effects list - List all effects and status
|
||||||
uv run cmdline.py /effects list # List all effects
|
/effects <name> on - Enable an effect
|
||||||
uv run cmdline.py /effects stats # Show performance stats
|
/effects <name> off - Disable an effect
|
||||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
|
||||||
|
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
---
|
||||||
|
|
||||||
### Config
|
## Config
|
||||||
|
|
||||||
All constants live in `engine/config.py`:
|
All constants live in `engine/config.py`:
|
||||||
|
|
||||||
@@ -83,98 +73,34 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
|
||||||
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
|
||||||
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||||
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor |
|
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
|
||||||
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
||||||
|
| `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
|
|
||||||
|
|
||||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
|
||||||
|
|
||||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
|
||||||
|
|
||||||
### Fonts
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
To push a message:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
|
||||||
```
|
|
||||||
|
|
||||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
|
||||||
|
|
||||||
### Figment Mode
|
|
||||||
|
|
||||||
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
|
|
||||||
|
|
||||||
**Enable it** with the `--figment` flag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run mainline.py --figment # glyph every 60 seconds (default)
|
|
||||||
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
|
|
||||||
|
|
||||||
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.figment_trigger import FigmentAction, FigmentCommand
|
|
||||||
|
|
||||||
class MyTrigger:
|
|
||||||
def poll(self):
|
|
||||||
if some_condition:
|
|
||||||
return FigmentCommand(action=FigmentAction.TRIGGER)
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
|
|
||||||
|
|
||||||
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync --extra figment # adds cairosvg
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Internals
|
## Fonts
|
||||||
|
|
||||||
### How it works
|
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
|
|
||||||
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||||
@@ -183,158 +109,95 @@ uv sync --extra figment # adds cairosvg
|
|||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
- An ntfy.sh poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
|
|
||||||
|
|
||||||
### Architecture
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||||
|
|
||||||
```
|
```
|
||||||
engine/
|
engine/
|
||||||
__init__.py package marker
|
__init__.py package marker
|
||||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
config.py constants, CLI flags, glyph tables
|
config.py constants, CLI flags, glyph tables
|
||||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
filter.py HTML stripping, content filter
|
filter.py HTML stripping, content filter
|
||||||
translate.py Google Translate wrapper + region detection
|
translate.py Google Translate wrapper + region detection
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
effects/ plugin architecture for visual effects
|
effects/ plugin-based effects system
|
||||||
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
types.py EffectConfig, EffectContext, EffectPlugin protocol
|
||||||
registry.py effect registration and lookup
|
registry.py Plugin discovery and management
|
||||||
chain.py effect pipeline chaining
|
chain.py Ordered pipeline execution
|
||||||
controller.py handles /effects commands
|
performance.py Performance monitoring
|
||||||
performance.py performance monitoring
|
controller.py NTFY command handler
|
||||||
legacy.py legacy functional effects
|
legacy.py Original effects (noise, glitch, fade, firehose)
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
effects_plugins/ External effect plugins (noise, glitch, fade, firehose)
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
display.py Swappable display backends (TerminalDisplay, NullDisplay)
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
scroll.py stream() frame loop + message rendering
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
frame.py scroll step calculation, timing
|
scroll.py stream() frame loop + message rendering
|
||||||
layers.py ticker zone, firehose, message + figment overlay rendering
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
|
frame.py scroll step calculation, timing
|
||||||
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
|
layers.py ticker zone, firehose, message overlay rendering
|
||||||
eventbus.py thread-safe event publishing for decoupled communication
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
events.py event types and definitions
|
events.py event types and definitions
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
emitters.py background emitters for ntfy and mic
|
emitters.py background emitters for ntfy and mic
|
||||||
types.py type definitions and dataclasses
|
types.py type definitions and dataclasses
|
||||||
themes.py THEME_REGISTRY — gradient color definitions
|
```
|
||||||
display/ Display backend system
|
|
||||||
__init__.py DisplayRegistry, get_monitor
|
|
||||||
backends/
|
|
||||||
terminal.py ANSI terminal display
|
|
||||||
websocket.py WebSocket server for browser clients
|
|
||||||
sixel.py Sixel graphics (pure Python)
|
|
||||||
null.py headless display for testing
|
|
||||||
multi.py forwards to multiple displays
|
|
||||||
benchmark.py performance benchmarking tool
|
|
||||||
|
|
||||||
effects_plugins/
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
__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
|
---
|
||||||
|
|
||||||
|
## Feeds
|
||||||
|
|
||||||
|
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
||||||
|
|
||||||
|
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy.sh Integration
|
||||||
|
|
||||||
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
|
|
||||||
|
To push a message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
|
poller.start()
|
||||||
|
# in render loop:
|
||||||
|
msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Ideas / Future
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync # minimal (no mic, no figment)
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
With [mise](https://mise.jdx.dev/):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run test # run test suite
|
|
||||||
mise run test-cov # run with coverage report
|
|
||||||
|
|
||||||
mise run lint # ruff check
|
|
||||||
mise run lint-fix # ruff check --fix
|
|
||||||
mise run format # ruff format
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run ruff check engine/ mainline.py
|
|
||||||
uv run ruff format engine/ mainline.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Pre-commit hooks run lint automatically via `hk`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- Concurrent feed fetching with ThreadPoolExecutor
|
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||||
- Background feed refresh daemon
|
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||||
- Translation pre-fetch during boot
|
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- Matrix rain katakana underlay
|
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||||
- CRT scanline simulation
|
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||||
- Sixel/iTerm2 inline images
|
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||||
- Parallax secondary column
|
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
|
|
||||||
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
|
|
||||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||||
@@ -348,4 +211,5 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
||||||
|
# test
|
||||||
|
|||||||
@@ -1,894 +0,0 @@
|
|||||||
# 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
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,145 +0,0 @@
|
|||||||
# README Update Design — 2026-03-15
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Restructure and expand `README.md` to:
|
|
||||||
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
|
||||||
2. Add extensibility-focused content (`Extending` section)
|
|
||||||
3. Add developer workflow coverage (`Development` section)
|
|
||||||
4. Improve navigability via top-level grouping (Approach C)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
# MAINLINE
|
|
||||||
> tagline + description
|
|
||||||
|
|
||||||
## Using
|
|
||||||
### Run
|
|
||||||
### Config
|
|
||||||
### Feeds
|
|
||||||
### Fonts
|
|
||||||
### ntfy.sh
|
|
||||||
|
|
||||||
## Internals
|
|
||||||
### How it works
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
## Extending
|
|
||||||
### NtfyPoller
|
|
||||||
### MicMonitor
|
|
||||||
### Render pipeline
|
|
||||||
|
|
||||||
## Development
|
|
||||||
### Setup
|
|
||||||
### Tasks
|
|
||||||
### Testing
|
|
||||||
### Linting
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
---
|
|
||||||
*footer*
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section-by-section design
|
|
||||||
|
|
||||||
### Using
|
|
||||||
|
|
||||||
All existing content preserved verbatim. Two changes:
|
|
||||||
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
|
||||||
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
|
||||||
|
|
||||||
Subsections moved into Using (currently standalone):
|
|
||||||
- `Feeds` — it's configuration, not a concept
|
|
||||||
- `ntfy.sh` (usage half)
|
|
||||||
|
|
||||||
### Internals
|
|
||||||
|
|
||||||
All existing content preserved verbatim. One change:
|
|
||||||
- **Architecture**: append `tests/` directory listing to the module tree
|
|
||||||
|
|
||||||
### Extending
|
|
||||||
|
|
||||||
Entirely new section. Three subsections:
|
|
||||||
|
|
||||||
**NtfyPoller**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: stdlib only dependencies
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**MicMonitor**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: sounddevice/numpy optional, degrades gracefully
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.mic import MicMonitor
|
|
||||||
|
|
||||||
mic = MicMonitor(threshold_db=50)
|
|
||||||
if mic.start(): # returns False if sounddevice unavailable
|
|
||||||
excess = mic.excess # dB above threshold, clamped to 0
|
|
||||||
db = mic.db # raw RMS dB level
|
|
||||||
```
|
|
||||||
|
|
||||||
**Render pipeline**
|
|
||||||
- Brief prose about `engine.render` as importable pipeline
|
|
||||||
- Minimal sketch of serve.py / ESP32 usage pattern
|
|
||||||
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Entirely new section. Four subsections:
|
|
||||||
|
|
||||||
**Setup**
|
|
||||||
- Hard requirements: Python 3.10+, uv
|
|
||||||
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
|
||||||
|
|
||||||
**Tasks** (via mise)
|
|
||||||
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
|
||||||
|
|
||||||
**Testing**
|
|
||||||
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
|
||||||
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
|
||||||
|
|
||||||
**Linting**
|
|
||||||
- `uv run ruff check` and `uv run ruff format`
|
|
||||||
- Note: pre-commit hooks run lint via `hk`
|
|
||||||
|
|
||||||
### Roadmap
|
|
||||||
|
|
||||||
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
|
||||||
|
|
||||||
### Footer
|
|
||||||
|
|
||||||
Update `Python 3.9+` → `Python 3.10+`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `README.md` — restructured and expanded as above
|
|
||||||
- No other files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What is not changing
|
|
||||||
|
|
||||||
- All existing prose, examples, and config table values — preserved verbatim where retained
|
|
||||||
- The Ideas/Future content — kept intact under the new Roadmap heading
|
|
||||||
- The cyberpunk voice and terse style of the existing README
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# Figment Mode Design Spec
|
|
||||||
|
|
||||||
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
|
||||||
|
|
||||||
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
|
||||||
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
|
||||||
- Random color from existing theme gradients (green, orange, purple)
|
|
||||||
- Configurable interval and duration via C&C
|
|
||||||
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Multi-figment simultaneous display (one at a time)
|
|
||||||
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
|
||||||
- Custom color palettes beyond existing themes
|
|
||||||
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
|
||||||
|
|
||||||
## Architecture: Hybrid Plugin + Overlay
|
|
||||||
|
|
||||||
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
|
||||||
|
|
||||||
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
|
||||||
|
|
||||||
### Component Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
+-------------------+
|
|
||||||
| FigmentTrigger | (Protocol)
|
|
||||||
| - NtfyTrigger | (v1)
|
|
||||||
| - MqttTrigger | (future)
|
|
||||||
| - SerialTrigger | (future)
|
|
||||||
+--------+----------+
|
|
||||||
|
|
|
||||||
| FigmentCommand
|
|
||||||
v
|
|
||||||
+------------------+ +-----------------+ +----------------------+
|
|
||||||
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
|
||||||
| .py | | (EffectPlugin) | | overlay() in |
|
|
||||||
| | | | | layers.py |
|
|
||||||
| SVG -> PIL -> | | Timer, state | | |
|
|
||||||
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
|
||||||
| | | selection | | commands for overlay |
|
|
||||||
+------------------+ +-----------------+ +----------------------+
|
|
||||||
|
|
|
||||||
| get_figment_state()
|
|
||||||
v
|
|
||||||
+-------------------+
|
|
||||||
| scroll.py |
|
|
||||||
+-------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Section 1: SVG Rasterization
|
|
||||||
|
|
||||||
**File: `engine/figment_render.py`**
|
|
||||||
|
|
||||||
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
|
||||||
|
|
||||||
### Pipeline
|
|
||||||
|
|
||||||
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
|
||||||
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
|
||||||
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
|
||||||
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
|
||||||
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
|
||||||
|
|
||||||
### Dependency
|
|
||||||
|
|
||||||
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
|
||||||
|
|
||||||
### Key Function
|
|
||||||
|
|
||||||
```python
|
|
||||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
|
||||||
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Section 2: Figment Overlay Rendering
|
|
||||||
|
|
||||||
**Integration point: `engine/layers.py`**
|
|
||||||
|
|
||||||
New function following the `render_message_overlay()` pattern.
|
|
||||||
|
|
||||||
### FigmentState Dataclass
|
|
||||||
|
|
||||||
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class FigmentState:
|
|
||||||
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
|
||||||
progress: float # 0.0 to 1.0 within current phase
|
|
||||||
rows: list[str] # rasterized half-block rows (uncolored)
|
|
||||||
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
|
||||||
center_row: int # top row for centering in viewport
|
|
||||||
center_col: int # left column for centering in viewport
|
|
||||||
```
|
|
||||||
|
|
||||||
### Function Signature
|
|
||||||
|
|
||||||
```python
|
|
||||||
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
|
||||||
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
|
||||||
```
|
|
||||||
|
|
||||||
### Animation Phases (~4.5 seconds total)
|
|
||||||
|
|
||||||
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
|
||||||
|
|
||||||
| Phase | Duration | Behavior |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
|
||||||
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
|
||||||
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
|
||||||
|
|
||||||
### Color
|
|
||||||
|
|
||||||
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
|
||||||
|
|
||||||
### Positioning
|
|
||||||
|
|
||||||
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
|
||||||
|
|
||||||
## Section 3: FigmentEffect (Effect Plugin)
|
|
||||||
|
|
||||||
**File: `effects_plugins/figment.py`**
|
|
||||||
|
|
||||||
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
|
||||||
|
|
||||||
### Chain Exclusion
|
|
||||||
|
|
||||||
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
|
||||||
|
|
||||||
### Responsibilities
|
|
||||||
|
|
||||||
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
|
||||||
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
|
||||||
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
|
||||||
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
|
||||||
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
|
||||||
|
|
||||||
### State Machine
|
|
||||||
|
|
||||||
```
|
|
||||||
idle ──(timer fires or trigger received)──> reveal
|
|
||||||
reveal ──(progress >= 1.0)──> hold
|
|
||||||
hold ──(progress >= 1.0)──> dissolve
|
|
||||||
dissolve ──(progress >= 1.0)──> idle
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interface
|
|
||||||
|
|
||||||
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
|
||||||
"""Tick the state machine and return current state, or None if idle."""
|
|
||||||
```
|
|
||||||
|
|
||||||
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
|
||||||
|
|
||||||
### Scroll Loop Access
|
|
||||||
|
|
||||||
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
|
|
||||||
plugin = registry.get("figment")
|
|
||||||
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
|
||||||
|
|
||||||
### EffectConfig
|
|
||||||
|
|
||||||
- `enabled`: bool (default `False` — opt-in)
|
|
||||||
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
|
||||||
- `params`:
|
|
||||||
- `interval_secs`: 60 (time between figments)
|
|
||||||
- `display_secs`: 4.5 (total animation duration)
|
|
||||||
- `figment_dir`: "figments" (SVG source directory)
|
|
||||||
|
|
||||||
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
|
||||||
|
|
||||||
## Section 4: Input Abstraction (FigmentTrigger)
|
|
||||||
|
|
||||||
**File: `engine/figment_trigger.py`**
|
|
||||||
|
|
||||||
### Protocol
|
|
||||||
|
|
||||||
```python
|
|
||||||
class FigmentTrigger(Protocol):
|
|
||||||
def poll(self) -> FigmentCommand | None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### FigmentCommand
|
|
||||||
|
|
||||||
```python
|
|
||||||
class FigmentAction(Enum):
|
|
||||||
TRIGGER = "trigger"
|
|
||||||
SET_INTENSITY = "set_intensity"
|
|
||||||
SET_INTERVAL = "set_interval"
|
|
||||||
SET_COLOR = "set_color"
|
|
||||||
STOP = "stop"
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentCommand:
|
|
||||||
action: FigmentAction
|
|
||||||
value: float | str | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
|
||||||
|
|
||||||
### Adapters
|
|
||||||
|
|
||||||
| Adapter | Transport | Dependency | Status |
|
|
||||||
|---------|-----------|------------|--------|
|
|
||||||
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
|
||||||
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
|
||||||
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
|
||||||
|
|
||||||
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
|
||||||
|
|
||||||
### Integration
|
|
||||||
|
|
||||||
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
|
||||||
|
|
||||||
### EventBus Bridge
|
|
||||||
|
|
||||||
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
|
||||||
|
|
||||||
## Section 5: Scroll Loop Integration
|
|
||||||
|
|
||||||
Minimal change to `engine/scroll.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In stream() setup (with safe import):
|
|
||||||
try:
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
_plugin = registry.get("figment")
|
|
||||||
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
|
||||||
except ImportError:
|
|
||||||
figment = None
|
|
||||||
|
|
||||||
# In frame loop, after effects processing, before ntfy message overlay:
|
|
||||||
if figment and figment.config.enabled:
|
|
||||||
figment_state = figment.get_figment_state(frame_number)
|
|
||||||
if figment_state is not None:
|
|
||||||
figment_overlay = render_figment_overlay(figment_state, w, h)
|
|
||||||
buf.extend(figment_overlay)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Overlay Priority
|
|
||||||
|
|
||||||
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
|
||||||
- Ntfy messages always appear on top of figments (higher priority)
|
|
||||||
- Existing glitch/noise effects run over the ticker underneath the figment
|
|
||||||
|
|
||||||
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
|
||||||
|
|
||||||
## Section 6: Error Handling
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
|
||||||
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
|
||||||
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
|
||||||
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
|
||||||
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
|
||||||
|
|
||||||
## Section 7: File Summary
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
|
||||||
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
|
||||||
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
|
||||||
| `figments/` | SVG source directory (ships with sample SVGs) |
|
|
||||||
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
|
||||||
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
|
||||||
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
|
||||||
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
|
||||||
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
|
||||||
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
|
||||||
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
|
||||||
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
|
||||||
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
|
||||||
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
|
||||||
@@ -5,7 +5,6 @@ PLUGIN_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_plugins():
|
def discover_plugins():
|
||||||
from engine.effects.registry import get_registry
|
from engine.effects.registry import get_registry
|
||||||
from engine.effects.types import EffectPlugin
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
imported = {}
|
imported = {}
|
||||||
@@ -23,8 +22,8 @@ def discover_plugins():
|
|||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
isinstance(attr, type)
|
isinstance(attr, type)
|
||||||
and issubclass(attr, EffectPlugin)
|
and hasattr(attr, "name")
|
||||||
and attr is not EffectPlugin
|
and hasattr(attr, "process")
|
||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import random
|
|||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
class FadeEffect(EffectPlugin):
|
class FadeEffect:
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
"""
|
|
||||||
Figment effect plugin — periodic SVG glyph overlay.
|
|
||||||
|
|
||||||
Owns the figment lifecycle: timer, SVG selection, state machine.
|
|
||||||
Delegates rendering to render_figment_overlay() in engine/layers.py.
|
|
||||||
|
|
||||||
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
|
|
||||||
convention in effects_plugins/__init__.py.
|
|
||||||
|
|
||||||
NOT added to the EffectChain order — process() is a no-op. The overlay
|
|
||||||
rendering is handled by scroll.py calling get_figment_state().
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum, auto
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from engine import config
|
|
||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|
||||||
from engine.figment_render import rasterize_svg
|
|
||||||
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
|
||||||
from engine.themes import THEME_REGISTRY
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentPhase(Enum):
|
|
||||||
REVEAL = auto()
|
|
||||||
HOLD = auto()
|
|
||||||
DISSOLVE = auto()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentState:
|
|
||||||
phase: FigmentPhase
|
|
||||||
progress: float
|
|
||||||
rows: list[str]
|
|
||||||
gradient: list[int]
|
|
||||||
center_row: int
|
|
||||||
center_col: int
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentEffect(EffectPlugin):
|
|
||||||
name = "figment"
|
|
||||||
config = EffectConfig(
|
|
||||||
enabled=False,
|
|
||||||
intensity=1.0,
|
|
||||||
params={
|
|
||||||
"interval_secs": 60,
|
|
||||||
"display_secs": 4.5,
|
|
||||||
"figment_dir": "figments",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
figment_dir: str | None = None,
|
|
||||||
triggers: list[FigmentTrigger] | None = None,
|
|
||||||
):
|
|
||||||
self.config = EffectConfig(
|
|
||||||
enabled=False,
|
|
||||||
intensity=1.0,
|
|
||||||
params={
|
|
||||||
"interval_secs": 60,
|
|
||||||
"display_secs": 4.5,
|
|
||||||
"figment_dir": figment_dir or "figments",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self._triggers = triggers or []
|
|
||||||
self._phase: FigmentPhase | None = None
|
|
||||||
self._progress: float = 0.0
|
|
||||||
self._rows: list[str] = []
|
|
||||||
self._gradient: list[int] = []
|
|
||||||
self._center_row: int = 0
|
|
||||||
self._center_col: int = 0
|
|
||||||
self._timer: float = 0.0
|
|
||||||
self._last_svg: str | None = None
|
|
||||||
self._svg_files: list[str] = []
|
|
||||||
self._scan_svgs()
|
|
||||||
|
|
||||||
def _scan_svgs(self) -> None:
|
|
||||||
figment_dir = Path(self.config.params["figment_dir"])
|
|
||||||
if figment_dir.is_dir():
|
|
||||||
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
|
||||||
return buf
|
|
||||||
|
|
||||||
def configure(self, cfg: EffectConfig) -> None:
|
|
||||||
# Preserve figment_dir if the new config doesn't supply one
|
|
||||||
figment_dir = cfg.params.get(
|
|
||||||
"figment_dir", self.config.params.get("figment_dir", "figments")
|
|
||||||
)
|
|
||||||
self.config = cfg
|
|
||||||
if "figment_dir" not in self.config.params:
|
|
||||||
self.config.params["figment_dir"] = figment_dir
|
|
||||||
self._scan_svgs()
|
|
||||||
|
|
||||||
def trigger(self, w: int, h: int) -> None:
|
|
||||||
"""Manually trigger a figment display."""
|
|
||||||
if not self._svg_files:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pick a random SVG, avoid repeating
|
|
||||||
candidates = [s for s in self._svg_files if s != self._last_svg]
|
|
||||||
if not candidates:
|
|
||||||
candidates = self._svg_files
|
|
||||||
svg_path = random.choice(candidates)
|
|
||||||
self._last_svg = svg_path
|
|
||||||
|
|
||||||
# Rasterize
|
|
||||||
try:
|
|
||||||
self._rows = rasterize_svg(svg_path, w, h)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pick random theme gradient
|
|
||||||
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
|
||||||
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
|
||||||
|
|
||||||
# Center in viewport
|
|
||||||
figment_h = len(self._rows)
|
|
||||||
figment_w = max((len(r) for r in self._rows), default=0)
|
|
||||||
self._center_row = max(0, (h - figment_h) // 2)
|
|
||||||
self._center_col = max(0, (w - figment_w) // 2)
|
|
||||||
|
|
||||||
# Start reveal phase
|
|
||||||
self._phase = FigmentPhase.REVEAL
|
|
||||||
self._progress = 0.0
|
|
||||||
|
|
||||||
def get_figment_state(
|
|
||||||
self, frame_number: int, w: int, h: int
|
|
||||||
) -> FigmentState | None:
|
|
||||||
"""Tick the state machine and return current state, or None if idle."""
|
|
||||||
if not self.config.enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Poll triggers
|
|
||||||
for trig in self._triggers:
|
|
||||||
cmd = trig.poll()
|
|
||||||
if cmd is not None:
|
|
||||||
self._handle_command(cmd, w, h)
|
|
||||||
|
|
||||||
# Tick timer when idle
|
|
||||||
if self._phase is None:
|
|
||||||
self._timer += config.FRAME_DT
|
|
||||||
interval = self.config.params.get("interval_secs", 60)
|
|
||||||
if self._timer >= interval:
|
|
||||||
self._timer = 0.0
|
|
||||||
self.trigger(w, h)
|
|
||||||
|
|
||||||
# Tick animation — snapshot current phase/progress, then advance
|
|
||||||
if self._phase is not None:
|
|
||||||
# Capture the state at the start of this frame
|
|
||||||
current_phase = self._phase
|
|
||||||
current_progress = self._progress
|
|
||||||
|
|
||||||
# Advance for next frame
|
|
||||||
display_secs = self.config.params.get("display_secs", 4.5)
|
|
||||||
phase_duration = display_secs / 3.0
|
|
||||||
self._progress += config.FRAME_DT / phase_duration
|
|
||||||
|
|
||||||
if self._progress >= 1.0:
|
|
||||||
self._progress = 0.0
|
|
||||||
if self._phase == FigmentPhase.REVEAL:
|
|
||||||
self._phase = FigmentPhase.HOLD
|
|
||||||
elif self._phase == FigmentPhase.HOLD:
|
|
||||||
self._phase = FigmentPhase.DISSOLVE
|
|
||||||
elif self._phase == FigmentPhase.DISSOLVE:
|
|
||||||
self._phase = None
|
|
||||||
|
|
||||||
return FigmentState(
|
|
||||||
phase=current_phase,
|
|
||||||
progress=current_progress,
|
|
||||||
rows=self._rows,
|
|
||||||
gradient=self._gradient,
|
|
||||||
center_row=self._center_row,
|
|
||||||
center_col=self._center_col,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
|
||||||
if cmd.action == FigmentAction.TRIGGER:
|
|
||||||
self.trigger(w, h)
|
|
||||||
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
|
|
||||||
cmd.value, (int, float)
|
|
||||||
):
|
|
||||||
self.config.intensity = float(cmd.value)
|
|
||||||
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
|
|
||||||
cmd.value, (int, float)
|
|
||||||
):
|
|
||||||
self.config.params["interval_secs"] = float(cmd.value)
|
|
||||||
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
|
||||||
if cmd.value in THEME_REGISTRY:
|
|
||||||
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
|
||||||
elif cmd.action == FigmentAction.STOP:
|
|
||||||
self._phase = None
|
|
||||||
self._progress = 0.0
|
|
||||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect:
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
class GlitchEffect(EffectPlugin):
|
class GlitchEffect:
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect:
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
|||||||
223
engine/app.py
223
engine/app.py
@@ -10,11 +10,9 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render, themes
|
from engine import config, render
|
||||||
|
from engine.controller import StreamController
|
||||||
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.ntfy import NtfyPoller
|
|
||||||
from engine.scroll import stream
|
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
CLR,
|
CLR,
|
||||||
CURSOR_OFF,
|
CURSOR_OFF,
|
||||||
@@ -65,30 +63,6 @@ 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()]
|
||||||
@@ -155,50 +129,6 @@ 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:
|
||||||
@@ -317,6 +247,110 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_effects_config():
|
||||||
|
"""Interactive picker for configuring effects pipeline."""
|
||||||
|
import effects_plugins
|
||||||
|
from engine.effects import get_effect_chain, get_registry
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = get_effect_chain()
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
|
||||||
|
effects = list(registry.list_all().values())
|
||||||
|
if not effects:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = 0
|
||||||
|
editing_intensity = False
|
||||||
|
intensity_value = 1.0
|
||||||
|
|
||||||
|
def _draw_effects_picker():
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print("\033[1;1H", end="")
|
||||||
|
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, effect in enumerate(effects):
|
||||||
|
prefix = " > " if i == selected else " "
|
||||||
|
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||||
|
if editing_intensity and i == selected:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print(
|
||||||
|
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_effects_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == " ":
|
||||||
|
return "toggle"
|
||||||
|
if ch == "q":
|
||||||
|
return "quit"
|
||||||
|
if ch == "+" or ch == "=":
|
||||||
|
return "up"
|
||||||
|
if ch == "-" or ch == "_":
|
||||||
|
return "down"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_effects_picker()
|
||||||
|
key = _read_effects_key()
|
||||||
|
|
||||||
|
if key == "quit" or key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "up" and editing_intensity:
|
||||||
|
intensity_value = min(1.0, intensity_value + 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "down" and editing_intensity:
|
||||||
|
intensity_value = max(0.0, intensity_value - 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(effects) - 1, selected + 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "toggle":
|
||||||
|
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
@@ -327,11 +361,13 @@ def main():
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
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()
|
||||||
|
pick_effects_config()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
@@ -341,10 +377,11 @@ def main():
|
|||||||
time.sleep(0.07)
|
time.sleep(0.07)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
_subtitle = {
|
_subtitle = (
|
||||||
"poetry": "literary consciousness stream",
|
"literary consciousness stream"
|
||||||
"code": "source consciousness stream",
|
if config.MODE == "poetry"
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
else "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()
|
||||||
@@ -365,15 +402,6 @@ 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)
|
||||||
@@ -391,9 +419,10 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
controller = StreamController()
|
||||||
mic_ok = mic.start()
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
if mic.available:
|
|
||||||
|
if controller.mic and controller.mic.available:
|
||||||
boot_ln(
|
boot_ln(
|
||||||
"Microphone",
|
"Microphone",
|
||||||
"ACTIVE"
|
"ACTIVE"
|
||||||
@@ -402,25 +431,11 @@ def main():
|
|||||||
bool(mic_ok),
|
bool(mic_ok),
|
||||||
)
|
)
|
||||||
|
|
||||||
ntfy = NtfyPoller(
|
|
||||||
config.NTFY_TOPIC,
|
|
||||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
|
||||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
|
||||||
)
|
|
||||||
ntfy_ok = ntfy.start()
|
|
||||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
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)
|
||||||
@@ -428,7 +443,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
stream(items, ntfy, mic)
|
controller.run(items)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ class Config:
|
|||||||
firehose: bool = False
|
firehose: bool = False
|
||||||
|
|
||||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
ntfy_reconnect_delay: int = 5
|
ntfy_reconnect_delay: int = 5
|
||||||
message_display_secs: int = 30
|
message_display_secs: int = 30
|
||||||
|
|
||||||
@@ -148,6 +150,8 @@ class Config:
|
|||||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
firehose="--firehose" in argv,
|
firehose="--firehose" in argv,
|
||||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||||
|
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||||
ntfy_reconnect_delay=5,
|
ntfy_reconnect_delay=5,
|
||||||
message_display_secs=30,
|
message_display_secs=30,
|
||||||
font_dir=font_dir,
|
font_dir=font_dir,
|
||||||
@@ -188,19 +192,13 @@ 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 = (
|
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
||||||
"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"
|
||||||
|
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
@@ -239,26 +237,3 @@ 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)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Stream controller - manages input sources and orchestrates the render stream.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from engine.config import Config, get_config
|
from engine.config import Config, get_config
|
||||||
|
from engine.effects.controller import handle_effects_command
|
||||||
from engine.eventbus import EventBus
|
from engine.eventbus import EventBus
|
||||||
from engine.events import EventType, StreamEvent
|
from engine.events import EventType, StreamEvent
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
@@ -13,11 +14,45 @@ from engine.scroll import stream
|
|||||||
class StreamController:
|
class StreamController:
|
||||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||||
|
|
||||||
|
_topics_warmed = False
|
||||||
|
|
||||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||||
self.config = config or get_config()
|
self.config = config or get_config()
|
||||||
self.event_bus = event_bus
|
self.event_bus = event_bus
|
||||||
self.mic: MicMonitor | None = None
|
self.mic: MicMonitor | None = None
|
||||||
self.ntfy: NtfyPoller | None = None
|
self.ntfy: NtfyPoller | None = None
|
||||||
|
self.ntfy_cc: NtfyPoller | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def warmup_topics(cls) -> None:
|
||||||
|
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
||||||
|
if cls._topics_warmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
topics = [
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline",
|
||||||
|
]
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic,
|
||||||
|
data=b"init",
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls._topics_warmed = True
|
||||||
|
|
||||||
def initialize_sources(self) -> tuple[bool, bool]:
|
def initialize_sources(self) -> tuple[bool, bool]:
|
||||||
"""Initialize microphone and ntfy sources.
|
"""Initialize microphone and ntfy sources.
|
||||||
@@ -35,7 +70,38 @@ class StreamController:
|
|||||||
)
|
)
|
||||||
ntfy_ok = self.ntfy.start()
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok
|
self.ntfy_cc = NtfyPoller(
|
||||||
|
self.config.ntfy_cc_cmd_topic,
|
||||||
|
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||||
|
display_secs=5,
|
||||||
|
)
|
||||||
|
self.ntfy_cc.subscribe(self._handle_cc_message)
|
||||||
|
ntfy_cc_ok = self.ntfy_cc.start()
|
||||||
|
|
||||||
|
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
||||||
|
|
||||||
|
def _handle_cc_message(self, event) -> None:
|
||||||
|
"""Handle incoming C&C message - like a serial port control interface."""
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
||||||
|
if not cmd.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
response = handle_effects_command(cmd)
|
||||||
|
|
||||||
|
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
||||||
|
data = response.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=data,
|
||||||
|
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def run(self, items: list) -> None:
|
def run(self, items: list) -> None:
|
||||||
"""Run the stream with initialized sources."""
|
"""Run the stream with initialized sources."""
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,11 +8,10 @@ class EffectContext:
|
|||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
ticker_height: int
|
ticker_height: int
|
||||||
camera_x: int = 0
|
mic_excess: float
|
||||||
mic_excess: float = 0.0
|
grad_offset: float
|
||||||
grad_offset: float = 0.0
|
frame_number: int
|
||||||
frame_number: int = 0
|
has_message: bool
|
||||||
has_message: bool = False
|
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,42 +22,15 @@ class EffectConfig:
|
|||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin(ABC):
|
class EffectPlugin:
|
||||||
name: str
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
|
||||||
@abstractmethod
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
def configure(self, config: EffectConfig) -> None:
|
||||||
def configure(self, config: EffectConfig) -> None: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def create_effect_context(
|
|
||||||
terminal_width: int = 80,
|
|
||||||
terminal_height: int = 24,
|
|
||||||
scroll_cam: int = 0,
|
|
||||||
ticker_height: int = 0,
|
|
||||||
camera_x: int = 0,
|
|
||||||
mic_excess: float = 0.0,
|
|
||||||
grad_offset: float = 0.0,
|
|
||||||
frame_number: int = 0,
|
|
||||||
has_message: bool = False,
|
|
||||||
items: list | None = None,
|
|
||||||
) -> EffectContext:
|
|
||||||
"""Factory function to create EffectContext with sensible defaults."""
|
|
||||||
return EffectContext(
|
|
||||||
terminal_width=terminal_width,
|
|
||||||
terminal_height=terminal_height,
|
|
||||||
scroll_cam=scroll_cam,
|
|
||||||
ticker_height=ticker_height,
|
|
||||||
camera_x=camera_x,
|
|
||||||
mic_excess=mic_excess,
|
|
||||||
grad_offset=grad_offset,
|
|
||||||
frame_number=frame_number,
|
|
||||||
has_message=has_message,
|
|
||||||
items=items or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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
|
||||||
@@ -66,12 +65,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""
|
|
||||||
SVG to half-block terminal art rasterization.
|
|
||||||
|
|
||||||
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
|
|
||||||
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
|
||||||
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
|
||||||
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
|
||||||
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
|
||||||
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
|
||||||
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
|
||||||
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
|
||||||
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
|
||||||
break
|
|
||||||
|
|
||||||
import cairosvg
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
_cache: dict[tuple[str, int, int], list[str]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
|
||||||
"""Convert SVG file to list of half-block terminal rows (uncolored).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
svg_path: Path to SVG file.
|
|
||||||
width: Target terminal width in columns.
|
|
||||||
height: Target terminal height in rows.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of strings, one per terminal row, containing block characters.
|
|
||||||
"""
|
|
||||||
cache_key = (svg_path, width, height)
|
|
||||||
if cache_key in _cache:
|
|
||||||
return _cache[cache_key]
|
|
||||||
|
|
||||||
# SVG -> PNG in memory
|
|
||||||
png_bytes = cairosvg.svg2png(
|
|
||||||
url=svg_path,
|
|
||||||
output_width=width,
|
|
||||||
output_height=height * 2, # 2 pixel rows per terminal row
|
|
||||||
)
|
|
||||||
|
|
||||||
# PNG -> greyscale PIL image
|
|
||||||
# Composite RGBA onto white background so transparent areas become white (255)
|
|
||||||
# and drawn pixels retain their luminance values.
|
|
||||||
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
|
||||||
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
|
|
||||||
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
|
|
||||||
background.paste(img_rgba, mask=img_rgba.split()[3])
|
|
||||||
img = background.convert("L")
|
|
||||||
|
|
||||||
data = img.tobytes()
|
|
||||||
pix_w = width
|
|
||||||
pix_h = height * 2
|
|
||||||
# White (255) = empty space, dark (< threshold) = filled pixel
|
|
||||||
threshold = 128
|
|
||||||
|
|
||||||
# Half-block encode: walk pixel pairs
|
|
||||||
rows: list[str] = []
|
|
||||||
for y in range(0, pix_h, 2):
|
|
||||||
row: list[str] = []
|
|
||||||
for x in range(pix_w):
|
|
||||||
top = data[y * pix_w + x] < threshold
|
|
||||||
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
|
|
||||||
if top and bot:
|
|
||||||
row.append("█")
|
|
||||||
elif top:
|
|
||||||
row.append("▀")
|
|
||||||
elif bot:
|
|
||||||
row.append("▄")
|
|
||||||
else:
|
|
||||||
row.append(" ")
|
|
||||||
rows.append("".join(row))
|
|
||||||
|
|
||||||
_cache[cache_key] = rows
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def clear_cache() -> None:
|
|
||||||
"""Clear the rasterization cache (e.g., on terminal resize)."""
|
|
||||||
_cache.clear()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
Figment trigger protocol and command types.
|
|
||||||
|
|
||||||
Defines the extensible input abstraction for triggering figment displays
|
|
||||||
from any control surface (ntfy, MQTT, serial, etc.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentAction(Enum):
|
|
||||||
TRIGGER = "trigger"
|
|
||||||
SET_INTENSITY = "set_intensity"
|
|
||||||
SET_INTERVAL = "set_interval"
|
|
||||||
SET_COLOR = "set_color"
|
|
||||||
STOP = "stop"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentCommand:
|
|
||||||
action: FigmentAction
|
|
||||||
value: float | str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentTrigger(Protocol):
|
|
||||||
"""Protocol for figment trigger sources.
|
|
||||||
|
|
||||||
Any input source (ntfy, MQTT, serial) can implement this
|
|
||||||
to trigger and control figment displays.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def poll(self) -> FigmentCommand | None: ...
|
|
||||||
104
engine/layers.py
104
engine/layers.py
@@ -18,7 +18,7 @@ from engine.effects import (
|
|||||||
noise,
|
noise,
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||||
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"
|
||||||
@@ -57,7 +57,9 @@ def render_message_overlay(
|
|||||||
else:
|
else:
|
||||||
msg_rows = msg_cache[1]
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
msg_rows = lr_gradient_opposite(
|
||||||
|
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
|
)
|
||||||
|
|
||||||
elapsed_s = int(time.monotonic() - m_ts)
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||||
@@ -256,101 +258,3 @@ def get_effect_chain() -> EffectChain | None:
|
|||||||
if _effect_chain is None:
|
if _effect_chain is None:
|
||||||
init_effects()
|
init_effects()
|
||||||
return _effect_chain
|
return _effect_chain
|
||||||
|
|
||||||
|
|
||||||
def render_figment_overlay(
|
|
||||||
figment_state,
|
|
||||||
w: int,
|
|
||||||
h: int,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Render figment overlay as ANSI cursor-positioning commands.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
|
||||||
w: terminal width
|
|
||||||
h: terminal height
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of ANSI strings to append to display buffer.
|
|
||||||
"""
|
|
||||||
from engine.render import _color_codes_to_ansi
|
|
||||||
|
|
||||||
rows = figment_state.rows
|
|
||||||
if not rows:
|
|
||||||
return []
|
|
||||||
|
|
||||||
phase = figment_state.phase
|
|
||||||
progress = figment_state.progress
|
|
||||||
gradient = figment_state.gradient
|
|
||||||
center_row = figment_state.center_row
|
|
||||||
center_col = figment_state.center_col
|
|
||||||
|
|
||||||
cols = _color_codes_to_ansi(gradient)
|
|
||||||
|
|
||||||
# Build a list of non-space cell positions
|
|
||||||
cell_positions = []
|
|
||||||
for r_idx, row in enumerate(rows):
|
|
||||||
for c_idx, ch in enumerate(row):
|
|
||||||
if ch != " ":
|
|
||||||
cell_positions.append((r_idx, c_idx))
|
|
||||||
|
|
||||||
n_cells = len(cell_positions)
|
|
||||||
if n_cells == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
|
||||||
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
|
||||||
shuffled = list(cell_positions)
|
|
||||||
rng.shuffle(shuffled)
|
|
||||||
|
|
||||||
# Phase-dependent visibility
|
|
||||||
from effects_plugins.figment import FigmentPhase
|
|
||||||
|
|
||||||
if phase == FigmentPhase.REVEAL:
|
|
||||||
visible_count = int(n_cells * progress)
|
|
||||||
visible = set(shuffled[:visible_count])
|
|
||||||
elif phase == FigmentPhase.HOLD:
|
|
||||||
visible = set(cell_positions)
|
|
||||||
# Strobe: dim some cells periodically
|
|
||||||
if int(progress * 20) % 3 == 0:
|
|
||||||
dim_count = int(n_cells * 0.3)
|
|
||||||
visible -= set(shuffled[:dim_count])
|
|
||||||
elif phase == FigmentPhase.DISSOLVE:
|
|
||||||
remaining_count = int(n_cells * (1.0 - progress))
|
|
||||||
visible = set(shuffled[:remaining_count])
|
|
||||||
else:
|
|
||||||
visible = set(cell_positions)
|
|
||||||
|
|
||||||
# Build overlay commands
|
|
||||||
overlay: list[str] = []
|
|
||||||
n_cols = len(cols)
|
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
|
||||||
|
|
||||||
for r_idx, row in enumerate(rows):
|
|
||||||
scr_row = center_row + r_idx + 1 # 1-indexed
|
|
||||||
if scr_row < 1 or scr_row > h:
|
|
||||||
continue
|
|
||||||
|
|
||||||
line_buf: list[str] = []
|
|
||||||
has_content = False
|
|
||||||
|
|
||||||
for c_idx, ch in enumerate(row):
|
|
||||||
scr_col = center_col + c_idx + 1
|
|
||||||
if scr_col < 1 or scr_col > w:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ch != " " and (r_idx, c_idx) in visible:
|
|
||||||
# Apply gradient color
|
|
||||||
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
|
||||||
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
|
||||||
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
|
||||||
has_content = True
|
|
||||||
else:
|
|
||||||
line_buf.append(" ")
|
|
||||||
|
|
||||||
if has_content:
|
|
||||||
line_str = "".join(line_buf).rstrip()
|
|
||||||
if line_str.strip():
|
|
||||||
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
|
||||||
|
|
||||||
return overlay
|
|
||||||
|
|||||||
129
engine/render.py
129
engine/render.py
@@ -15,72 +15,38 @@ 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 ─────────────────────────────────────────────
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
def _color_codes_to_ansi(color_codes):
|
# Left → right: white-hot leading edge fades to near-black
|
||||||
"""Convert a list of 256-color codes to ANSI escape code strings.
|
GRAD_COLS = [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
Args:
|
"\033[38;5;118m", # bright lime
|
||||||
color_codes: List of 12 integers (256-color palette codes)
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
Returns:
|
"\033[38;5;40m", # green
|
||||||
List of ANSI escape code strings
|
"\033[38;5;34m", # medium green
|
||||||
"""
|
"\033[38;5;28m", # dark green
|
||||||
if not color_codes or len(color_codes) != 12:
|
"\033[38;5;22m", # deep green
|
||||||
# Fallback to default green if invalid
|
"\033[2;38;5;22m", # dim deep green
|
||||||
return _default_green_gradient()
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||||
|
MSG_GRAD_COLS = [
|
||||||
|
"\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
|
||||||
@@ -223,15 +189,9 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, cols=None):
|
def lr_gradient(rows, offset=0.0, grad_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."""
|
||||||
if cols is None:
|
cols = grad_cols or GRAD_COLS
|
||||||
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 = []
|
||||||
@@ -253,30 +213,7 @@ def lr_gradient(rows, offset=0.0, 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, _default_magenta_gradient())
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
|
||||||
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 ─────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from engine.frame import calculate_scroll_step
|
|||||||
from engine.layers import (
|
from engine.layers import (
|
||||||
apply_glitch,
|
apply_glitch,
|
||||||
process_effects,
|
process_effects,
|
||||||
render_figment_overlay,
|
|
||||||
render_firehose,
|
render_firehose,
|
||||||
render_message_overlay,
|
render_message_overlay,
|
||||||
render_ticker_zone,
|
render_ticker_zone,
|
||||||
@@ -54,18 +53,6 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
|||||||
msg_cache = (None, None)
|
msg_cache = (None, None)
|
||||||
frame_number = 0
|
frame_number = 0
|
||||||
|
|
||||||
# Figment overlay (optional — requires cairosvg)
|
|
||||||
figment = None
|
|
||||||
if config.FIGMENT:
|
|
||||||
try:
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
|
|
||||||
figment = FigmentEffect()
|
|
||||||
figment.config.enabled = True
|
|
||||||
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
|
|
||||||
except (ImportError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if queued >= config.HEADLINE_LIMIT and not active:
|
if queued >= config.HEADLINE_LIMIT and not active:
|
||||||
break
|
break
|
||||||
@@ -136,13 +123,6 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
|||||||
firehose_buf = render_firehose(items, w, fh, h)
|
firehose_buf = render_firehose(items, w, fh, h)
|
||||||
buf.extend(firehose_buf)
|
buf.extend(firehose_buf)
|
||||||
|
|
||||||
# Figment overlay (between effects and ntfy message)
|
|
||||||
if figment and figment.config.enabled:
|
|
||||||
figment_state = figment.get_figment_state(frame_number, w, h)
|
|
||||||
if figment_state is not None:
|
|
||||||
figment_buf = render_figment_overlay(figment_state, w, h)
|
|
||||||
buf.extend(figment_buf)
|
|
||||||
|
|
||||||
if msg_overlay:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
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]
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,60 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,110 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Binary file not shown.
29
mise.toml
29
mise.toml
@@ -1,7 +1,3 @@
|
|||||||
[env]
|
|
||||||
_.path = ["/opt/homebrew/lib"]
|
|
||||||
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
|
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
python = "3.12"
|
python = "3.12"
|
||||||
hk = "latest"
|
hk = "latest"
|
||||||
@@ -29,24 +25,41 @@ run = "uv run mainline.py"
|
|||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
|
daemon = "nohup uv run mainline.py > /dev/null 2>&1 &"
|
||||||
|
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||||
|
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Command & Control
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
cmd = "uv run cmdline.py"
|
||||||
|
cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:"
|
||||||
|
|
||||||
|
# Initialize ntfy topics (warm up before first use - also done automatically by mainline)
|
||||||
|
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "uv sync"
|
install = "mise run sync"
|
||||||
install-dev = "uv sync --group dev"
|
install-dev = "mise run sync && uv sync --group dev"
|
||||||
|
|
||||||
bootstrap = "uv sync && uv run mainline.py --help"
|
bootstrap = "uv sync && uv run mainline.py --help"
|
||||||
|
|
||||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
|
||||||
|
# Aggressive cleanup - removes all generated files, caches, and venv
|
||||||
|
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ 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
3
tests/fixtures/test.svg
vendored
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 155 B |
@@ -1,35 +0,0 @@
|
|||||||
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}"
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Tests for the FigmentEffect plugin."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
|
||||||
|
|
||||||
from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
|
|
||||||
from engine.effects.types import EffectConfig, EffectContext
|
|
||||||
|
|
||||||
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
|
||||||
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentPhase:
|
|
||||||
def test_is_enum(self):
|
|
||||||
assert issubclass(FigmentPhase, Enum)
|
|
||||||
|
|
||||||
def test_has_all_phases(self):
|
|
||||||
assert hasattr(FigmentPhase, "REVEAL")
|
|
||||||
assert hasattr(FigmentPhase, "HOLD")
|
|
||||||
assert hasattr(FigmentPhase, "DISSOLVE")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentState:
|
|
||||||
def test_creation(self):
|
|
||||||
state = FigmentState(
|
|
||||||
phase=FigmentPhase.REVEAL,
|
|
||||||
progress=0.5,
|
|
||||||
rows=["█▀▄", " █ "],
|
|
||||||
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
|
||||||
center_row=5,
|
|
||||||
center_col=10,
|
|
||||||
)
|
|
||||||
assert state.phase == FigmentPhase.REVEAL
|
|
||||||
assert state.progress == 0.5
|
|
||||||
assert len(state.rows) == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentEffectInit:
|
|
||||||
def test_name(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
assert effect.name == "figment"
|
|
||||||
|
|
||||||
def test_default_config(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
assert effect.config.enabled is False
|
|
||||||
assert effect.config.intensity == 1.0
|
|
||||||
assert effect.config.params["interval_secs"] == 60
|
|
||||||
assert effect.config.params["display_secs"] == 4.5
|
|
||||||
|
|
||||||
def test_process_is_noop(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
buf = ["line1", "line2"]
|
|
||||||
ctx = EffectContext(
|
|
||||||
terminal_width=80,
|
|
||||||
terminal_height=24,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=20,
|
|
||||||
)
|
|
||||||
result = effect.process(buf, ctx)
|
|
||||||
assert result == buf
|
|
||||||
assert result is buf
|
|
||||||
|
|
||||||
def test_configure(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
new_cfg = EffectConfig(enabled=True, intensity=0.5)
|
|
||||||
effect.configure(new_cfg)
|
|
||||||
assert effect.config.enabled is True
|
|
||||||
assert effect.config.intensity == 0.5
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentStateMachine:
|
|
||||||
def test_idle_initially(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
effect.config.enabled = True
|
|
||||||
state = effect.get_figment_state(0, 80, 24)
|
|
||||||
# Timer hasn't fired yet, should be None (idle)
|
|
||||||
assert state is None
|
|
||||||
|
|
||||||
def test_trigger_starts_reveal(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
effect.config.enabled = True
|
|
||||||
effect.trigger(80, 24)
|
|
||||||
state = effect.get_figment_state(1, 80, 24)
|
|
||||||
assert state is not None
|
|
||||||
assert state.phase == FigmentPhase.REVEAL
|
|
||||||
|
|
||||||
def test_full_cycle(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
effect.config.enabled = True
|
|
||||||
effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s
|
|
||||||
|
|
||||||
effect.trigger(40, 20)
|
|
||||||
|
|
||||||
# Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
|
|
||||||
# With display_secs=0.15, each phase is 0.05s = 1 frame
|
|
||||||
state = effect.get_figment_state(1, 40, 20)
|
|
||||||
assert state is not None
|
|
||||||
assert state.phase == FigmentPhase.REVEAL
|
|
||||||
|
|
||||||
# Advance enough frames to get through all phases
|
|
||||||
for frame in range(2, 100):
|
|
||||||
state = effect.get_figment_state(frame, 40, 20)
|
|
||||||
if state is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Should have completed the full cycle back to idle
|
|
||||||
assert state is None
|
|
||||||
|
|
||||||
def test_timer_fires_at_interval(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
effect.config.enabled = True
|
|
||||||
effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps
|
|
||||||
|
|
||||||
# Frame 0: idle
|
|
||||||
state = effect.get_figment_state(0, 40, 20)
|
|
||||||
assert state is None
|
|
||||||
|
|
||||||
# Advance past interval (0.1s = 2 frames)
|
|
||||||
state = effect.get_figment_state(1, 40, 20)
|
|
||||||
state = effect.get_figment_state(2, 40, 20)
|
|
||||||
state = effect.get_figment_state(3, 40, 20)
|
|
||||||
# Timer should have fired by now
|
|
||||||
assert state is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentEdgeCases:
|
|
||||||
def test_empty_figment_dir(self, tmp_path):
|
|
||||||
effect = FigmentEffect(figment_dir=str(tmp_path))
|
|
||||||
effect.config.enabled = True
|
|
||||||
effect.trigger(40, 20)
|
|
||||||
state = effect.get_figment_state(1, 40, 20)
|
|
||||||
# No SVGs available — should stay idle
|
|
||||||
assert state is None
|
|
||||||
|
|
||||||
def test_missing_figment_dir(self):
|
|
||||||
effect = FigmentEffect(figment_dir="/nonexistent/path")
|
|
||||||
effect.config.enabled = True
|
|
||||||
effect.trigger(40, 20)
|
|
||||||
state = effect.get_figment_state(1, 40, 20)
|
|
||||||
assert state is None
|
|
||||||
|
|
||||||
def test_disabled_ignores_trigger(self):
|
|
||||||
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
|
||||||
effect.config.enabled = False
|
|
||||||
effect.trigger(80, 24)
|
|
||||||
state = effect.get_figment_state(1, 80, 24)
|
|
||||||
assert state is None
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Tests for render_figment_overlay in engine.layers."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
|
||||||
|
|
||||||
from effects_plugins.figment import FigmentPhase, FigmentState
|
|
||||||
from engine.layers import render_figment_overlay
|
|
||||||
|
|
||||||
|
|
||||||
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
|
|
||||||
return FigmentState(
|
|
||||||
phase=phase,
|
|
||||||
progress=progress,
|
|
||||||
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
|
|
||||||
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
|
||||||
center_row=10,
|
|
||||||
center_col=37,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderFigmentOverlay:
|
|
||||||
def test_returns_list_of_strings(self):
|
|
||||||
state = _make_state()
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert all(isinstance(s, str) for s in result)
|
|
||||||
|
|
||||||
def test_contains_ansi_positioning(self):
|
|
||||||
state = _make_state()
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
# Should contain cursor positioning escape codes
|
|
||||||
assert any("\033[" in s for s in result)
|
|
||||||
|
|
||||||
def test_reveal_phase_partial(self):
|
|
||||||
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
# At progress 0.0, very few cells should be visible
|
|
||||||
# Result should still be a valid list
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
def test_hold_phase_full(self):
|
|
||||||
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
# During hold, content should be present
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
def test_dissolve_phase(self):
|
|
||||||
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
# At high dissolve progress, most cells are gone
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
||||||
def test_empty_rows(self):
|
|
||||||
state = FigmentState(
|
|
||||||
phase=FigmentPhase.HOLD,
|
|
||||||
progress=0.5,
|
|
||||||
rows=[],
|
|
||||||
gradient=[46] * 12,
|
|
||||||
center_row=0,
|
|
||||||
center_col=0,
|
|
||||||
)
|
|
||||||
result = render_figment_overlay(state, 80, 24)
|
|
||||||
assert result == []
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Tests for engine.figment_render module."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
|
||||||
|
|
||||||
from engine.figment_render import rasterize_svg
|
|
||||||
|
|
||||||
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
|
||||||
|
|
||||||
|
|
||||||
class TestRasterizeSvg:
|
|
||||||
def test_returns_list_of_strings(self):
|
|
||||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
assert isinstance(rows, list)
|
|
||||||
assert all(isinstance(r, str) for r in rows)
|
|
||||||
|
|
||||||
def test_output_height_matches_terminal_height(self):
|
|
||||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
assert len(rows) == 20
|
|
||||||
|
|
||||||
def test_output_contains_block_characters(self):
|
|
||||||
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
all_chars = "".join(rows)
|
|
||||||
block_chars = {"█", "▀", "▄"}
|
|
||||||
assert any(ch in all_chars for ch in block_chars)
|
|
||||||
|
|
||||||
def test_different_sizes_produce_different_output(self):
|
|
||||||
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
|
|
||||||
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
|
|
||||||
assert len(rows_small) == 10
|
|
||||||
assert len(rows_large) == 40
|
|
||||||
|
|
||||||
def test_nonexistent_file_raises(self):
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
with pytest.raises((FileNotFoundError, OSError)):
|
|
||||||
rasterize_svg("/nonexistent/file.svg", 40, 20)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRasterizeCache:
|
|
||||||
def test_cache_returns_same_result(self):
|
|
||||||
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
assert rows1 == rows2
|
|
||||||
|
|
||||||
def test_cache_invalidated_by_size_change(self):
|
|
||||||
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
|
||||||
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
|
|
||||||
assert len(rows1) != len(rows2)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Tests for engine.figment_trigger module."""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
from engine.figment_trigger import FigmentAction, FigmentCommand
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentAction:
|
|
||||||
def test_is_enum(self):
|
|
||||||
assert issubclass(FigmentAction, Enum)
|
|
||||||
|
|
||||||
def test_has_trigger(self):
|
|
||||||
assert FigmentAction.TRIGGER.value == "trigger"
|
|
||||||
|
|
||||||
def test_has_set_intensity(self):
|
|
||||||
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
|
|
||||||
|
|
||||||
def test_has_set_interval(self):
|
|
||||||
assert FigmentAction.SET_INTERVAL.value == "set_interval"
|
|
||||||
|
|
||||||
def test_has_set_color(self):
|
|
||||||
assert FigmentAction.SET_COLOR.value == "set_color"
|
|
||||||
|
|
||||||
def test_has_stop(self):
|
|
||||||
assert FigmentAction.STOP.value == "stop"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFigmentCommand:
|
|
||||||
def test_trigger_command(self):
|
|
||||||
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
|
|
||||||
assert cmd.action == FigmentAction.TRIGGER
|
|
||||||
assert cmd.value is None
|
|
||||||
|
|
||||||
def test_set_intensity_command(self):
|
|
||||||
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
|
|
||||||
assert cmd.value == 0.8
|
|
||||||
|
|
||||||
def test_set_color_command(self):
|
|
||||||
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
|
|
||||||
assert cmd.value == "orange"
|
|
||||||
127
tests/test_ntfy_integration.py
Normal file
127
tests/test_ntfy_integration.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for ntfy topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyTopics:
|
||||||
|
def test_cc_cmd_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C CMD topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_resp_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C RESP topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_RESP_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
||||||
|
|
||||||
|
def test_message_topic_exists_and_writable(self):
|
||||||
|
"""Verify message topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_cmd_topic_readable(self):
|
||||||
|
"""Verify we can read messages from C&C CMD topic."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
test_message = f"integration_test_{int(time.time())}"
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
poll_url,
|
||||||
|
headers={"User-Agent": "mainline-test/0.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
if body.strip():
|
||||||
|
data = json.loads(body.split("\n")[0])
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_topics_are_different(self):
|
||||||
|
"""Verify C&C CMD/RESP and message topics are different."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
||||||
|
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
||||||
|
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
||||||
|
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""
|
|
||||||
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