Compare commits
62 Commits
d4ea28add2
...
feat/figme
| Author | SHA1 | Date | |
|---|---|---|---|
| a25b80d4a6 | |||
| 3a1aa975d1 | |||
| d5e5f39404 | |||
| 2bfd3a01da | |||
| 4cf316c280 | |||
| 79d271c42b | |||
| 525af4bc46 | |||
| 085f150cb0 | |||
| 0b6e2fae74 | |||
| 6864ad84c6 | |||
| acb42ea140 | |||
| 7014a9d5cd | |||
| 2cc8dbfc02 | |||
| f1d5162488 | |||
| 9f61226779 | |||
| 9415e18679 | |||
| 0819f8d160 | |||
| edd1416407 | |||
| ac9b47f668 | |||
| b149825bcb | |||
| 1b29e91f9d | |||
| 001158214c | |||
| 31f5d9f171 | |||
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee | |||
| 8b696c96ce | |||
| 72d21459ca | |||
| 58dbbbdba7 | |||
| 7ff78c66ed | |||
| 2229ccdea4 | |||
| f13e89f823 | |||
| 4228400c43 | |||
| 05cc475858 | |||
| cfd7e8931e | |||
| 15de46722a | |||
| 35e5c8d38b | |||
| cdc8094de2 | |||
| f170143939 | |||
| 19fb4bc4fe | |||
| ae10fd78ca | |||
| 4afab642f7 | |||
| f6f177590b | |||
| 9ae4dc2b07 | |||
| 1ac2dec3b0 | |||
| 757c854584 | |||
| 4844a64203 | |||
| 9201117096 | |||
| d758541156 | |||
| b979621dd4 | |||
| f91cc9844e | |||
| bddbd69371 | |||
| 6e39a2dad2 | |||
| 1ba3848bed |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,4 +1,12 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.mainline_venv/
|
.mainline_venv/
|
||||||
|
.venv/
|
||||||
|
uv.lock
|
||||||
.mainline_cache_*.json
|
.mainline_cache_*.json
|
||||||
|
.DS_Store
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Agent Development Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||||
|
- **hk** (hk.jdx.dev) - git hook manager
|
||||||
|
- **uv** - fast Python package installer
|
||||||
|
- **ruff** - linter and formatter
|
||||||
|
- **pytest** - test runner
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
mise run install
|
||||||
|
|
||||||
|
# Or equivalently:
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run test # Run tests
|
||||||
|
mise run test-v # Run tests verbose
|
||||||
|
mise run test-cov # Run tests with coverage report
|
||||||
|
mise run lint # Run ruff linter
|
||||||
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
|
mise run format # Run ruff formatter
|
||||||
|
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Hooks
|
||||||
|
|
||||||
|
**At the start of every agent session**, verify hooks are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la .git/hooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
If hooks are not installed, install them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hk init --mise
|
||||||
|
mise run pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses hk configured in `hk.pkl`:
|
||||||
|
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||||
|
- **pre-push**: runs ruff check
|
||||||
|
|
||||||
|
## Workflow Rules
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
|
||||||
|
1. **Always run the test suite** - never commit code that fails tests:
|
||||||
|
```bash
|
||||||
|
mise run test
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Always run the linter**:
|
||||||
|
```bash
|
||||||
|
mise run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Fix any lint errors** before committing (or let the pre-commit hook handle it).
|
||||||
|
|
||||||
|
4. **Review your changes** using `git diff` to understand what will be committed.
|
||||||
|
|
||||||
|
### On Failing Tests
|
||||||
|
|
||||||
|
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
||||||
|
|
||||||
|
- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior.
|
||||||
|
|
||||||
|
- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test.
|
||||||
|
|
||||||
|
**Never** modify a test to make it pass without understanding why it failed.
|
||||||
|
|
||||||
|
### Code Review
|
||||||
|
|
||||||
|
Before committing significant changes:
|
||||||
|
- Run `git diff` to review all changes
|
||||||
|
- Ensure new code follows existing patterns in the codebase
|
||||||
|
- Check that type hints are added for new functions
|
||||||
|
- Verify that tests exist for new functionality
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
mise run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with coverage:
|
||||||
|
```bash
|
||||||
|
mise run test-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||||
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
336
README.md
336
README.md
@@ -2,17 +2,50 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run
|
## Contents
|
||||||
|
|
||||||
|
- [Using](#using)
|
||||||
|
- [Run](#run)
|
||||||
|
- [Config](#config)
|
||||||
|
- [Display Modes](#display-modes)
|
||||||
|
- [Feeds](#feeds)
|
||||||
|
- [Fonts](#fonts)
|
||||||
|
- [ntfy.sh](#ntfysh)
|
||||||
|
- [Figment Mode](#figment-mode)
|
||||||
|
- [Command & Control](#command--control-cc)
|
||||||
|
- [Internals](#internals)
|
||||||
|
- [How it works](#how-it-works)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Setup](#setup)
|
||||||
|
- [Tasks](#tasks)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Linting](#linting)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [Performance](#performance)
|
||||||
|
- [Graphics](#graphics)
|
||||||
|
- [Cyberpunk Vibes](#cyberpunk-vibes)
|
||||||
|
- [Extensibility](#extensibility)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -20,11 +53,28 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
Or with uv:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
uv run mainline.py
|
||||||
|
```
|
||||||
|
|
||||||
## Config
|
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||||
|
|
||||||
|
### Command & Control (C&C)
|
||||||
|
|
||||||
|
Control mainline remotely using `cmdline.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cmdline.py # Interactive TUI
|
||||||
|
uv run cmdline.py /effects list # List all effects
|
||||||
|
uv run cmdline.py /effects stats # Show performance stats
|
||||||
|
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
All constants live in `engine/config.py`:
|
All constants live in `engine/config.py`:
|
||||||
|
|
||||||
@@ -33,34 +83,98 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
||||||
|
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
||||||
|
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
||||||
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
| `SSAA` | `4` | Super-sampling factor |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||||
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
|
||||||
|
### Display Modes
|
||||||
|
|
||||||
|
Mainline supports multiple display backends:
|
||||||
|
|
||||||
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
|
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
||||||
|
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
||||||
|
|
||||||
|
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||||
|
|
||||||
|
### Feeds
|
||||||
|
|
||||||
|
~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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fonts
|
## Internals
|
||||||
|
|
||||||
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.
|
### How it works
|
||||||
|
|
||||||
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
|
||||||
@@ -69,78 +183,158 @@ To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or po
|
|||||||
- 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 poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
|
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
|
||||||
|
|
||||||
---
|
### Architecture
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
engine/
|
engine/
|
||||||
config.py constants, CLI flags, glyph tables
|
__init__.py package marker
|
||||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
config.py constants, CLI flags, glyph tables
|
||||||
filter.py HTML stripping, content filter
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
translate.py Google Translate wrapper + region detection
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
filter.py HTML stripping, content filter
|
||||||
effects.py noise, glitch_bar, fade, firehose
|
translate.py Google Translate wrapper + region detection
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
effects/ plugin architecture for visual effects
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
||||||
scroll.py stream() frame loop + message rendering
|
registry.py effect registration and lookup
|
||||||
app.py main(), font picker TUI, boot sequence, signal handler
|
chain.py effect pipeline chaining
|
||||||
|
controller.py handles /effects commands
|
||||||
|
performance.py performance monitoring
|
||||||
|
legacy.py legacy functional effects
|
||||||
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
|
scroll.py stream() frame loop + message rendering
|
||||||
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
|
frame.py scroll step calculation, timing
|
||||||
|
layers.py ticker zone, firehose, message + figment overlay rendering
|
||||||
|
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
|
||||||
|
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
|
||||||
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
|
events.py event types and definitions
|
||||||
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
|
emitters.py background emitters for ntfy and mic
|
||||||
|
types.py type definitions and dataclasses
|
||||||
|
themes.py THEME_REGISTRY — gradient color definitions
|
||||||
|
display/ Display backend system
|
||||||
|
__init__.py DisplayRegistry, get_monitor
|
||||||
|
backends/
|
||||||
|
terminal.py ANSI terminal display
|
||||||
|
websocket.py WebSocket server for browser clients
|
||||||
|
sixel.py Sixel graphics (pure Python)
|
||||||
|
null.py headless display for testing
|
||||||
|
multi.py forwards to multiple displays
|
||||||
|
benchmark.py performance benchmarking tool
|
||||||
|
|
||||||
|
effects_plugins/
|
||||||
|
__init__.py plugin discovery (ABC issubclass scan)
|
||||||
|
noise.py NoiseEffect — random character noise
|
||||||
|
glitch.py GlitchEffect — horizontal glitch bars
|
||||||
|
fade.py FadeEffect — edge fade zones
|
||||||
|
firehose.py FirehoseEffect — dense bottom ticker strip
|
||||||
|
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
|
||||||
|
|
||||||
|
figments/ SVG assets for figment mode
|
||||||
```
|
```
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feeds
|
## Development
|
||||||
|
|
||||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
### Setup
|
||||||
|
|
||||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
|
||||||
|
|
||||||
```python
|
### Tasks
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
With [mise](https://mise.jdx.dev/):
|
||||||
poller.start()
|
|
||||||
# in render loop:
|
```bash
|
||||||
msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ideas / Future
|
## Roadmap
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
- Concurrent feed fetching with ThreadPoolExecutor
|
||||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
- Background feed refresh daemon
|
||||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
- Translation pre-fetch during boot
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
- Matrix rain katakana underlay
|
||||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
- CRT scanline simulation
|
||||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
- Sixel/iTerm2 inline images
|
||||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
- Parallax secondary column
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
|
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
|
||||||
|
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
|
||||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||||
@@ -154,4 +348,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||||
|
|||||||
250
cmdline.py
Normal file
250
cmdline.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python cmdline.py # Interactive TUI mode
|
||||||
|
python cmdline.py --help # Show help
|
||||||
|
python cmdline.py /effects list # Send single command via ntfy
|
||||||
|
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||||
|
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||||
|
|
||||||
|
The TUI mode provides:
|
||||||
|
- Arrow keys to navigate command history
|
||||||
|
- Tab completion for commands
|
||||||
|
- Auto-refresh for performance stats
|
||||||
|
|
||||||
|
C&C works like a serial port:
|
||||||
|
1. Send command to ntfy_cc_topic
|
||||||
|
2. Mainline receives, processes, responds to same topic
|
||||||
|
3. Cmdline polls for response
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
||||||
|
|
||||||
|
try:
|
||||||
|
CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
|
||||||
|
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
|
||||||
|
except AttributeError:
|
||||||
|
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyResponsePoller:
|
||||||
|
"""Polls ntfy for command responses."""
|
||||||
|
|
||||||
|
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||||
|
self.cmd_topic = cmd_topic
|
||||||
|
self.resp_topic = resp_topic
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_id = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _build_url(self) -> str:
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
parsed = urlparse(self.resp_topic)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||||
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def send_and_wait(self, cmd: str) -> str:
|
||||||
|
"""Send command and wait for response."""
|
||||||
|
url = self.cmd_topic.replace("/json", "")
|
||||||
|
data = cmd.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-cmdline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error sending command: {e}"
|
||||||
|
|
||||||
|
return self._wait_for_response(cmd)
|
||||||
|
|
||||||
|
def _wait_for_response(self, expected_cmd: str = "") -> str:
|
||||||
|
"""Poll for response message."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < self.timeout:
|
||||||
|
try:
|
||||||
|
url = self._build_url()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
for line in resp:
|
||||||
|
try:
|
||||||
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
self._last_id = data.get("id")
|
||||||
|
msg = data.get("message", "")
|
||||||
|
if msg:
|
||||||
|
return msg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
return "Timeout waiting for response"
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_COMMANDS = """Available commands:
|
||||||
|
/effects list - List all effects and status
|
||||||
|
/effects <name> on - Enable an effect
|
||||||
|
/effects <name> off - Disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
|
/help - Show this help
|
||||||
|
/quit - Exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def print_header():
|
||||||
|
w = 60
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print(f"\033[1;1H", end="")
|
||||||
|
print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
|
||||||
|
)
|
||||||
|
print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_response(response: str, is_error: bool = False) -> None:
|
||||||
|
"""Print response with nice formatting."""
|
||||||
|
print()
|
||||||
|
if is_error:
|
||||||
|
print(f" \033[1;38;5;196m✗ Error\033[0m")
|
||||||
|
print(f" \033[38;5;196m{'─' * 40}\033[0m")
|
||||||
|
else:
|
||||||
|
print(f" \033[1;38;5;82m✓ Response\033[0m")
|
||||||
|
print(f" \033[38;5;37m{'─' * 40}\033[0m")
|
||||||
|
|
||||||
|
for line in response.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_mode():
|
||||||
|
"""Interactive TUI for sending commands."""
|
||||||
|
import readline
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.startswith("/"):
|
||||||
|
if cmd == "/quit" or cmd == "/exit":
|
||||||
|
print(f"\n \033[1;38;5;245mGoodbye!{RST}\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
if cmd == "/help":
|
||||||
|
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" \033[38;5;245m⟳ Sending to mainline...{RST}")
|
||||||
|
result = poller.send_and_wait(cmd)
|
||||||
|
print_response(result, is_error=result.startswith("Error"))
|
||||||
|
else:
|
||||||
|
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Mainline command-line interface",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=AVAILABLE_COMMANDS,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Command to send (e.g., /effects list)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--watch",
|
||||||
|
"-w",
|
||||||
|
action="store_true",
|
||||||
|
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
return interactive_mode()
|
||||||
|
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
if args.watch and "/effects stats" in args.command:
|
||||||
|
import signal
|
||||||
|
|
||||||
|
def handle_sigterm(*_):
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(f"\033[2J\033[1;1H", end="")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}"
|
||||||
|
)
|
||||||
|
print(f" \033[38;5;37m{'─' * 44}{RST}")
|
||||||
|
for line in result.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
time.sleep(2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# Color Scheme Switcher Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||||
|
|
||||||
|
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Purpose | Change Type |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||||
|
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||||
|
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||||
|
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||||
|
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||||
|
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Theme Data Module
|
||||||
|
|
||||||
|
### Task 1: Create themes.py with Theme class and registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `engine/themes.py`
|
||||||
|
- Test: `tests/test_themes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for Theme class**
|
||||||
|
|
||||||
|
Create `tests/test_themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Test color themes and registry."""
|
||||||
|
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_construction():
|
||||||
|
"""Theme stores name and gradient lists."""
|
||||||
|
main = ["\033[1;38;5;231m"] * 12
|
||||||
|
msg = ["\033[1;38;5;225m"] * 12
|
||||||
|
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||||
|
|
||||||
|
assert theme.name == "Test Green"
|
||||||
|
assert theme.main_gradient == main
|
||||||
|
assert theme.message_gradient == msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradient_length():
|
||||||
|
"""Each gradient must have exactly 12 ANSI codes."""
|
||||||
|
for theme_id, theme in THEME_REGISTRY.items():
|
||||||
|
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||||
|
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes():
|
||||||
|
"""Registry contains green, orange, purple."""
|
||||||
|
assert len(THEME_REGISTRY) == 3
|
||||||
|
assert "green" in THEME_REGISTRY
|
||||||
|
assert "orange" in THEME_REGISTRY
|
||||||
|
assert "purple" in THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_valid():
|
||||||
|
"""get_theme returns Theme object for valid ID."""
|
||||||
|
theme = get_theme("green")
|
||||||
|
assert isinstance(theme, Theme)
|
||||||
|
assert theme.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_invalid():
|
||||||
|
"""get_theme raises KeyError for invalid ID."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
get_theme("invalid_theme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_green_theme_unchanged():
|
||||||
|
"""Green theme uses original green → magenta colors."""
|
||||||
|
green_theme = get_theme("green")
|
||||||
|
# First color should be white (bold)
|
||||||
|
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||||
|
# Last deep green
|
||||||
|
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||||
|
# Message gradient is magenta
|
||||||
|
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: FAIL (module doesn't exist)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||||
|
|
||||||
|
Create `engine/themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Color theme definitions and registry."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
"""Initialize theme with display name and gradient lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Display name (e.g., "Verdant Green")
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||||
|
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||||
|
# Each gradient: white → primary/complementary, 12 steps total
|
||||||
|
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||||
|
|
||||||
|
_GREEN_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_GREEN_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;215m", # pale orange-white
|
||||||
|
"\033[38;5;209m", # bright orange
|
||||||
|
"\033[38;5;208m", # vibrant orange
|
||||||
|
"\033[38;5;202m", # orange
|
||||||
|
"\033[38;5;166m", # dark orange
|
||||||
|
"\033[38;5;130m", # burnt orange
|
||||||
|
"\033[38;5;94m", # rust
|
||||||
|
"\033[38;5;58m", # dark rust
|
||||||
|
"\033[38;5;94m", # rust (hold)
|
||||||
|
"\033[2;38;5;94m", # dim rust
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;33m", # bright blue
|
||||||
|
"\033[38;5;27m", # blue
|
||||||
|
"\033[38;5;21m", # deep blue
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;18m", # navy
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[2;38;5;18m", # dim navy
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale purple-white
|
||||||
|
"\033[38;5;177m", # bright purple
|
||||||
|
"\033[38;5;171m", # vibrant purple
|
||||||
|
"\033[38;5;165m", # purple
|
||||||
|
"\033[38;5;135m", # medium purple
|
||||||
|
"\033[38;5;129m", # purple
|
||||||
|
"\033[38;5;93m", # dark purple
|
||||||
|
"\033[38;5;57m", # deep purple
|
||||||
|
"\033[38;5;57m", # deep purple (hold)
|
||||||
|
"\033[2;38;5;57m", # dim deep purple
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;226m", # pale yellow-white
|
||||||
|
"\033[38;5;226m", # bright yellow
|
||||||
|
"\033[38;5;220m", # yellow
|
||||||
|
"\033[38;5;220m", # yellow (hold)
|
||||||
|
"\033[38;5;184m", # dark yellow
|
||||||
|
"\033[38;5;184m", # dark yellow (hold)
|
||||||
|
"\033[38;5;178m", # olive-yellow
|
||||||
|
"\033[38;5;178m", # olive-yellow (hold)
|
||||||
|
"\033[38;5;172m", # golden
|
||||||
|
"\033[2;38;5;172m", # dim golden
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme(
|
||||||
|
name="Verdant Green",
|
||||||
|
main_gradient=_GREEN_MAIN,
|
||||||
|
message_gradient=_GREEN_MESSAGE,
|
||||||
|
),
|
||||||
|
"orange": Theme(
|
||||||
|
name="Molten Orange",
|
||||||
|
main_gradient=_ORANGE_MAIN,
|
||||||
|
message_gradient=_ORANGE_MESSAGE,
|
||||||
|
),
|
||||||
|
"purple": Theme(
|
||||||
|
name="Violet Purple",
|
||||||
|
main_gradient=_PURPLE_MAIN,
|
||||||
|
message_gradient=_PURPLE_MESSAGE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id: str) -> Theme:
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id not found in registry
|
||||||
|
"""
|
||||||
|
if theme_id not in THEME_REGISTRY:
|
||||||
|
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: PASS (all 6 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/themes.py tests/test_themes.py
|
||||||
|
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||||
|
|
||||||
|
- Define Theme class to encapsulate name and main/message gradients
|
||||||
|
- Create THEME_REGISTRY with green, orange, purple themes
|
||||||
|
- Each gradient has 12 ANSI 256-color codes finalized
|
||||||
|
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||||
|
- Add get_theme() lookup with error handling
|
||||||
|
- Add comprehensive unit tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Config Integration
|
||||||
|
|
||||||
|
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/config.py:1-30`
|
||||||
|
- Test: `tests/test_config.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for config changes**
|
||||||
|
|
||||||
|
Add to `tests/test_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_active_theme_initially_none():
|
||||||
|
"""ACTIVE_THEME is None before initialization."""
|
||||||
|
# This test may fail if config is already initialized
|
||||||
|
# We'll set it to None first for testing
|
||||||
|
import engine.config
|
||||||
|
engine.config.ACTIVE_THEME = None
|
||||||
|
assert engine.config.ACTIVE_THEME is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_green():
|
||||||
|
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
set_active_theme("green")
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME is not None
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
assert config.ACTIVE_THEME == get_theme("green")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_default():
|
||||||
|
"""set_active_theme() with no args defaults to green."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
set_active_theme()
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_invalid():
|
||||||
|
"""set_active_theme() with invalid ID raises KeyError."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
set_active_theme("invalid")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: FAIL (functions don't exist yet)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||||
|
|
||||||
|
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is invalid
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DELETED:
|
||||||
|
# GRAD_COLS = [...]
|
||||||
|
# MSG_GRAD_COLS = [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 4 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify existing config tests still pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (all existing + new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/config.py tests/test_config.py
|
||||||
|
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||||
|
|
||||||
|
- Add ACTIVE_THEME global (initialized to None)
|
||||||
|
- Add set_active_theme(theme_id) function with green default
|
||||||
|
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||||
|
- Add comprehensive tests for theme setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Render Pipeline Integration
|
||||||
|
|
||||||
|
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/render.py:15-220`
|
||||||
|
- Test: `tests/test_render.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||||
|
|
||||||
|
Add to `tests/test_render.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||||
|
from engine import config, render
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
# Set orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
# Create simple rows
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Result should start with first color from orange main gradient
|
||||||
|
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
# Clear active theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Should not crash and should return something
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_green_gradient_length():
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
from engine import render
|
||||||
|
|
||||||
|
colors = render._default_green_gradient()
|
||||||
|
assert len(colors) == 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Expected: FAIL (function signature doesn't match)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
"""
|
||||||
|
Render rows through a left-to-right color sweep.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text rows to colorize
|
||||||
|
offset: Gradient position offset (for animation)
|
||||||
|
cols: Optional list of color codes. If None, uses active theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of colorized rows
|
||||||
|
"""
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... rest of function unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||||
|
|
||||||
|
Add to `engine/render.py` before `lr_gradient()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (original colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 3 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full render test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing tests may need adjustment for mocking)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/render.py tests/test_render.py
|
||||||
|
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||||
|
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||||
|
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||||
|
- Add tests for theme-aware and fallback gradient rendering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Message Gradient Integration
|
||||||
|
|
||||||
|
### Task 4: Update scroll.py to use message gradient from config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/scroll.py:85-95`
|
||||||
|
- Test: existing `tests/test_scroll.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||||
|
|
||||||
|
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||||
|
|
||||||
|
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||||
|
|
||||||
|
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||||
|
|
||||||
|
Old code:
|
||||||
|
```python
|
||||||
|
# Some variation of:
|
||||||
|
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
```
|
||||||
|
|
||||||
|
New code:
|
||||||
|
```python
|
||||||
|
from engine import config
|
||||||
|
msg_cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else render._default_magenta_gradient()
|
||||||
|
)
|
||||||
|
rows = lr_gradient(rows, offset, msg_cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in scroll.py:
|
||||||
|
```python
|
||||||
|
rows = render.msg_gradient(rows, offset)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing scroll tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_scroll.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing functionality unchanged)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/scroll.py engine/render.py
|
||||||
|
git commit -m "feat: update scroll.py to use theme message gradient
|
||||||
|
|
||||||
|
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||||
|
- Use fallback magenta gradient when theme not initialized
|
||||||
|
- Ensure ntfy messages render in complementary color from selected theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Color Picker UI
|
||||||
|
|
||||||
|
### Task 5: Create pick_color_theme() function in app.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/app.py:1-300`
|
||||||
|
- Test: manual/integration (interactive)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu."""
|
||||||
|
import sys
|
||||||
|
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create pick_color_theme() function**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add after helper function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use green
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... existing signal handler setup ...
|
||||||
|
|
||||||
|
pick_color_theme() # NEW LINE - before font picker
|
||||||
|
pick_font_face()
|
||||||
|
|
||||||
|
# ... rest of main unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- See color theme picker menu before font picker
|
||||||
|
- Can navigate with ↑/↓ or j/k
|
||||||
|
- Can select with Enter or q
|
||||||
|
- Selected theme applies to scrolling headlines
|
||||||
|
- Can select different themes and see colors change
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||||
|
|
||||||
|
Run: `echo "" | python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- No color picker menu shown
|
||||||
|
- Defaults to green theme
|
||||||
|
- App runs without error
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/app.py
|
||||||
|
git commit -m "feat: add pick_color_theme() UI and integration
|
||||||
|
|
||||||
|
- Create _draw_color_picker() to render menu
|
||||||
|
- Create pick_color_theme() function mirroring font picker pattern
|
||||||
|
- Integrate into main() before font picker
|
||||||
|
- Fallback to green theme in non-interactive environments
|
||||||
|
- Support arrow keys and j/k navigation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: Integration & Validation
|
||||||
|
|
||||||
|
### Task 6: End-to-end testing and cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: All modified files
|
||||||
|
- Verify: App functionality
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
|
||||||
|
Expected: PASS (all tests, including new ones)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
Run: `ruff check engine/ mainline.py`
|
||||||
|
|
||||||
|
Expected: No errors (fix any style issues)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual integration test - green theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Verdant Green" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in green → deep green
|
||||||
|
- ntfy messages render in magenta gradient
|
||||||
|
- Both work correctly during streaming
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual integration test - orange theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Molten Orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in orange → deep orange
|
||||||
|
- ntfy messages render in blue gradient
|
||||||
|
- Colors are visually distinct from green
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual integration test - purple theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Violet Purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in purple → deep purple
|
||||||
|
- ntfy messages render in yellow gradient
|
||||||
|
- Colors are visually distinct from green and orange
|
||||||
|
|
||||||
|
- [ ] **Step 6: Test poetry mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --poetry`
|
||||||
|
|
||||||
|
Then select "orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Poetry mode works with color picker
|
||||||
|
- Colors apply to poetry rendering
|
||||||
|
|
||||||
|
- [ ] **Step 7: Test code mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --code`
|
||||||
|
|
||||||
|
Then select "purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Code mode works with color picker
|
||||||
|
- Colors apply to code rendering
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify acceptance criteria**
|
||||||
|
|
||||||
|
✓ Color picker displays 3 theme options at startup
|
||||||
|
✓ Selection applies to all headline and message gradients
|
||||||
|
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
✓ No persistence between runs (each run picks fresh)
|
||||||
|
✓ Non-TTY environments default to green without error
|
||||||
|
✓ Architecture supports future random/animation modes
|
||||||
|
✓ All gradient color codes finalized with no TBD values
|
||||||
|
|
||||||
|
- [ ] **Step 9: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: color scheme switcher implementation complete
|
||||||
|
|
||||||
|
Closes color-pick feature with:
|
||||||
|
- Three selectable color themes (green, orange, purple)
|
||||||
|
- Interactive menu at startup (mirrors font picker UI)
|
||||||
|
- Complementary colors for ntfy message queue
|
||||||
|
- Fallback to green in non-interactive environments
|
||||||
|
- All tests passing, manual validation complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Create feature branch PR summary**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Color Scheme Switcher
|
||||||
|
|
||||||
|
Implements interactive color theme selection for Mainline news ticker.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||||
|
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||||
|
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||||
|
- Fresh theme selection each run (no persistence)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||||
|
- `engine/render.py` (theme-aware gradients)
|
||||||
|
- `engine/scroll.py` (message gradient integration)
|
||||||
|
- `engine/app.py` (pick_color_theme UI)
|
||||||
|
- `tests/test_themes.py` (new theme tests)
|
||||||
|
- `README.md` (documentation)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
All met. App fully tested and ready for merge.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||||
|
- [ ] Full suite: `pytest tests/ -v`
|
||||||
|
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||||
|
- [ ] Manual: Green theme selection
|
||||||
|
- [ ] Manual: Orange theme selection
|
||||||
|
- [ ] Manual: Purple theme selection
|
||||||
|
- [ ] Manual: Poetry mode with colors
|
||||||
|
- [ ] Manual: Code mode with colors
|
||||||
|
- [ ] Manual: Non-TTY fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||||
|
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||||
|
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||||
|
- Message gradients use complementary colors; lookup in scroll.py
|
||||||
|
- Each gradient has 12 colors; verify length in tests
|
||||||
|
- No persistence; fresh picker each run
|
||||||
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 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
|
||||||
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Code Scroll Mode — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Branch:** feat/code-scroll
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Mirror the existing `--poetry` mode pattern as closely as possible
|
||||||
|
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
||||||
|
- No changes to `scroll.py` or the render pipeline
|
||||||
|
- The item tuple shape `(text, src, ts)` is unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `engine/fetch_code.py`
|
||||||
|
|
||||||
|
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
|
||||||
|
1. Glob `engine/*.py` in sorted order
|
||||||
|
2. For each file:
|
||||||
|
a. Read source text
|
||||||
|
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
||||||
|
c. Iterate source lines (1-indexed). Skip if:
|
||||||
|
- The stripped line is empty
|
||||||
|
- The stripped line starts with `#`
|
||||||
|
d. For each kept line emit:
|
||||||
|
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
||||||
|
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
||||||
|
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
||||||
|
3. Return `(items, len(items), 0)`
|
||||||
|
|
||||||
|
**Scope label rules:**
|
||||||
|
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
||||||
|
- `ClassDef` → `name` (no parens)
|
||||||
|
- No enclosing node → `<module>`
|
||||||
|
|
||||||
|
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### `engine/config.py`
|
||||||
|
|
||||||
|
Extend `MODE` detection to recognise `--code`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MODE = (
|
||||||
|
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code" if "--code" in sys.argv
|
||||||
|
else "news"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `engine/app.py`
|
||||||
|
|
||||||
|
**Subtitle line** — extend the subtitle dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_subtitle = {
|
||||||
|
"poetry": "literary consciousness stream",
|
||||||
|
"code": "source consciousness stream",
|
||||||
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif config.MODE == "code":
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, line_count, _ = fetch_code()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||||
|
```
|
||||||
|
|
||||||
|
No cache save/load — local source files are read instantly and change only on disk writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/*.py (sorted)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
fetch_code()
|
||||||
|
│ ast.parse → scope map
|
||||||
|
│ filter blank + comment lines
|
||||||
|
│ emit (line, scope(), engine.module)
|
||||||
|
▼
|
||||||
|
items: List[Tuple[str, str, str]]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
stream(items, ntfy, mic) ← unchanged
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
next_headline() shuffles + recycles automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
||||||
|
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New file: `tests/test_fetch_code.py`
|
||||||
|
|
||||||
|
| Test | Assertion |
|
||||||
|
|------|-----------|
|
||||||
|
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
||||||
|
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
||||||
|
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
||||||
|
|
||||||
|
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 mainline.py --code # source consciousness mode
|
||||||
|
uv run mainline.py --code
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Syntax highlighting / token-aware coloring (can be added later)
|
||||||
|
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
||||||
|
- Caching code items to disk
|
||||||
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Color Scheme Switcher Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Status:** Revised after review
|
||||||
|
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||||
|
|
||||||
|
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Functional:**
|
||||||
|
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||||
|
2. Main headline gradient uses the selected primary color (white → color)
|
||||||
|
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||||
|
4. Selection is fresh each run (no persistence)
|
||||||
|
5. Design supports future "random rotation" mode without refactoring
|
||||||
|
|
||||||
|
**Complementary colors (precise opposites):**
|
||||||
|
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||||
|
- Orange (38;5;208) → Blue (38;5;21)
|
||||||
|
- Purple (38;5;129) → Yellow (38;5;226)
|
||||||
|
|
||||||
|
**Non-functional:**
|
||||||
|
- Reuse the existing font picker pattern for UI consistency
|
||||||
|
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||||
|
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||||
|
- Font picker UI remains hardcoded green for visual continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Module: `engine/themes.py`
|
||||||
|
|
||||||
|
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient # white → primary color
|
||||||
|
self.message_gradient = message_gradient # white → complementary
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Registry:**
|
||||||
|
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||||
|
|
||||||
|
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright color
|
||||||
|
"\033[38;5;40m", # color
|
||||||
|
"\033[38;5;34m", # medium color
|
||||||
|
"\033[38;5;28m", # dark color
|
||||||
|
"\033[38;5;22m", # deep color
|
||||||
|
"\033[2;38;5;22m", # dim deep color
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finalized color codes:**
|
||||||
|
|
||||||
|
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||||
|
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||||
|
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||||
|
|
||||||
|
**Orange (primary: 208, complementary: 21)**
|
||||||
|
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||||
|
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||||
|
|
||||||
|
**Purple (primary: 129, complementary: 226)**
|
||||||
|
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||||
|
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||||
|
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/config.py`
|
||||||
|
|
||||||
|
**New globals:**
|
||||||
|
```python
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||||
|
```
|
||||||
|
|
||||||
|
**New function:**
|
||||||
|
```python
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Called by `app.pick_color_theme()` with user selection
|
||||||
|
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||||
|
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||||
|
|
||||||
|
**Removal:**
|
||||||
|
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/render.py`
|
||||||
|
|
||||||
|
**Updated gradient access in existing functions:**
|
||||||
|
|
||||||
|
Current pattern (will be removed):
|
||||||
|
```python
|
||||||
|
GRAD_COLS = [...] # hardcoded green
|
||||||
|
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||||
|
```
|
||||||
|
|
||||||
|
New pattern — update `lr_gradient()` function:
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient())
|
||||||
|
# ... rest of function unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Define fallback:**
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (current colors)."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||||
|
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||||
|
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||||
|
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message gradient handling:**
|
||||||
|
|
||||||
|
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||||
|
```python
|
||||||
|
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
# Use:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
or define a helper:
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/app.py`
|
||||||
|
|
||||||
|
**New function: `pick_color_theme()`**
|
||||||
|
|
||||||
|
Mirrors `pick_font_face()` pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use default
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker (similar to font picker)
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||||
|
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement in `main()`:**
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... signal handler setup ...
|
||||||
|
pick_color_theme() # NEW — before title/subtitle
|
||||||
|
pick_font_face()
|
||||||
|
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User starts: mainline.py
|
||||||
|
↓
|
||||||
|
main() called
|
||||||
|
↓
|
||||||
|
pick_color_theme()
|
||||||
|
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||||
|
→ If not TTY: silently call config.set_active_theme("green")
|
||||||
|
↓
|
||||||
|
pick_font_face() — renders in hardcoded green UI colors
|
||||||
|
↓
|
||||||
|
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||||
|
↓
|
||||||
|
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||||
|
↓
|
||||||
|
On exit: no persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Initialization Guarantee
|
||||||
|
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||||
|
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||||
|
2. It's called before any rendering happens
|
||||||
|
3. Default fallback ensures non-TTY environments don't crash
|
||||||
|
|
||||||
|
### Module Independence
|
||||||
|
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||||
|
|
||||||
|
### Color Code Finalization
|
||||||
|
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||||
|
|
||||||
|
### Theme ID Naming
|
||||||
|
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||||
|
|
||||||
|
### Terminal Resize Handling
|
||||||
|
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||||
|
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||||
|
2. Once a theme is selected, the menu closes and rendering begins
|
||||||
|
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||||
|
|
||||||
|
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
1. **Unit tests** (`tests/test_themes.py`):
|
||||||
|
- Verify Theme class construction
|
||||||
|
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||||
|
- Confirm gradient lists have correct length (12)
|
||||||
|
|
||||||
|
2. **Integration tests** (`tests/test_render.py`):
|
||||||
|
- Mock `config.ACTIVE_THEME` to each theme
|
||||||
|
- Verify `lr_gradient()` uses correct colors
|
||||||
|
- Verify fallback works when `ACTIVE_THEME` is None
|
||||||
|
|
||||||
|
3. **Existing tests:**
|
||||||
|
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||||
|
- Use pytest fixtures to set theme per test case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||||
|
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||||
|
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||||
|
- `tests/test_themes.py` (new unit tests)
|
||||||
|
- `tests/test_render.py` (update mocking strategy)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. ✓ Color picker displays 3 theme options at startup
|
||||||
|
2. ✓ Selection applies to all headline and message gradients
|
||||||
|
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
5. ✓ No persistence between runs
|
||||||
|
6. ✓ Non-TTY environments default to green without error
|
||||||
|
7. ✓ Architecture supports future random/animation modes
|
||||||
|
8. ✓ All gradient color codes finalized with no TBD values
|
||||||
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
# Figment Mode Design Spec
|
||||||
|
|
||||||
|
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
||||||
|
|
||||||
|
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
||||||
|
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
||||||
|
- Random color from existing theme gradients (green, orange, purple)
|
||||||
|
- Configurable interval and duration via C&C
|
||||||
|
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Multi-figment simultaneous display (one at a time)
|
||||||
|
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
||||||
|
- Custom color palettes beyond existing themes
|
||||||
|
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
||||||
|
|
||||||
|
## Architecture: Hybrid Plugin + Overlay
|
||||||
|
|
||||||
|
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
||||||
|
|
||||||
|
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
||||||
|
|
||||||
|
### Component Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------+
|
||||||
|
| FigmentTrigger | (Protocol)
|
||||||
|
| - NtfyTrigger | (v1)
|
||||||
|
| - MqttTrigger | (future)
|
||||||
|
| - SerialTrigger | (future)
|
||||||
|
+--------+----------+
|
||||||
|
|
|
||||||
|
| FigmentCommand
|
||||||
|
v
|
||||||
|
+------------------+ +-----------------+ +----------------------+
|
||||||
|
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
||||||
|
| .py | | (EffectPlugin) | | overlay() in |
|
||||||
|
| | | | | layers.py |
|
||||||
|
| SVG -> PIL -> | | Timer, state | | |
|
||||||
|
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
||||||
|
| | | selection | | commands for overlay |
|
||||||
|
+------------------+ +-----------------+ +----------------------+
|
||||||
|
|
|
||||||
|
| get_figment_state()
|
||||||
|
v
|
||||||
|
+-------------------+
|
||||||
|
| scroll.py |
|
||||||
|
+-------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Section 1: SVG Rasterization
|
||||||
|
|
||||||
|
**File: `engine/figment_render.py`**
|
||||||
|
|
||||||
|
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
|
|
||||||
|
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
||||||
|
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
||||||
|
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
||||||
|
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
||||||
|
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
||||||
|
|
||||||
|
### Dependency
|
||||||
|
|
||||||
|
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
||||||
|
|
||||||
|
### Key Function
|
||||||
|
|
||||||
|
```python
|
||||||
|
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||||
|
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Section 2: Figment Overlay Rendering
|
||||||
|
|
||||||
|
**Integration point: `engine/layers.py`**
|
||||||
|
|
||||||
|
New function following the `render_message_overlay()` pattern.
|
||||||
|
|
||||||
|
### FigmentState Dataclass
|
||||||
|
|
||||||
|
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class FigmentState:
|
||||||
|
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
||||||
|
progress: float # 0.0 to 1.0 within current phase
|
||||||
|
rows: list[str] # rasterized half-block rows (uncolored)
|
||||||
|
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
||||||
|
center_row: int # top row for centering in viewport
|
||||||
|
center_col: int # left column for centering in viewport
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function Signature
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
||||||
|
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Phases (~4.5 seconds total)
|
||||||
|
|
||||||
|
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
||||||
|
|
||||||
|
| Phase | Duration | Behavior |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
||||||
|
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
||||||
|
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
||||||
|
|
||||||
|
### Color
|
||||||
|
|
||||||
|
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
||||||
|
|
||||||
|
### Positioning
|
||||||
|
|
||||||
|
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
||||||
|
|
||||||
|
## Section 3: FigmentEffect (Effect Plugin)
|
||||||
|
|
||||||
|
**File: `effects_plugins/figment.py`**
|
||||||
|
|
||||||
|
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
||||||
|
|
||||||
|
### Chain Exclusion
|
||||||
|
|
||||||
|
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
||||||
|
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
||||||
|
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
||||||
|
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
||||||
|
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
||||||
|
|
||||||
|
### State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
idle ──(timer fires or trigger received)──> reveal
|
||||||
|
reveal ──(progress >= 1.0)──> hold
|
||||||
|
hold ──(progress >= 1.0)──> dissolve
|
||||||
|
dissolve ──(progress >= 1.0)──> idle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interface
|
||||||
|
|
||||||
|
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
||||||
|
"""Tick the state machine and return current state, or None if idle."""
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
||||||
|
|
||||||
|
### Scroll Loop Access
|
||||||
|
|
||||||
|
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
|
||||||
|
plugin = registry.get("figment")
|
||||||
|
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
||||||
|
|
||||||
|
### EffectConfig
|
||||||
|
|
||||||
|
- `enabled`: bool (default `False` — opt-in)
|
||||||
|
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
||||||
|
- `params`:
|
||||||
|
- `interval_secs`: 60 (time between figments)
|
||||||
|
- `display_secs`: 4.5 (total animation duration)
|
||||||
|
- `figment_dir`: "figments" (SVG source directory)
|
||||||
|
|
||||||
|
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
||||||
|
|
||||||
|
## Section 4: Input Abstraction (FigmentTrigger)
|
||||||
|
|
||||||
|
**File: `engine/figment_trigger.py`**
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FigmentTrigger(Protocol):
|
||||||
|
def poll(self) -> FigmentCommand | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### FigmentCommand
|
||||||
|
|
||||||
|
```python
|
||||||
|
class FigmentAction(Enum):
|
||||||
|
TRIGGER = "trigger"
|
||||||
|
SET_INTENSITY = "set_intensity"
|
||||||
|
SET_INTERVAL = "set_interval"
|
||||||
|
SET_COLOR = "set_color"
|
||||||
|
STOP = "stop"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentCommand:
|
||||||
|
action: FigmentAction
|
||||||
|
value: float | str | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
||||||
|
|
||||||
|
### Adapters
|
||||||
|
|
||||||
|
| Adapter | Transport | Dependency | Status |
|
||||||
|
|---------|-----------|------------|--------|
|
||||||
|
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
||||||
|
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
||||||
|
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
||||||
|
|
||||||
|
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
||||||
|
|
||||||
|
### EventBus Bridge
|
||||||
|
|
||||||
|
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
||||||
|
|
||||||
|
## Section 5: Scroll Loop Integration
|
||||||
|
|
||||||
|
Minimal change to `engine/scroll.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In stream() setup (with safe import):
|
||||||
|
try:
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
_plugin = registry.get("figment")
|
||||||
|
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
||||||
|
except ImportError:
|
||||||
|
figment = None
|
||||||
|
|
||||||
|
# In frame loop, after effects processing, before ntfy message overlay:
|
||||||
|
if figment and figment.config.enabled:
|
||||||
|
figment_state = figment.get_figment_state(frame_number)
|
||||||
|
if figment_state is not None:
|
||||||
|
figment_overlay = render_figment_overlay(figment_state, w, h)
|
||||||
|
buf.extend(figment_overlay)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay Priority
|
||||||
|
|
||||||
|
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
||||||
|
- Ntfy messages always appear on top of figments (higher priority)
|
||||||
|
- Existing glitch/noise effects run over the ticker underneath the figment
|
||||||
|
|
||||||
|
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
||||||
|
|
||||||
|
## Section 6: Error Handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
||||||
|
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
||||||
|
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
||||||
|
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
||||||
|
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
||||||
|
|
||||||
|
## Section 7: File Summary
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
||||||
|
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
||||||
|
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
||||||
|
| `figments/` | SVG source directory (ships with sample SVGs) |
|
||||||
|
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
||||||
|
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
||||||
|
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
||||||
|
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
||||||
|
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
||||||
|
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
||||||
|
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
||||||
|
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
||||||
|
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
||||||
|
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
||||||
36
effects_plugins/__init__.py
Normal file
36
effects_plugins/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins():
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
from engine.effects.types import EffectPlugin
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||||
|
if file_path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = file_path.stem
|
||||||
|
if module_name in ("base", "types"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||||
|
for attr_name in dir(module):
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and issubclass(attr, EffectPlugin)
|
||||||
|
and attr is not EffectPlugin
|
||||||
|
and attr_name.endswith("Effect")
|
||||||
|
):
|
||||||
|
plugin = attr()
|
||||||
|
registry.register(plugin)
|
||||||
|
imported[plugin.name] = plugin
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return imported
|
||||||
58
effects_plugins/fade.py
Normal file
58
effects_plugins/fade.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class FadeEffect(EffectPlugin):
|
||||||
|
name = "fade"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||||
|
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
if r >= ctx.ticker_height:
|
||||||
|
continue
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = (
|
||||||
|
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||||
|
if bot_zone > 0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
row_fade = min(top_f, bot_f) * intensity
|
||||||
|
|
||||||
|
if row_fade < 1.0 and result[r].strip():
|
||||||
|
result[r] = self._fade_line(result[r], row_fade)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _fade_line(self, s: str, fade: float) -> str:
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ""
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == " ":
|
||||||
|
result.append(" ")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
200
effects_plugins/figment.py
Normal file
200
effects_plugins/figment.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""
|
||||||
|
Figment effect plugin — periodic SVG glyph overlay.
|
||||||
|
|
||||||
|
Owns the figment lifecycle: timer, SVG selection, state machine.
|
||||||
|
Delegates rendering to render_figment_overlay() in engine/layers.py.
|
||||||
|
|
||||||
|
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
|
||||||
|
convention in effects_plugins/__init__.py.
|
||||||
|
|
||||||
|
NOT added to the EffectChain order — process() is a no-op. The overlay
|
||||||
|
rendering is handled by scroll.py calling get_figment_state().
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.figment_render import rasterize_svg
|
||||||
|
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
||||||
|
from engine.themes import THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentPhase(Enum):
|
||||||
|
REVEAL = auto()
|
||||||
|
HOLD = auto()
|
||||||
|
DISSOLVE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentState:
|
||||||
|
phase: FigmentPhase
|
||||||
|
progress: float
|
||||||
|
rows: list[str]
|
||||||
|
gradient: list[int]
|
||||||
|
center_row: int
|
||||||
|
center_col: int
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentEffect(EffectPlugin):
|
||||||
|
name = "figment"
|
||||||
|
config = EffectConfig(
|
||||||
|
enabled=False,
|
||||||
|
intensity=1.0,
|
||||||
|
params={
|
||||||
|
"interval_secs": 60,
|
||||||
|
"display_secs": 4.5,
|
||||||
|
"figment_dir": "figments",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
figment_dir: str | None = None,
|
||||||
|
triggers: list[FigmentTrigger] | None = None,
|
||||||
|
):
|
||||||
|
self.config = EffectConfig(
|
||||||
|
enabled=False,
|
||||||
|
intensity=1.0,
|
||||||
|
params={
|
||||||
|
"interval_secs": 60,
|
||||||
|
"display_secs": 4.5,
|
||||||
|
"figment_dir": figment_dir or "figments",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._triggers = triggers or []
|
||||||
|
self._phase: FigmentPhase | None = None
|
||||||
|
self._progress: float = 0.0
|
||||||
|
self._rows: list[str] = []
|
||||||
|
self._gradient: list[int] = []
|
||||||
|
self._center_row: int = 0
|
||||||
|
self._center_col: int = 0
|
||||||
|
self._timer: float = 0.0
|
||||||
|
self._last_svg: str | None = None
|
||||||
|
self._svg_files: list[str] = []
|
||||||
|
self._scan_svgs()
|
||||||
|
|
||||||
|
def _scan_svgs(self) -> None:
|
||||||
|
figment_dir = Path(self.config.params["figment_dir"])
|
||||||
|
if figment_dir.is_dir():
|
||||||
|
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
# Preserve figment_dir if the new config doesn't supply one
|
||||||
|
figment_dir = cfg.params.get(
|
||||||
|
"figment_dir", self.config.params.get("figment_dir", "figments")
|
||||||
|
)
|
||||||
|
self.config = cfg
|
||||||
|
if "figment_dir" not in self.config.params:
|
||||||
|
self.config.params["figment_dir"] = figment_dir
|
||||||
|
self._scan_svgs()
|
||||||
|
|
||||||
|
def trigger(self, w: int, h: int) -> None:
|
||||||
|
"""Manually trigger a figment display."""
|
||||||
|
if not self._svg_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick a random SVG, avoid repeating
|
||||||
|
candidates = [s for s in self._svg_files if s != self._last_svg]
|
||||||
|
if not candidates:
|
||||||
|
candidates = self._svg_files
|
||||||
|
svg_path = random.choice(candidates)
|
||||||
|
self._last_svg = svg_path
|
||||||
|
|
||||||
|
# Rasterize
|
||||||
|
try:
|
||||||
|
self._rows = rasterize_svg(svg_path, w, h)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick random theme gradient
|
||||||
|
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
||||||
|
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
||||||
|
|
||||||
|
# Center in viewport
|
||||||
|
figment_h = len(self._rows)
|
||||||
|
figment_w = max((len(r) for r in self._rows), default=0)
|
||||||
|
self._center_row = max(0, (h - figment_h) // 2)
|
||||||
|
self._center_col = max(0, (w - figment_w) // 2)
|
||||||
|
|
||||||
|
# Start reveal phase
|
||||||
|
self._phase = FigmentPhase.REVEAL
|
||||||
|
self._progress = 0.0
|
||||||
|
|
||||||
|
def get_figment_state(
|
||||||
|
self, frame_number: int, w: int, h: int
|
||||||
|
) -> FigmentState | None:
|
||||||
|
"""Tick the state machine and return current state, or None if idle."""
|
||||||
|
if not self.config.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Poll triggers
|
||||||
|
for trig in self._triggers:
|
||||||
|
cmd = trig.poll()
|
||||||
|
if cmd is not None:
|
||||||
|
self._handle_command(cmd, w, h)
|
||||||
|
|
||||||
|
# Tick timer when idle
|
||||||
|
if self._phase is None:
|
||||||
|
self._timer += config.FRAME_DT
|
||||||
|
interval = self.config.params.get("interval_secs", 60)
|
||||||
|
if self._timer >= interval:
|
||||||
|
self._timer = 0.0
|
||||||
|
self.trigger(w, h)
|
||||||
|
|
||||||
|
# Tick animation — snapshot current phase/progress, then advance
|
||||||
|
if self._phase is not None:
|
||||||
|
# Capture the state at the start of this frame
|
||||||
|
current_phase = self._phase
|
||||||
|
current_progress = self._progress
|
||||||
|
|
||||||
|
# Advance for next frame
|
||||||
|
display_secs = self.config.params.get("display_secs", 4.5)
|
||||||
|
phase_duration = display_secs / 3.0
|
||||||
|
self._progress += config.FRAME_DT / phase_duration
|
||||||
|
|
||||||
|
if self._progress >= 1.0:
|
||||||
|
self._progress = 0.0
|
||||||
|
if self._phase == FigmentPhase.REVEAL:
|
||||||
|
self._phase = FigmentPhase.HOLD
|
||||||
|
elif self._phase == FigmentPhase.HOLD:
|
||||||
|
self._phase = FigmentPhase.DISSOLVE
|
||||||
|
elif self._phase == FigmentPhase.DISSOLVE:
|
||||||
|
self._phase = None
|
||||||
|
|
||||||
|
return FigmentState(
|
||||||
|
phase=current_phase,
|
||||||
|
progress=current_progress,
|
||||||
|
rows=self._rows,
|
||||||
|
gradient=self._gradient,
|
||||||
|
center_row=self._center_row,
|
||||||
|
center_col=self._center_col,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
||||||
|
if cmd.action == FigmentAction.TRIGGER:
|
||||||
|
self.trigger(w, h)
|
||||||
|
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
|
||||||
|
cmd.value, (int, float)
|
||||||
|
):
|
||||||
|
self.config.intensity = float(cmd.value)
|
||||||
|
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
|
||||||
|
cmd.value, (int, float)
|
||||||
|
):
|
||||||
|
self.config.params["interval_secs"] = float(cmd.value)
|
||||||
|
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
||||||
|
if cmd.value in THEME_REGISTRY:
|
||||||
|
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
||||||
|
elif cmd.action == FigmentAction.STOP:
|
||||||
|
self._phase = None
|
||||||
|
self._progress = 0.0
|
||||||
72
effects_plugins/firehose.py
Normal file
72
effects_plugins/firehose.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class FirehoseEffect(EffectPlugin):
|
||||||
|
name = "firehose"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
if firehose_h <= 0 or not ctx.items:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
h = ctx.terminal_height
|
||||||
|
|
||||||
|
for fr in range(firehose_h):
|
||||||
|
scr_row = h - firehose_h + fr + 1
|
||||||
|
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||||
|
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35 * intensity:
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55 * intensity:
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78 * intensity:
|
||||||
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = "".join(
|
||||||
|
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||||
|
)
|
||||||
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
37
effects_plugins/glitch.py
Normal file
37
effects_plugins/glitch.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchEffect(EffectPlugin):
|
||||||
|
name = "glitch"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||||
|
glitch_prob = glitch_prob * intensity
|
||||||
|
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||||
|
n_hits = int(n_hits * intensity)
|
||||||
|
|
||||||
|
if random.random() < glitch_prob:
|
||||||
|
for _ in range(min(n_hits, len(result))):
|
||||||
|
gi = random.randint(0, len(result) - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _glitch_bar(self, w: int) -> str:
|
||||||
|
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
36
effects_plugins/noise.py
Normal file
36
effects_plugins/noise.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseEffect(EffectPlugin):
|
||||||
|
name = "noise"
|
||||||
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
probability = intensity * 0.15
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
cy = ctx.scroll_cam + r
|
||||||
|
if random.random() < probability:
|
||||||
|
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate_noise(self, w: int, cy: int) -> str:
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
154
engine/app.py
154
engine/app.py
@@ -2,23 +2,33 @@
|
|||||||
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import signal
|
|
||||||
import atexit
|
import atexit
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
import termios
|
import termios
|
||||||
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render, themes
|
||||||
from engine.terminal import (
|
|
||||||
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
|
||||||
slow_print, boot_ln,
|
|
||||||
)
|
|
||||||
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.ntfy import NtfyPoller
|
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
from engine.scroll import stream
|
from engine.scroll import stream
|
||||||
|
from engine.terminal import (
|
||||||
|
CLR,
|
||||||
|
CURSOR_OFF,
|
||||||
|
CURSOR_ON,
|
||||||
|
G_DIM,
|
||||||
|
G_HI,
|
||||||
|
G_MID,
|
||||||
|
RST,
|
||||||
|
W_DIM,
|
||||||
|
W_GHOST,
|
||||||
|
boot_ln,
|
||||||
|
slow_print,
|
||||||
|
tw,
|
||||||
|
)
|
||||||
|
|
||||||
TITLE = [
|
TITLE = [
|
||||||
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||||
@@ -29,6 +39,7 @@ TITLE = [
|
|||||||
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _read_picker_key():
|
def _read_picker_key():
|
||||||
ch = sys.stdin.read(1)
|
ch = sys.stdin.read(1)
|
||||||
if ch == "\x03":
|
if ch == "\x03":
|
||||||
@@ -53,6 +64,31 @@ def _read_picker_key():
|
|||||||
return "enter"
|
return "enter"
|
||||||
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()]
|
||||||
@@ -99,7 +135,9 @@ def _draw_font_picker(faces, selected):
|
|||||||
active = pos == selected
|
active = pos == selected
|
||||||
pointer = "▶" if active else " "
|
pointer = "▶" if active else " "
|
||||||
color = G_HI if active else W_DIM
|
color = G_HI if active else W_DIM
|
||||||
print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}")
|
print(
|
||||||
|
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
|
||||||
|
)
|
||||||
|
|
||||||
if top > 0:
|
if top > 0:
|
||||||
print(f" {W_GHOST}… {top} above{RST}")
|
print(f" {W_GHOST}… {top} above{RST}")
|
||||||
@@ -116,6 +154,51 @@ def _draw_font_picker(faces, selected):
|
|||||||
shown = row[:max_preview_w]
|
shown = row[:max_preview_w]
|
||||||
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:
|
||||||
@@ -225,7 +308,9 @@ def pick_font_face():
|
|||||||
font_index=selected_font["font_index"],
|
font_index=selected_font["font_index"],
|
||||||
)
|
)
|
||||||
render.clear_font_cache()
|
render.clear_font_cache()
|
||||||
print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}")
|
print(
|
||||||
|
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||||
|
)
|
||||||
time.sleep(0.8)
|
time.sleep(0.8)
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
@@ -245,6 +330,7 @@ def main():
|
|||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_color_theme()
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
@@ -255,32 +341,48 @@ def main():
|
|||||||
time.sleep(0.07)
|
time.sleep(0.07)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
_subtitle = "literary consciousness stream" if config.MODE == 'poetry' else "digital consciousness stream"
|
_subtitle = {
|
||||||
|
"poetry": "literary consciousness stream",
|
||||||
|
"code": "source consciousness stream",
|
||||||
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
cached = load_cache() if '--refresh' not in sys.argv else None
|
cached = load_cache() if "--refresh" not in sys.argv else None
|
||||||
if cached:
|
if cached:
|
||||||
items = cached
|
items = cached
|
||||||
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||||
elif config.MODE == 'poetry':
|
elif config.MODE == "poetry":
|
||||||
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
print()
|
print()
|
||||||
items, linked, failed = fetch_poetry()
|
items, linked, failed = fetch_poetry()
|
||||||
print()
|
print()
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}")
|
print(
|
||||||
|
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||||
|
)
|
||||||
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)
|
||||||
print()
|
print()
|
||||||
items, linked, failed = fetch_all()
|
items, linked, failed = fetch_all()
|
||||||
print()
|
print()
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}")
|
print(
|
||||||
|
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||||
|
)
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||||
save_cache(items)
|
save_cache(items)
|
||||||
|
|
||||||
@@ -292,11 +394,17 @@ def main():
|
|||||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||||
mic_ok = mic.start()
|
mic_ok = mic.start()
|
||||||
if mic.available:
|
if mic.available:
|
||||||
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", bool(mic_ok))
|
boot_ln(
|
||||||
|
"Microphone",
|
||||||
|
"ACTIVE"
|
||||||
|
if mic_ok
|
||||||
|
else "OFFLINE · check System Settings → Privacy → Microphone",
|
||||||
|
bool(mic_ok),
|
||||||
|
)
|
||||||
|
|
||||||
ntfy = NtfyPoller(
|
ntfy = NtfyPoller(
|
||||||
config.NTFY_TOPIC,
|
config.NTFY_TOPIC,
|
||||||
poll_interval=config.NTFY_POLL_INTERVAL,
|
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||||
)
|
)
|
||||||
ntfy_ok = ntfy.start()
|
ntfy_ok = ntfy.start()
|
||||||
@@ -305,6 +413,14 @@ def main():
|
|||||||
if config.FIREHOSE:
|
if config.FIREHOSE:
|
||||||
boot_ln("Firehose", "ENGAGED", True)
|
boot_ln("Firehose", "ENGAGED", True)
|
||||||
|
|
||||||
|
if config.FIGMENT:
|
||||||
|
try:
|
||||||
|
from effects_plugins.figment import FigmentEffect # noqa: F401
|
||||||
|
|
||||||
|
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
|
||||||
|
except (ImportError, OSError):
|
||||||
|
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
|
||||||
|
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
slow_print(" > STREAMING...\n")
|
slow_print(" > STREAMING...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|||||||
210
engine/config.py
210
engine/config.py
@@ -1,25 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
Configuration constants, CLI flags, and glyph tables.
|
Configuration constants, CLI flags, and glyph tables.
|
||||||
|
Supports both global constants (backward compatible) and injected config for testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||||
|
|
||||||
|
|
||||||
def _arg_value(flag):
|
def _arg_value(flag, argv: list[str] | None = None):
|
||||||
"""Get value following a CLI flag, if present."""
|
"""Get value following a CLI flag, if present."""
|
||||||
if flag not in sys.argv:
|
argv = argv or sys.argv
|
||||||
|
if flag not in argv:
|
||||||
return None
|
return None
|
||||||
i = sys.argv.index(flag)
|
i = argv.index(flag)
|
||||||
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
return argv[i + 1] if i + 1 < len(argv) else None
|
||||||
|
|
||||||
|
|
||||||
def _arg_int(flag, default):
|
def _arg_int(flag, default, argv: list[str] | None = None):
|
||||||
"""Get int CLI argument with safe fallback."""
|
"""Get int CLI argument with safe fallback."""
|
||||||
raw = _arg_value(flag)
|
raw = _arg_value(flag, argv)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
@@ -51,40 +54,178 @@ def _list_font_files(font_dir):
|
|||||||
def list_repo_font_files():
|
def list_repo_font_files():
|
||||||
"""Public helper for discovering repository font files."""
|
"""Public helper for discovering repository font files."""
|
||||||
return _list_font_files(FONT_DIR)
|
return _list_font_files(FONT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_platform_font_paths() -> dict[str, str]:
|
||||||
|
"""Get platform-appropriate font paths for non-Latin scripts."""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Darwin":
|
||||||
|
return {
|
||||||
|
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||||
|
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||||
|
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||||
|
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||||
|
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||||
|
}
|
||||||
|
elif system == "Linux":
|
||||||
|
return {
|
||||||
|
"zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||||
|
"th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Immutable configuration container for injected config."""
|
||||||
|
|
||||||
|
headline_limit: int = 1000
|
||||||
|
feed_timeout: int = 10
|
||||||
|
mic_threshold_db: int = 50
|
||||||
|
mode: str = "news"
|
||||||
|
firehose: bool = False
|
||||||
|
|
||||||
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_reconnect_delay: int = 5
|
||||||
|
message_display_secs: int = 30
|
||||||
|
|
||||||
|
font_dir: str = "fonts"
|
||||||
|
font_path: str = ""
|
||||||
|
font_index: int = 0
|
||||||
|
font_picker: bool = True
|
||||||
|
font_sz: int = 60
|
||||||
|
render_h: int = 8
|
||||||
|
|
||||||
|
ssaa: int = 4
|
||||||
|
|
||||||
|
scroll_dur: float = 5.625
|
||||||
|
frame_dt: float = 0.05
|
||||||
|
firehose_h: int = 12
|
||||||
|
grad_speed: float = 0.08
|
||||||
|
|
||||||
|
glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
|
kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
|
argv = argv or sys.argv
|
||||||
|
|
||||||
|
font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts")
|
||||||
|
font_file_arg = _arg_value("--font-file", argv)
|
||||||
|
font_files = _list_font_files(font_dir)
|
||||||
|
font_path = (
|
||||||
|
_resolve_font_path(font_file_arg)
|
||||||
|
if font_file_arg
|
||||||
|
else (font_files[0] if font_files else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
headline_limit=1000,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
|
firehose="--firehose" in argv,
|
||||||
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir=font_dir,
|
||||||
|
font_path=font_path,
|
||||||
|
font_index=max(0, _arg_int("--font-index", 0, argv)),
|
||||||
|
font_picker="--no-font-picker" not in argv,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_config: Config | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> Config:
|
||||||
|
"""Get the global config instance (lazy-loaded)."""
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = Config.from_args()
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
def set_config(config: Config) -> None:
|
||||||
|
"""Set the global config instance (for testing)."""
|
||||||
|
global _config
|
||||||
|
_config = config
|
||||||
|
|
||||||
|
|
||||||
# ─── RUNTIME ──────────────────────────────────────────────
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
|
MODE = (
|
||||||
FIREHOSE = '--firehose' in sys.argv
|
"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
|
||||||
|
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?since=20s&poll=1"
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
NTFY_POLL_INTERVAL = 15 # seconds between polls
|
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
|
||||||
|
|
||||||
# ─── FONT RENDERING ──────────────────────────────────────
|
# ─── FONT RENDERING ──────────────────────────────────────
|
||||||
FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts")
|
FONT_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts")
|
||||||
_FONT_FILE_ARG = _arg_value('--font-file')
|
_FONT_FILE_ARG = _arg_value("--font-file")
|
||||||
_FONT_FILES = _list_font_files(FONT_DIR)
|
_FONT_FILES = _list_font_files(FONT_DIR)
|
||||||
FONT_PATH = (
|
FONT_PATH = (
|
||||||
_resolve_font_path(_FONT_FILE_ARG)
|
_resolve_font_path(_FONT_FILE_ARG)
|
||||||
if _FONT_FILE_ARG
|
if _FONT_FILE_ARG
|
||||||
else (_FONT_FILES[0] if _FONT_FILES else "")
|
else (_FONT_FILES[0] if _FONT_FILES else "")
|
||||||
)
|
)
|
||||||
FONT_INDEX = max(0, _arg_int('--font-index', 0))
|
FONT_INDEX = max(0, _arg_int("--font-index", 0))
|
||||||
FONT_PICKER = '--no-font-picker' not in sys.argv
|
FONT_PICKER = "--no-font-picker" not in sys.argv
|
||||||
FONT_SZ = 60
|
FONT_SZ = 60
|
||||||
RENDER_H = 8 # terminal rows per rendered text line
|
RENDER_H = 8 # terminal rows per rendered text line
|
||||||
|
|
||||||
# ─── FONT RENDERING (ADVANCED) ────────────────────────────
|
# ─── FONT RENDERING (ADVANCED) ────────────────────────────
|
||||||
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
||||||
|
|
||||||
# ─── SCROLL / FRAME ──────────────────────────────────────
|
# ─── SCROLL / FRAME ──────────────────────────────────────
|
||||||
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||||
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||||
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||||
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||||
|
|
||||||
# ─── GLYPHS ───────────────────────────────────────────────
|
# ─── GLYPHS ───────────────────────────────────────────────
|
||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
@@ -98,3 +239,26 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||||
|
Defaults to "green"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
|||||||
68
engine/controller.py
Normal file
68
engine/controller.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Stream controller - manages input sources and orchestrates the render stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.config import Config, get_config
|
||||||
|
from engine.eventbus import EventBus
|
||||||
|
from engine.events import EventType, StreamEvent
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.scroll import stream
|
||||||
|
|
||||||
|
|
||||||
|
class StreamController:
|
||||||
|
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||||
|
self.config = config or get_config()
|
||||||
|
self.event_bus = event_bus
|
||||||
|
self.mic: MicMonitor | None = None
|
||||||
|
self.ntfy: NtfyPoller | None = None
|
||||||
|
|
||||||
|
def initialize_sources(self) -> tuple[bool, bool]:
|
||||||
|
"""Initialize microphone and ntfy sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(mic_ok, ntfy_ok) - success status for each source
|
||||||
|
"""
|
||||||
|
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
|
||||||
|
mic_ok = self.mic.start() if self.mic.available else False
|
||||||
|
|
||||||
|
self.ntfy = NtfyPoller(
|
||||||
|
self.config.ntfy_topic,
|
||||||
|
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||||
|
display_secs=self.config.message_display_secs,
|
||||||
|
)
|
||||||
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
|
return bool(mic_ok), ntfy_ok
|
||||||
|
|
||||||
|
def run(self, items: list) -> None:
|
||||||
|
"""Run the stream with initialized sources."""
|
||||||
|
if self.mic is None or self.ntfy is None:
|
||||||
|
self.initialize_sources()
|
||||||
|
|
||||||
|
if self.event_bus:
|
||||||
|
self.event_bus.publish(
|
||||||
|
EventType.STREAM_START,
|
||||||
|
StreamEvent(
|
||||||
|
event_type=EventType.STREAM_START,
|
||||||
|
headline_count=len(items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stream(items, self.ntfy, self.mic)
|
||||||
|
|
||||||
|
if self.event_bus:
|
||||||
|
self.event_bus.publish(
|
||||||
|
EventType.STREAM_END,
|
||||||
|
StreamEvent(
|
||||||
|
event_type=EventType.STREAM_END,
|
||||||
|
headline_count=len(items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up resources."""
|
||||||
|
if self.mic:
|
||||||
|
self.mic.stop()
|
||||||
102
engine/display.py
Normal file
102
engine/display.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Display output abstraction - allows swapping output backends.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
- init(width, height): Initialize display with terminal dimensions
|
||||||
|
- show(buffer): Render buffer (list of strings) to display
|
||||||
|
- clear(): Clear the display
|
||||||
|
- cleanup(): Shutdown display
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sys.stdout.buffer.write("".join(buffer).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
42
engine/effects/__init__.py
Normal file
42
engine/effects/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from engine.effects.chain import EffectChain
|
||||||
|
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||||
|
from engine.effects.legacy import (
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain():
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EffectChain",
|
||||||
|
"EffectRegistry",
|
||||||
|
"EffectConfig",
|
||||||
|
"EffectContext",
|
||||||
|
"PipelineConfig",
|
||||||
|
"get_registry",
|
||||||
|
"set_registry",
|
||||||
|
"get_effect_chain",
|
||||||
|
"get_monitor",
|
||||||
|
"set_monitor",
|
||||||
|
"PerformanceMonitor",
|
||||||
|
"handle_effects_command",
|
||||||
|
"show_effects_menu",
|
||||||
|
"fade_line",
|
||||||
|
"firehose_line",
|
||||||
|
"glitch_bar",
|
||||||
|
"noise",
|
||||||
|
"next_headline",
|
||||||
|
"vis_trunc",
|
||||||
|
]
|
||||||
71
engine/effects/chain.py
Normal file
71
engine/effects/chain.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry
|
||||||
|
from engine.effects.types import EffectContext
|
||||||
|
|
||||||
|
|
||||||
|
class EffectChain:
|
||||||
|
def __init__(
|
||||||
|
self, registry: EffectRegistry, monitor: PerformanceMonitor | None = None
|
||||||
|
):
|
||||||
|
self._registry = registry
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._monitor = monitor
|
||||||
|
|
||||||
|
def _get_monitor(self) -> PerformanceMonitor:
|
||||||
|
if self._monitor is not None:
|
||||||
|
return self._monitor
|
||||||
|
return get_monitor()
|
||||||
|
|
||||||
|
def set_order(self, names: list[str]) -> None:
|
||||||
|
self._order = list(names)
|
||||||
|
|
||||||
|
def get_order(self) -> list[str]:
|
||||||
|
return self._order.copy()
|
||||||
|
|
||||||
|
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||||
|
if name not in self._registry.list_all():
|
||||||
|
return False
|
||||||
|
if position is None:
|
||||||
|
self._order.append(name)
|
||||||
|
else:
|
||||||
|
self._order.insert(position, name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_effect(self, name: str) -> bool:
|
||||||
|
if name in self._order:
|
||||||
|
self._order.remove(name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reorder(self, new_order: list[str]) -> bool:
|
||||||
|
all_plugins = set(self._registry.list_all().keys())
|
||||||
|
if not all(name in all_plugins for name in new_order):
|
||||||
|
return False
|
||||||
|
self._order = list(new_order)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
monitor = self._get_monitor()
|
||||||
|
frame_number = ctx.frame_number
|
||||||
|
monitor.start_frame(frame_number)
|
||||||
|
|
||||||
|
frame_start = time.perf_counter()
|
||||||
|
result = list(buf)
|
||||||
|
for name in self._order:
|
||||||
|
plugin = self._registry.get(name)
|
||||||
|
if plugin and plugin.config.enabled:
|
||||||
|
chars_in = sum(len(line) for line in result)
|
||||||
|
effect_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = plugin.process(result, ctx)
|
||||||
|
except Exception:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
elapsed = time.perf_counter() - effect_start
|
||||||
|
chars_out = sum(len(line) for line in result)
|
||||||
|
monitor.record_effect(name, elapsed * 1000, chars_in, chars_out)
|
||||||
|
|
||||||
|
total_elapsed = time.perf_counter() - frame_start
|
||||||
|
monitor.end_frame(frame_number, total_elapsed * 1000)
|
||||||
|
return result
|
||||||
144
engine/effects/controller.py
Normal file
144
engine/effects/controller.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from engine.effects.performance import get_monitor
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
_effect_chain_ref = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effect_chain():
|
||||||
|
global _effect_chain_ref
|
||||||
|
if _effect_chain_ref is not None:
|
||||||
|
return _effect_chain_ref
|
||||||
|
try:
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_effect_chain_ref(chain) -> None:
|
||||||
|
global _effect_chain_ref
|
||||||
|
_effect_chain_ref = chain
|
||||||
|
|
||||||
|
|
||||||
|
def handle_effects_command(cmd: str) -> str:
|
||||||
|
"""Handle /effects command from NTFY message.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/effects list - list all effects and their status
|
||||||
|
/effects <name> on - enable an effect
|
||||||
|
/effects <name> off - disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - set intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||||
|
/effects stats - show performance statistics
|
||||||
|
"""
|
||||||
|
parts = cmd.strip().split()
|
||||||
|
if not parts or parts[0] != "/effects":
|
||||||
|
return "Unknown command"
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
if len(parts) == 1 or parts[1] == "list":
|
||||||
|
result = ["Effects:"]
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
result.append(f" {name}: {status} (intensity={intensity})")
|
||||||
|
if chain:
|
||||||
|
result.append(f"Order: {chain.get_order()}")
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
if parts[1] == "stats":
|
||||||
|
return _format_stats()
|
||||||
|
|
||||||
|
if parts[1] == "reorder" and len(parts) >= 3:
|
||||||
|
new_order = parts[2].split(",")
|
||||||
|
if chain and chain.reorder(new_order):
|
||||||
|
return f"Reordered pipeline: {new_order}"
|
||||||
|
return "Failed to reorder pipeline"
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
|
return "Usage: /effects <name> on|off|intensity <value>"
|
||||||
|
|
||||||
|
effect_name = parts[1]
|
||||||
|
action = parts[2]
|
||||||
|
|
||||||
|
if effect_name not in registry.list_all():
|
||||||
|
return f"Unknown effect: {effect_name}"
|
||||||
|
|
||||||
|
if action == "on":
|
||||||
|
registry.enable(effect_name)
|
||||||
|
return f"Enabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "off":
|
||||||
|
registry.disable(effect_name)
|
||||||
|
return f"Disabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "intensity" and len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
value = float(parts[3])
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
return "Intensity must be between 0.0 and 1.0"
|
||||||
|
plugin = registry.get(effect_name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.intensity = value
|
||||||
|
return f"Set {effect_name} intensity to {value}"
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid intensity value"
|
||||||
|
|
||||||
|
return f"Unknown action: {action}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stats() -> str:
|
||||||
|
monitor = get_monitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
|
||||||
|
if "error" in stats:
|
||||||
|
return stats["error"]
|
||||||
|
|
||||||
|
lines = ["Performance Stats:"]
|
||||||
|
|
||||||
|
pipeline = stats["pipeline"]
|
||||||
|
lines.append(
|
||||||
|
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats["effects"]:
|
||||||
|
lines.append(" Per-effect (avg ms):")
|
||||||
|
for name, effect_stats in stats["effects"].items():
|
||||||
|
lines.append(
|
||||||
|
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def show_effects_menu() -> str:
|
||||||
|
"""Generate effects menu text for display."""
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||||
|
"",
|
||||||
|
"Effects:",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||||
|
|
||||||
|
if chain:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Controls:")
|
||||||
|
lines.append(" /effects <name> on|off")
|
||||||
|
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||||
|
lines.append(" /effects reorder name1,name2,...")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -7,8 +7,8 @@ import random
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.terminal import RST, DIM, G_LO, G_DIM, W_GHOST, C_DIM
|
|
||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
def noise(w):
|
def noise(w):
|
||||||
@@ -34,23 +34,23 @@ def fade_line(s, fade):
|
|||||||
if fade >= 1.0:
|
if fade >= 1.0:
|
||||||
return s
|
return s
|
||||||
if fade <= 0.0:
|
if fade <= 0.0:
|
||||||
return ''
|
return ""
|
||||||
result = []
|
result = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(s):
|
while i < len(s):
|
||||||
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
j = i + 2
|
j = i + 2
|
||||||
while j < len(s) and not s[j].isalpha():
|
while j < len(s) and not s[j].isalpha():
|
||||||
j += 1
|
j += 1
|
||||||
result.append(s[i:j + 1])
|
result.append(s[i : j + 1])
|
||||||
i = j + 1
|
i = j + 1
|
||||||
elif s[i] == ' ':
|
elif s[i] == " ":
|
||||||
result.append(' ')
|
result.append(" ")
|
||||||
i += 1
|
i += 1
|
||||||
else:
|
else:
|
||||||
result.append(s[i] if random.random() < fade else ' ')
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
i += 1
|
i += 1
|
||||||
return ''.join(result)
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def vis_trunc(s, w):
|
def vis_trunc(s, w):
|
||||||
@@ -61,17 +61,17 @@ def vis_trunc(s, w):
|
|||||||
while i < len(s):
|
while i < len(s):
|
||||||
if vw >= w:
|
if vw >= w:
|
||||||
break
|
break
|
||||||
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
j = i + 2
|
j = i + 2
|
||||||
while j < len(s) and not s[j].isalpha():
|
while j < len(s) and not s[j].isalpha():
|
||||||
j += 1
|
j += 1
|
||||||
result.append(s[i:j + 1])
|
result.append(s[i : j + 1])
|
||||||
i = j + 1
|
i = j + 1
|
||||||
else:
|
else:
|
||||||
result.append(s[i])
|
result.append(s[i])
|
||||||
vw += 1
|
vw += 1
|
||||||
i += 1
|
i += 1
|
||||||
return ''.join(result)
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def next_headline(pool, items, seen):
|
def next_headline(pool, items, seen):
|
||||||
@@ -94,7 +94,7 @@ def firehose_line(items, w):
|
|||||||
if r < 0.35:
|
if r < 0.35:
|
||||||
# Raw headline text
|
# Raw headline text
|
||||||
title, src, ts = random.choice(items)
|
title, src, ts = random.choice(items)
|
||||||
text = title[:w - 1]
|
text = title[: w - 1]
|
||||||
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
return f"{color}{text}{RST}"
|
return f"{color}{text}{RST}"
|
||||||
elif r < 0.55:
|
elif r < 0.55:
|
||||||
@@ -103,12 +103,13 @@ def firehose_line(items, w):
|
|||||||
return "".join(
|
return "".join(
|
||||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
if random.random() < d else " "
|
if random.random() < d
|
||||||
|
else " "
|
||||||
for _ in range(w)
|
for _ in range(w)
|
||||||
)
|
)
|
||||||
elif r < 0.78:
|
elif r < 0.78:
|
||||||
# Status / program output
|
# Status / program output
|
||||||
sources = FEEDS if config.MODE == 'news' else POETRY_SOURCES
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
src = random.choice(list(sources.keys()))
|
src = random.choice(list(sources.keys()))
|
||||||
msgs = [
|
msgs = [
|
||||||
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
@@ -118,16 +119,16 @@ def firehose_line(items, w):
|
|||||||
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
]
|
]
|
||||||
text = random.choice(msgs)[:w - 1]
|
text = random.choice(msgs)[: w - 1]
|
||||||
color = random.choice([G_LO, G_DIM, W_GHOST])
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
return f"{color}{text}{RST}"
|
return f"{color}{text}{RST}"
|
||||||
else:
|
else:
|
||||||
# Headline fragment with glitch prefix
|
# Headline fragment with glitch prefix
|
||||||
title, _, _ = random.choice(items)
|
title, _, _ = random.choice(items)
|
||||||
start = random.randint(0, max(0, len(title) - 20))
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
frag = title[start:start + random.randint(10, 35)]
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
pad = random.randint(0, max(0, w - len(frag) - 8))
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
gp = ''.join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
gp = "".join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
||||||
text = (' ' * pad + gp + ' ' + frag)[:w - 1]
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
return f"{color}{text}{RST}"
|
return f"{color}{text}{RST}"
|
||||||
103
engine/effects/performance.py
Normal file
103
engine/effects/performance.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectTiming:
|
||||||
|
name: str
|
||||||
|
duration_ms: float
|
||||||
|
buffer_chars_in: int
|
||||||
|
buffer_chars_out: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameTiming:
|
||||||
|
frame_number: int
|
||||||
|
total_ms: float
|
||||||
|
effects: list[EffectTiming]
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceMonitor:
|
||||||
|
"""Collects and stores performance metrics for effect pipeline."""
|
||||||
|
|
||||||
|
def __init__(self, max_frames: int = 60):
|
||||||
|
self._max_frames = max_frames
|
||||||
|
self._frames: deque[FrameTiming] = deque(maxlen=max_frames)
|
||||||
|
self._current_frame: list[EffectTiming] = []
|
||||||
|
|
||||||
|
def start_frame(self, frame_number: int) -> None:
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
def record_effect(
|
||||||
|
self, name: str, duration_ms: float, chars_in: int, chars_out: int
|
||||||
|
) -> None:
|
||||||
|
self._current_frame.append(
|
||||||
|
EffectTiming(
|
||||||
|
name=name,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
buffer_chars_in=chars_in,
|
||||||
|
buffer_chars_out=chars_out,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def end_frame(self, frame_number: int, total_ms: float) -> None:
|
||||||
|
self._frames.append(
|
||||||
|
FrameTiming(
|
||||||
|
frame_number=frame_number,
|
||||||
|
total_ms=total_ms,
|
||||||
|
effects=self._current_frame,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
if not self._frames:
|
||||||
|
return {"error": "No timing data available"}
|
||||||
|
|
||||||
|
total_times = [f.total_ms for f in self._frames]
|
||||||
|
avg_total = sum(total_times) / len(total_times)
|
||||||
|
min_total = min(total_times)
|
||||||
|
max_total = max(total_times)
|
||||||
|
|
||||||
|
effect_stats: dict[str, dict] = {}
|
||||||
|
for frame in self._frames:
|
||||||
|
for effect in frame.effects:
|
||||||
|
if effect.name not in effect_stats:
|
||||||
|
effect_stats[effect.name] = {"times": [], "total_chars": 0}
|
||||||
|
effect_stats[effect.name]["times"].append(effect.duration_ms)
|
||||||
|
effect_stats[effect.name]["total_chars"] += effect.buffer_chars_out
|
||||||
|
|
||||||
|
for name, stats in effect_stats.items():
|
||||||
|
times = stats["times"]
|
||||||
|
stats["avg_ms"] = sum(times) / len(times)
|
||||||
|
stats["min_ms"] = min(times)
|
||||||
|
stats["max_ms"] = max(times)
|
||||||
|
del stats["times"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_count": len(self._frames),
|
||||||
|
"pipeline": {
|
||||||
|
"avg_ms": avg_total,
|
||||||
|
"min_ms": min_total,
|
||||||
|
"max_ms": max_total,
|
||||||
|
},
|
||||||
|
"effects": effect_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._frames.clear()
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
|
||||||
|
_monitor: PerformanceMonitor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor() -> PerformanceMonitor:
|
||||||
|
global _monitor
|
||||||
|
if _monitor is None:
|
||||||
|
_monitor = PerformanceMonitor()
|
||||||
|
return _monitor
|
||||||
|
|
||||||
|
|
||||||
|
def set_monitor(monitor: PerformanceMonitor) -> None:
|
||||||
|
global _monitor
|
||||||
|
_monitor = monitor
|
||||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from engine.effects.types import EffectConfig, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class EffectRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._plugins: dict[str, EffectPlugin] = {}
|
||||||
|
self._discovered: bool = False
|
||||||
|
|
||||||
|
def register(self, plugin: EffectPlugin) -> None:
|
||||||
|
self._plugins[plugin.name] = plugin
|
||||||
|
|
||||||
|
def get(self, name: str) -> EffectPlugin | None:
|
||||||
|
return self._plugins.get(name)
|
||||||
|
|
||||||
|
def list_all(self) -> dict[str, EffectPlugin]:
|
||||||
|
return self._plugins.copy()
|
||||||
|
|
||||||
|
def list_enabled(self) -> list[EffectPlugin]:
|
||||||
|
return [p for p in self._plugins.values() if p.config.enabled]
|
||||||
|
|
||||||
|
def enable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.configure(config)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_enabled(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
return plugin.config.enabled if plugin else False
|
||||||
|
|
||||||
|
|
||||||
|
_registry: EffectRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> EffectRegistry:
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = EffectRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def set_registry(registry: EffectRegistry) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
68
engine/effects/types.py
Normal file
68
engine/effects/types.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectContext:
|
||||||
|
terminal_width: int
|
||||||
|
terminal_height: int
|
||||||
|
scroll_cam: int
|
||||||
|
ticker_height: int
|
||||||
|
camera_x: int = 0
|
||||||
|
mic_excess: float = 0.0
|
||||||
|
grad_offset: float = 0.0
|
||||||
|
frame_number: int = 0
|
||||||
|
has_message: bool = False
|
||||||
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
intensity: float = 1.0
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPlugin(ABC):
|
||||||
|
name: str
|
||||||
|
config: EffectConfig
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def configure(self, config: EffectConfig) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def create_effect_context(
|
||||||
|
terminal_width: int = 80,
|
||||||
|
terminal_height: int = 24,
|
||||||
|
scroll_cam: int = 0,
|
||||||
|
ticker_height: int = 0,
|
||||||
|
camera_x: int = 0,
|
||||||
|
mic_excess: float = 0.0,
|
||||||
|
grad_offset: float = 0.0,
|
||||||
|
frame_number: int = 0,
|
||||||
|
has_message: bool = False,
|
||||||
|
items: list | None = None,
|
||||||
|
) -> EffectContext:
|
||||||
|
"""Factory function to create EffectContext with sensible defaults."""
|
||||||
|
return EffectContext(
|
||||||
|
terminal_width=terminal_width,
|
||||||
|
terminal_height=terminal_height,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_height,
|
||||||
|
camera_x=camera_x,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
order: list[str] = field(default_factory=list)
|
||||||
|
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||||
25
engine/emitters.py
Normal file
25
engine/emitters.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Event emitter protocols - abstract interfaces for event-producing components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class EventEmitter(Protocol):
|
||||||
|
"""Protocol for components that emit events."""
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
def unsubscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Startable(Protocol):
|
||||||
|
"""Protocol for components that can be started."""
|
||||||
|
|
||||||
|
def start(self) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Stoppable(Protocol):
|
||||||
|
"""Protocol for components that can be stopped."""
|
||||||
|
|
||||||
|
def stop(self) -> None: ...
|
||||||
72
engine/eventbus.py
Normal file
72
engine/eventbus.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Event bus - pub/sub messaging for decoupled component communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.events import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""Thread-safe event bus for publish-subscribe messaging."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._subscribers: dict[EventType, list[Callable[[Any], None]]] = defaultdict(
|
||||||
|
list
|
||||||
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||||
|
"""Register a callback for a specific event type."""
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers[event_type].append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(
|
||||||
|
self, event_type: EventType, callback: Callable[[Any], None]
|
||||||
|
) -> None:
|
||||||
|
"""Remove a callback for a specific event type."""
|
||||||
|
with self._lock:
|
||||||
|
if callback in self._subscribers[event_type]:
|
||||||
|
self._subscribers[event_type].remove(callback)
|
||||||
|
|
||||||
|
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||||
|
"""Publish an event to all subscribers."""
|
||||||
|
with self._lock:
|
||||||
|
callbacks = list(self._subscribers.get(event_type, []))
|
||||||
|
for callback in callbacks:
|
||||||
|
try:
|
||||||
|
callback(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Remove all subscribers."""
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.clear()
|
||||||
|
|
||||||
|
def subscriber_count(self, event_type: EventType | None = None) -> int:
|
||||||
|
"""Get subscriber count for an event type, or total if None."""
|
||||||
|
with self._lock:
|
||||||
|
if event_type is None:
|
||||||
|
return sum(len(cb) for cb in self._subscribers.values())
|
||||||
|
return len(self._subscribers.get(event_type, []))
|
||||||
|
|
||||||
|
|
||||||
|
_event_bus: EventBus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_bus() -> EventBus:
|
||||||
|
"""Get the global event bus instance."""
|
||||||
|
global _event_bus
|
||||||
|
if _event_bus is None:
|
||||||
|
_event_bus = EventBus()
|
||||||
|
return _event_bus
|
||||||
|
|
||||||
|
|
||||||
|
def set_event_bus(bus: EventBus) -> None:
|
||||||
|
"""Set the global event bus instance (for testing)."""
|
||||||
|
global _event_bus
|
||||||
|
_event_bus = bus
|
||||||
77
engine/events.py
Normal file
77
engine/events.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Event types for the mainline application.
|
||||||
|
Defines the core events that flow through the system.
|
||||||
|
These types support a future migration to an event-driven architecture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
"""Core event types in the mainline application."""
|
||||||
|
|
||||||
|
NEW_HEADLINE = auto()
|
||||||
|
FRAME_TICK = auto()
|
||||||
|
MIC_LEVEL = auto()
|
||||||
|
NTFY_MESSAGE = auto()
|
||||||
|
STREAM_START = auto()
|
||||||
|
STREAM_END = auto()
|
||||||
|
FIGMENT_TRIGGER = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadlineEvent:
|
||||||
|
"""Event emitted when a new headline is ready for display."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameTickEvent:
|
||||||
|
"""Event emitted on each render frame."""
|
||||||
|
|
||||||
|
frame_number: int
|
||||||
|
timestamp: datetime
|
||||||
|
delta_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MicLevelEvent:
|
||||||
|
"""Event emitted when microphone level changes significantly."""
|
||||||
|
|
||||||
|
db_level: float
|
||||||
|
excess_above_threshold: float
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NtfyMessageEvent:
|
||||||
|
"""Event emitted when an ntfy message is received."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
message_id: str | None = None
|
||||||
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamEvent:
|
||||||
|
"""Event emitted when stream starts or ends."""
|
||||||
|
|
||||||
|
event_type: EventType
|
||||||
|
headline_count: int = 0
|
||||||
|
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
|
||||||
@@ -3,21 +3,27 @@ RSS feed fetching, Project Gutenberg parsing, and headline caching.
|
|||||||
Depends on: config, sources, filter, terminal.
|
Depends on: config, sources, filter, terminal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
|
from engine.filter import skip, strip_tags
|
||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
from engine.filter import strip_tags, skip
|
|
||||||
from engine.terminal import boot_ln
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
|
# Type alias for headline items
|
||||||
|
HeadlineTuple = tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||||
def fetch_feed(url):
|
def fetch_feed(url: str) -> Any | None:
|
||||||
|
"""Fetch and parse a single RSS feed URL."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
@@ -27,8 +33,9 @@ def fetch_feed(url):
|
|||||||
|
|
||||||
|
|
||||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||||
def fetch_all():
|
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
items = []
|
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
||||||
|
items: list[HeadlineTuple] = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for src, url in FEEDS.items():
|
for src, url in FEEDS.items():
|
||||||
feed = fetch_feed(url)
|
feed = fetch_feed(url)
|
||||||
@@ -58,31 +65,36 @@ def fetch_all():
|
|||||||
|
|
||||||
|
|
||||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||||
def _fetch_gutenberg(url, label):
|
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=15)
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
|
text = (
|
||||||
|
resp.read()
|
||||||
|
.decode("utf-8", errors="replace")
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.replace("\r", "\n")
|
||||||
|
)
|
||||||
# Strip PG boilerplate
|
# Strip PG boilerplate
|
||||||
m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text)
|
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[m.end():]
|
text = text[m.end() :]
|
||||||
m = re.search(r'\*\*\*\s*END OF', text)
|
m = re.search(r"\*\*\*\s*END OF", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[:m.start()]
|
text = text[: m.start()]
|
||||||
# Split on blank lines into stanzas/passages
|
# Split on blank lines into stanzas/passages
|
||||||
blocks = re.split(r'\n{2,}', text.strip())
|
blocks = re.split(r"\n{2,}", text.strip())
|
||||||
items = []
|
items = []
|
||||||
for blk in blocks:
|
for blk in blocks:
|
||||||
blk = ' '.join(blk.split()) # flatten to one line
|
blk = " ".join(blk.split()) # flatten to one line
|
||||||
if len(blk) < 20 or len(blk) > 280:
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
continue
|
continue
|
||||||
if blk.isupper(): # skip all-caps headers
|
if blk.isupper(): # skip all-caps headers
|
||||||
continue
|
continue
|
||||||
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals
|
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
||||||
continue
|
continue
|
||||||
items.append((blk, label, ''))
|
items.append((blk, label, ""))
|
||||||
return items
|
return items
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|||||||
67
engine/fetch_code.py
Normal file
67
engine/fetch_code.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
||||||
|
as scroll items. Used by --code mode.
|
||||||
|
Depends on: nothing (stdlib only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ENGINE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_map(source: str) -> dict[int, str]:
|
||||||
|
"""Return {line_number: scope_label} for every line in source.
|
||||||
|
|
||||||
|
Nodes are sorted by range size descending so inner scopes overwrite
|
||||||
|
outer ones, guaranteeing the narrowest enclosing scope wins.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
span = end - node.lineno
|
||||||
|
nodes.append((span, node))
|
||||||
|
|
||||||
|
# Largest range first → inner scopes overwrite on second pass
|
||||||
|
nodes.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
scope = {}
|
||||||
|
for _, node in nodes:
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
label = node.name
|
||||||
|
else:
|
||||||
|
label = f"{node.name}()"
|
||||||
|
for ln in range(node.lineno, end + 1):
|
||||||
|
scope[ln] = label
|
||||||
|
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_code():
|
||||||
|
"""Read engine/*.py and return (items, line_count, 0).
|
||||||
|
|
||||||
|
Each item is (text, src, ts) where:
|
||||||
|
text = the code line (rstripped, indentation preserved)
|
||||||
|
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
||||||
|
ts = dotted module path, e.g. 'engine.scroll'
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
||||||
|
module = f"engine.{path.stem}"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
scope = _scope_map(source)
|
||||||
|
for lineno, raw in enumerate(source.splitlines(), start=1):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
label = scope.get(lineno, "<module>")
|
||||||
|
items.append((raw.rstrip(), label, module))
|
||||||
|
|
||||||
|
return items, len(items), 0
|
||||||
90
engine/figment_render.py
Normal file
90
engine/figment_render.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
SVG to half-block terminal art rasterization.
|
||||||
|
|
||||||
|
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
|
||||||
|
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
||||||
|
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
||||||
|
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
||||||
|
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
||||||
|
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
||||||
|
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
||||||
|
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
||||||
|
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
||||||
|
break
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
_cache: dict[tuple[str, int, int], list[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||||
|
"""Convert SVG file to list of half-block terminal rows (uncolored).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
svg_path: Path to SVG file.
|
||||||
|
width: Target terminal width in columns.
|
||||||
|
height: Target terminal height in rows.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings, one per terminal row, containing block characters.
|
||||||
|
"""
|
||||||
|
cache_key = (svg_path, width, height)
|
||||||
|
if cache_key in _cache:
|
||||||
|
return _cache[cache_key]
|
||||||
|
|
||||||
|
# SVG -> PNG in memory
|
||||||
|
png_bytes = cairosvg.svg2png(
|
||||||
|
url=svg_path,
|
||||||
|
output_width=width,
|
||||||
|
output_height=height * 2, # 2 pixel rows per terminal row
|
||||||
|
)
|
||||||
|
|
||||||
|
# PNG -> greyscale PIL image
|
||||||
|
# Composite RGBA onto white background so transparent areas become white (255)
|
||||||
|
# and drawn pixels retain their luminance values.
|
||||||
|
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
||||||
|
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
|
||||||
|
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
|
||||||
|
background.paste(img_rgba, mask=img_rgba.split()[3])
|
||||||
|
img = background.convert("L")
|
||||||
|
|
||||||
|
data = img.tobytes()
|
||||||
|
pix_w = width
|
||||||
|
pix_h = height * 2
|
||||||
|
# White (255) = empty space, dark (< threshold) = filled pixel
|
||||||
|
threshold = 128
|
||||||
|
|
||||||
|
# Half-block encode: walk pixel pairs
|
||||||
|
rows: list[str] = []
|
||||||
|
for y in range(0, pix_h, 2):
|
||||||
|
row: list[str] = []
|
||||||
|
for x in range(pix_w):
|
||||||
|
top = data[y * pix_w + x] < threshold
|
||||||
|
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
|
||||||
|
if top and bot:
|
||||||
|
row.append("█")
|
||||||
|
elif top:
|
||||||
|
row.append("▀")
|
||||||
|
elif bot:
|
||||||
|
row.append("▄")
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
rows.append("".join(row))
|
||||||
|
|
||||||
|
_cache[cache_key] = rows
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""Clear the rasterization cache (e.g., on terminal resize)."""
|
||||||
|
_cache.clear()
|
||||||
36
engine/figment_trigger.py
Normal file
36
engine/figment_trigger.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Figment trigger protocol and command types.
|
||||||
|
|
||||||
|
Defines the extensible input abstraction for triggering figment displays
|
||||||
|
from any control surface (ntfy, MQTT, serial, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentAction(Enum):
|
||||||
|
TRIGGER = "trigger"
|
||||||
|
SET_INTENSITY = "set_intensity"
|
||||||
|
SET_INTERVAL = "set_interval"
|
||||||
|
SET_COLOR = "set_color"
|
||||||
|
STOP = "stop"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentCommand:
|
||||||
|
action: FigmentAction
|
||||||
|
value: float | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentTrigger(Protocol):
|
||||||
|
"""Protocol for figment trigger sources.
|
||||||
|
|
||||||
|
Any input source (ntfy, MQTT, serial) can implement this
|
||||||
|
to trigger and control figment displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def poll(self) -> FigmentCommand | None: ...
|
||||||
@@ -29,29 +29,29 @@ def strip_tags(html):
|
|||||||
|
|
||||||
# ─── CONTENT FILTER ───────────────────────────────────────
|
# ─── CONTENT FILTER ───────────────────────────────────────
|
||||||
_SKIP_RE = re.compile(
|
_SKIP_RE = re.compile(
|
||||||
r'\b(?:'
|
r"\b(?:"
|
||||||
# ── sports ──
|
# ── sports ──
|
||||||
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
|
r"football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|"
|
||||||
r'hockey|lacrosse|volleyball|badminton|'
|
r"hockey|lacrosse|volleyball|badminton|"
|
||||||
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
|
r"nba|nfl|nhl|mlb|mls|fifa|uefa|"
|
||||||
r'premier league|champions league|la liga|serie a|bundesliga|'
|
r"premier league|champions league|la liga|serie a|bundesliga|"
|
||||||
r'world cup|super bowl|world series|stanley cup|'
|
r"world cup|super bowl|world series|stanley cup|"
|
||||||
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
|
r"playoff|playoffs|touchdown|goalkeeper|striker|quarterback|"
|
||||||
r'slam dunk|home run|grand slam|offside|halftime|'
|
r"slam dunk|home run|grand slam|offside|halftime|"
|
||||||
r'batting|wicket|innings|'
|
r"batting|wicket|innings|"
|
||||||
r'formula 1|nascar|motogp|'
|
r"formula 1|nascar|motogp|"
|
||||||
r'boxing|ufc|mma|'
|
r"boxing|ufc|mma|"
|
||||||
r'marathon|tour de france|'
|
r"marathon|tour de france|"
|
||||||
r'transfer window|draft pick|relegation|'
|
r"transfer window|draft pick|relegation|"
|
||||||
# ── vapid / insipid ──
|
# ── vapid / insipid ──
|
||||||
r'kardashian|jenner|reality tv|reality show|'
|
r"kardashian|jenner|reality tv|reality show|"
|
||||||
r'influencer|viral video|tiktok|instagram|'
|
r"influencer|viral video|tiktok|instagram|"
|
||||||
r'best dressed|worst dressed|red carpet|'
|
r"best dressed|worst dressed|red carpet|"
|
||||||
r'horoscope|zodiac|gossip|bikini|selfie|'
|
r"horoscope|zodiac|gossip|bikini|selfie|"
|
||||||
r'you won.t believe|what happened next|'
|
r"you won.t believe|what happened next|"
|
||||||
r'celebrity couple|celebrity feud|baby bump'
|
r"celebrity couple|celebrity feud|baby bump"
|
||||||
r')\b',
|
r")\b",
|
||||||
re.IGNORECASE
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
57
engine/frame.py
Normal file
57
engine/frame.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Frame timing utilities — FPS control and precise timing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class FrameTimer:
|
||||||
|
"""Frame timer for consistent render loop timing."""
|
||||||
|
|
||||||
|
def __init__(self, target_frame_dt: float = 0.05):
|
||||||
|
self.target_frame_dt = target_frame_dt
|
||||||
|
self._frame_count = 0
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
self._last_frame_time = self._start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fps(self) -> float:
|
||||||
|
"""Current FPS based on elapsed frames."""
|
||||||
|
elapsed = time.monotonic() - self._start_time
|
||||||
|
if elapsed > 0:
|
||||||
|
return self._frame_count / elapsed
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def sleep_until_next_frame(self) -> float:
|
||||||
|
"""Sleep to maintain target frame rate. Returns actual elapsed time."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_frame_time
|
||||||
|
self._last_frame_time = now
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
sleep_time = max(0, self.target_frame_dt - elapsed)
|
||||||
|
if sleep_time > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
return elapsed
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset frame counter and start time."""
|
||||||
|
self._frame_count = 0
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
self._last_frame_time = self._start_time
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_scroll_step(
|
||||||
|
scroll_dur: float, view_height: int, padding: int = 15
|
||||||
|
) -> float:
|
||||||
|
"""Calculate scroll step interval for smooth scrolling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scroll_dur: Duration in seconds for one headline to scroll through view
|
||||||
|
view_height: Terminal height in rows
|
||||||
|
padding: Extra rows for off-screen content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time in seconds between scroll steps
|
||||||
|
"""
|
||||||
|
return scroll_dur / (view_height + padding) * 2
|
||||||
356
engine/layers.py
Normal file
356
engine/layers.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
"""
|
||||||
|
Layer compositing — message overlay, ticker zone, firehose, noise.
|
||||||
|
Depends on: config, render, effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectContext,
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.render import big_wrap, lr_gradient, msg_gradient
|
||||||
|
from engine.terminal import RST, W_COOL
|
||||||
|
|
||||||
|
MSG_META = "\033[38;5;245m"
|
||||||
|
MSG_BORDER = "\033[2;38;5;37m"
|
||||||
|
|
||||||
|
|
||||||
|
def render_message_overlay(
|
||||||
|
msg: tuple[str, str, float] | None,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
msg_cache: tuple,
|
||||||
|
) -> tuple[list[str], tuple]:
|
||||||
|
"""Render ntfy message overlay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: (title, body, timestamp) or None
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
msg_cache: (cache_key, rendered_rows) for caching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(list of ANSI strings, updated cache)
|
||||||
|
"""
|
||||||
|
overlay = []
|
||||||
|
if msg is None:
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
m_title, m_body, m_ts = msg
|
||||||
|
display_text = m_body or m_title or "(empty)"
|
||||||
|
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||||
|
|
||||||
|
cache_key = (display_text, w)
|
||||||
|
if msg_cache[0] != cache_key:
|
||||||
|
msg_rows = big_wrap(display_text, w - 4)
|
||||||
|
msg_cache = (cache_key, msg_rows)
|
||||||
|
else:
|
||||||
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
|
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||||
|
|
||||||
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
|
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||||
|
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||||
|
panel_h = len(msg_rows) + 2
|
||||||
|
panel_top = max(0, (h - panel_h) // 2)
|
||||||
|
|
||||||
|
row_idx = 0
|
||||||
|
for mr in msg_rows:
|
||||||
|
ln = vis_trunc(mr, w)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
meta_parts = []
|
||||||
|
if m_title and m_title != m_body:
|
||||||
|
meta_parts.append(m_title)
|
||||||
|
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||||
|
meta = (
|
||||||
|
" " + " \u00b7 ".join(meta_parts)
|
||||||
|
if len(meta_parts) > 1
|
||||||
|
else " " + meta_parts[0]
|
||||||
|
)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}\033[0m\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
bar = "\u2500" * (w - 4)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}\033[0m\033[K")
|
||||||
|
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
|
||||||
|
def render_ticker_zone(
|
||||||
|
active: list,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
w: int,
|
||||||
|
noise_cache: dict,
|
||||||
|
grad_offset: float,
|
||||||
|
) -> tuple[list[str], dict]:
|
||||||
|
"""Render the ticker scroll zone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||||
|
scroll_cam: camera position (viewport top)
|
||||||
|
ticker_h: height of ticker zone
|
||||||
|
w: terminal width
|
||||||
|
noise_cache: dict of cy -> noise string
|
||||||
|
grad_offset: gradient animation offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(list of ANSI strings, updated noise_cache)
|
||||||
|
"""
|
||||||
|
buf = []
|
||||||
|
top_zone = max(1, int(ticker_h * 0.25))
|
||||||
|
bot_zone = max(1, int(ticker_h * 0.10))
|
||||||
|
|
||||||
|
def noise_at(cy):
|
||||||
|
if cy not in noise_cache:
|
||||||
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
for r in range(ticker_h):
|
||||||
|
scr_row = r + 1
|
||||||
|
cy = scroll_cam + r
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||||
|
row_fade = min(top_f, bot_f)
|
||||||
|
drawn = False
|
||||||
|
|
||||||
|
for content, hc, by, midx in active:
|
||||||
|
cr = cy - by
|
||||||
|
if 0 <= cr < len(content):
|
||||||
|
raw = content[cr]
|
||||||
|
if cr != midx:
|
||||||
|
colored = lr_gradient([raw], grad_offset)[0]
|
||||||
|
else:
|
||||||
|
colored = raw
|
||||||
|
ln = vis_trunc(colored, w)
|
||||||
|
if row_fade < 1.0:
|
||||||
|
ln = fade_line(ln, row_fade)
|
||||||
|
|
||||||
|
if cr == midx:
|
||||||
|
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||||
|
elif ln.strip():
|
||||||
|
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
drawn = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not drawn:
|
||||||
|
n = noise_at(cy)
|
||||||
|
if row_fade < 1.0 and n:
|
||||||
|
n = fade_line(n, row_fade)
|
||||||
|
if n:
|
||||||
|
buf.append(f"\033[{scr_row};1H{n}")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
|
||||||
|
return buf, noise_cache
|
||||||
|
|
||||||
|
|
||||||
|
def apply_glitch(
|
||||||
|
buf: list[str],
|
||||||
|
ticker_buf_start: int,
|
||||||
|
mic_excess: float,
|
||||||
|
w: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Apply glitch effect to ticker buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: current buffer
|
||||||
|
ticker_buf_start: index where ticker starts in buffer
|
||||||
|
mic_excess: mic level above threshold
|
||||||
|
w: terminal width
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated buffer with glitches applied
|
||||||
|
"""
|
||||||
|
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||||
|
n_hits = 4 + int(mic_excess / 2)
|
||||||
|
ticker_buf_len = len(buf) - ticker_buf_start
|
||||||
|
|
||||||
|
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||||
|
for _ in range(min(n_hits, ticker_buf_len)):
|
||||||
|
gi = random.randint(0, ticker_buf_len - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||||
|
"""Render firehose strip at bottom of screen."""
|
||||||
|
buf = []
|
||||||
|
if fh > 0:
|
||||||
|
for fr in range(fh):
|
||||||
|
scr_row = h - fh + fr + 1
|
||||||
|
fline = firehose_line(items, w)
|
||||||
|
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
_effect_chain = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_effects() -> None:
|
||||||
|
"""Initialize effect plugins and chain."""
|
||||||
|
global _effect_chain
|
||||||
|
from engine.effects import EffectChain, get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
import effects_plugins
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
_effect_chain = chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_effects(
|
||||||
|
buf: list[str],
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
mic_excess: float,
|
||||||
|
grad_offset: float,
|
||||||
|
frame_number: int,
|
||||||
|
has_message: bool,
|
||||||
|
items: list,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Process buffer through effect chain."""
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_h,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return _effect_chain.process(buf, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain() -> EffectChain | None:
|
||||||
|
"""Get the effect chain instance."""
|
||||||
|
global _effect_chain
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
return _effect_chain
|
||||||
|
|
||||||
|
|
||||||
|
def render_figment_overlay(
|
||||||
|
figment_state,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Render figment overlay as ANSI cursor-positioning commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI strings to append to display buffer.
|
||||||
|
"""
|
||||||
|
from engine.render import _color_codes_to_ansi
|
||||||
|
|
||||||
|
rows = figment_state.rows
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
phase = figment_state.phase
|
||||||
|
progress = figment_state.progress
|
||||||
|
gradient = figment_state.gradient
|
||||||
|
center_row = figment_state.center_row
|
||||||
|
center_col = figment_state.center_col
|
||||||
|
|
||||||
|
cols = _color_codes_to_ansi(gradient)
|
||||||
|
|
||||||
|
# Build a list of non-space cell positions
|
||||||
|
cell_positions = []
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
if ch != " ":
|
||||||
|
cell_positions.append((r_idx, c_idx))
|
||||||
|
|
||||||
|
n_cells = len(cell_positions)
|
||||||
|
if n_cells == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
||||||
|
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
||||||
|
shuffled = list(cell_positions)
|
||||||
|
rng.shuffle(shuffled)
|
||||||
|
|
||||||
|
# Phase-dependent visibility
|
||||||
|
from effects_plugins.figment import FigmentPhase
|
||||||
|
|
||||||
|
if phase == FigmentPhase.REVEAL:
|
||||||
|
visible_count = int(n_cells * progress)
|
||||||
|
visible = set(shuffled[:visible_count])
|
||||||
|
elif phase == FigmentPhase.HOLD:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
# Strobe: dim some cells periodically
|
||||||
|
if int(progress * 20) % 3 == 0:
|
||||||
|
dim_count = int(n_cells * 0.3)
|
||||||
|
visible -= set(shuffled[:dim_count])
|
||||||
|
elif phase == FigmentPhase.DISSOLVE:
|
||||||
|
remaining_count = int(n_cells * (1.0 - progress))
|
||||||
|
visible = set(shuffled[:remaining_count])
|
||||||
|
else:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
|
||||||
|
# Build overlay commands
|
||||||
|
overlay: list[str] = []
|
||||||
|
n_cols = len(cols)
|
||||||
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
scr_row = center_row + r_idx + 1 # 1-indexed
|
||||||
|
if scr_row < 1 or scr_row > h:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_buf: list[str] = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
scr_col = center_col + c_idx + 1
|
||||||
|
if scr_col < 1 or scr_col > w:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch != " " and (r_idx, c_idx) in visible:
|
||||||
|
# Apply gradient color
|
||||||
|
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
||||||
|
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
||||||
|
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
||||||
|
has_content = True
|
||||||
|
else:
|
||||||
|
line_buf.append(" ")
|
||||||
|
|
||||||
|
if has_content:
|
||||||
|
line_str = "".join(line_buf).rstrip()
|
||||||
|
if line_str.strip():
|
||||||
|
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
||||||
|
|
||||||
|
return overlay
|
||||||
@@ -4,15 +4,21 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import sounddevice as _sd
|
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
|
import sounddevice as _sd
|
||||||
|
|
||||||
_HAS_MIC = True
|
_HAS_MIC = True
|
||||||
except Exception:
|
except Exception:
|
||||||
_HAS_MIC = False
|
_HAS_MIC = False
|
||||||
|
|
||||||
|
|
||||||
|
from engine.events import MicLevelEvent
|
||||||
|
|
||||||
|
|
||||||
class MicMonitor:
|
class MicMonitor:
|
||||||
"""Background mic stream that exposes current RMS dB level."""
|
"""Background mic stream that exposes current RMS dB level."""
|
||||||
|
|
||||||
@@ -20,6 +26,7 @@ class MicMonitor:
|
|||||||
self.threshold_db = threshold_db
|
self.threshold_db = threshold_db
|
||||||
self._db = -99.0
|
self._db = -99.0
|
||||||
self._stream = None
|
self._stream = None
|
||||||
|
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
@@ -36,16 +43,43 @@ class MicMonitor:
|
|||||||
"""dB above threshold (clamped to 0)."""
|
"""dB above threshold (clamped to 0)."""
|
||||||
return max(0.0, self._db - self.threshold_db)
|
return max(0.0, self._db - self.threshold_db)
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||||
|
"""Register a callback to be called when mic level changes."""
|
||||||
|
self._subscribers.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||||
|
"""Remove a registered callback."""
|
||||||
|
if callback in self._subscribers:
|
||||||
|
self._subscribers.remove(callback)
|
||||||
|
|
||||||
|
def _emit(self, event: MicLevelEvent) -> None:
|
||||||
|
"""Emit an event to all subscribers."""
|
||||||
|
for cb in self._subscribers:
|
||||||
|
try:
|
||||||
|
cb(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||||
if not _HAS_MIC:
|
if not _HAS_MIC:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _cb(indata, frames, t, status):
|
def _cb(indata, frames, t, status):
|
||||||
rms = float(_np.sqrt(_np.mean(indata ** 2)))
|
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||||
|
if self._subscribers:
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=self._db,
|
||||||
|
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
)
|
||||||
|
self._emit(event)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._stream = _sd.InputStream(
|
self._stream = _sd.InputStream(
|
||||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
|
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
||||||
|
)
|
||||||
self._stream.start()
|
self._stream.start()
|
||||||
atexit.register(self.stop)
|
atexit.register(self.stop)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
ntfy.sh message poller — standalone, zero internal dependencies.
|
ntfy.sh SSE stream listener — standalone, zero internal dependencies.
|
||||||
Reusable by any visualizer:
|
Reusable by any visualizer:
|
||||||
|
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||||
poller.start()
|
poller.start()
|
||||||
# in render loop:
|
# in render loop:
|
||||||
msg = poller.get_active_message()
|
msg = poller.get_active_message()
|
||||||
@@ -13,24 +13,47 @@ Reusable by any visualizer:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
from engine.events import NtfyMessageEvent
|
||||||
|
|
||||||
|
|
||||||
class NtfyPoller:
|
class NtfyPoller:
|
||||||
"""Background poller for ntfy.sh topics."""
|
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||||
|
|
||||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
def __init__(self, topic_url, reconnect_delay=5, display_secs=30):
|
||||||
self.topic_url = topic_url
|
self.topic_url = topic_url
|
||||||
self.poll_interval = poll_interval
|
self.reconnect_delay = reconnect_delay
|
||||||
self.display_secs = display_secs
|
self.display_secs = display_secs
|
||||||
self._message = None # (title, body, monotonic_timestamp) or None
|
self._message = None # (title, body, monotonic_timestamp) or None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._subscribers: list[Callable[[NtfyMessageEvent], None]] = []
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||||
|
"""Register a callback to be called when a message is received."""
|
||||||
|
self._subscribers.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||||
|
"""Remove a registered callback."""
|
||||||
|
if callback in self._subscribers:
|
||||||
|
self._subscribers.remove(callback)
|
||||||
|
|
||||||
|
def _emit(self, event: NtfyMessageEvent) -> None:
|
||||||
|
"""Emit an event to all subscribers."""
|
||||||
|
for cb in self._subscribers:
|
||||||
|
try:
|
||||||
|
cb(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start background polling thread. Returns True."""
|
"""Start background stream thread. Returns True."""
|
||||||
t = threading.Thread(target=self._poll_loop, daemon=True)
|
t = threading.Thread(target=self._stream_loop, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -50,19 +73,36 @@ class NtfyPoller:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._message = None
|
self._message = None
|
||||||
|
|
||||||
def _poll_loop(self):
|
def _build_url(self, last_id=None):
|
||||||
|
"""Build the stream URL, substituting since= to avoid message replays on reconnect."""
|
||||||
|
parsed = urlparse(self.topic_url)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params["since"] = [last_id if last_id else "20s"]
|
||||||
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def _stream_loop(self):
|
||||||
|
last_id = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
url = self._build_url(last_id)
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
self.topic_url, headers={"User-Agent": "mainline/0.1"})
|
url, headers={"User-Agent": "mainline/0.1"}
|
||||||
resp = urllib.request.urlopen(req, timeout=10)
|
)
|
||||||
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'):
|
# timeout=90 keeps the socket alive through ntfy.sh keepalive heartbeats
|
||||||
if not line.strip():
|
resp = urllib.request.urlopen(req, timeout=90)
|
||||||
continue
|
while True:
|
||||||
|
line = resp.readline()
|
||||||
|
if not line:
|
||||||
|
break # server closed connection — reconnect
|
||||||
try:
|
try:
|
||||||
data = json.loads(line)
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
# Advance cursor on every event (message + keepalive) to
|
||||||
|
# avoid replaying already-seen events after a reconnect.
|
||||||
|
if "id" in data:
|
||||||
|
last_id = data["id"]
|
||||||
if data.get("event") == "message":
|
if data.get("event") == "message":
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._message = (
|
self._message = (
|
||||||
@@ -70,6 +110,13 @@ class NtfyPoller:
|
|||||||
data.get("message", ""),
|
data.get("message", ""),
|
||||||
time.monotonic(),
|
time.monotonic(),
|
||||||
)
|
)
|
||||||
|
event = NtfyMessageEvent(
|
||||||
|
title=data.get("title", ""),
|
||||||
|
body=data.get("message", ""),
|
||||||
|
message_id=data.get("id"),
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
)
|
||||||
|
self._emit(event)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(self.poll_interval)
|
time.sleep(self.reconnect_delay)
|
||||||
|
|||||||
198
engine/render.py
198
engine/render.py
@@ -4,49 +4,83 @@ Font loading, text rasterization, word-wrap, gradient coloring, headline block a
|
|||||||
Depends on: config, terminal, sources, translate.
|
Depends on: config, terminal, sources, translate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
|
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||||
from engine.terminal import RST
|
from engine.terminal import RST
|
||||||
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
|
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
|
||||||
# Left → right: white-hot leading edge fades to near-black
|
|
||||||
GRAD_COLS = [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
MSG_GRAD_COLS = [
|
def _color_codes_to_ansi(color_codes):
|
||||||
"\033[1;38;5;231m", # white
|
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
Args:
|
||||||
"\033[38;5;201m", # bright magenta
|
color_codes: List of 12 integers (256-color palette codes)
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
Returns:
|
||||||
"\033[38;5;125m", # dark magenta
|
List of ANSI escape code strings
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
"""
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
if not color_codes or len(color_codes) != 12:
|
||||||
"\033[2;38;5;235m", # near black
|
# Fallback to default green if invalid
|
||||||
]
|
return _default_green_gradient()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, code in enumerate(color_codes):
|
||||||
|
if i < 2:
|
||||||
|
# Bold for first 2 (bright leading edge)
|
||||||
|
result.append(f"\033[1;38;5;{code}m")
|
||||||
|
elif i < 10:
|
||||||
|
# Normal for middle 8
|
||||||
|
result.append(f"\033[38;5;{code}m")
|
||||||
|
else:
|
||||||
|
# Dim for last 2 (dark trailing edge)
|
||||||
|
result.append(f"\033[2;38;5;{code}m")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
@@ -62,13 +96,14 @@ def font():
|
|||||||
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
||||||
)
|
)
|
||||||
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
||||||
if _FONT_OBJ is None or _FONT_OBJ_KEY != key:
|
if _FONT_OBJ is None or key != _FONT_OBJ_KEY:
|
||||||
_FONT_OBJ = ImageFont.truetype(
|
_FONT_OBJ = ImageFont.truetype(
|
||||||
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
||||||
)
|
)
|
||||||
_FONT_OBJ_KEY = key
|
_FONT_OBJ_KEY = key
|
||||||
return _FONT_OBJ
|
return _FONT_OBJ
|
||||||
|
|
||||||
|
|
||||||
def clear_font_cache():
|
def clear_font_cache():
|
||||||
"""Reset cached font objects after changing primary font selection."""
|
"""Reset cached font objects after changing primary font selection."""
|
||||||
global _FONT_OBJ, _FONT_OBJ_KEY
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
@@ -123,7 +158,7 @@ def render_line(text, fnt=None):
|
|||||||
pad = 4
|
pad = 4
|
||||||
img_w = bbox[2] - bbox[0] + pad * 2
|
img_w = bbox[2] - bbox[0] + pad * 2
|
||||||
img_h = bbox[3] - bbox[1] + pad * 2
|
img_h = bbox[3] - bbox[1] + pad * 2
|
||||||
img = Image.new('L', (img_w, img_h), 0)
|
img = Image.new("L", (img_w, img_h), 0)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
||||||
pix_h = config.RENDER_H * 2
|
pix_h = config.RENDER_H * 2
|
||||||
@@ -188,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
def lr_gradient(rows, offset=0.0, cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
cols = grad_cols or GRAD_COLS
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
if config.ACTIVE_THEME:
|
||||||
|
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
||||||
|
else:
|
||||||
|
cols = _default_green_gradient()
|
||||||
n = len(cols)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -200,8 +241,8 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|||||||
continue
|
continue
|
||||||
buf = []
|
buf = []
|
||||||
for x, ch in enumerate(row):
|
for x, ch in enumerate(row):
|
||||||
if ch == ' ':
|
if ch == " ":
|
||||||
buf.append(' ')
|
buf.append(" ")
|
||||||
else:
|
else:
|
||||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||||
idx = min(round(shifted * (n - 1)), n - 1)
|
idx = min(round(shifted * (n - 1)), n - 1)
|
||||||
@@ -212,13 +253,40 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
return lr_gradient(rows, offset, _default_magenta_gradient())
|
||||||
|
|
||||||
|
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||||
|
|
||||||
|
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||||
|
falling back to default magenta if no theme is set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text strings to colorize
|
||||||
|
offset: Gradient offset (0.0-1.0) for animation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows with ANSI color codes applied
|
||||||
|
"""
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
cols = (
|
||||||
|
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
def make_block(title, src, ts, w):
|
def make_block(title, src, ts, w):
|
||||||
"""Render a headline into a content block with color."""
|
"""Render a headline into a content block with color."""
|
||||||
target_lang = (SOURCE_LANGS.get(src) or detect_location_language(title)) if config.MODE == 'news' else None
|
target_lang = (
|
||||||
|
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
||||||
|
if config.MODE == "news"
|
||||||
|
else None
|
||||||
|
)
|
||||||
lang_font = font_for_lang(target_lang)
|
lang_font = font_for_lang(target_lang)
|
||||||
if target_lang:
|
if target_lang:
|
||||||
title = translate_headline(title, target_lang)
|
title = translate_headline(title, target_lang)
|
||||||
@@ -227,28 +295,36 @@ def make_block(title, src, ts, w):
|
|||||||
title_up = re.sub(r"\s+", " ", title)
|
title_up = re.sub(r"\s+", " ", title)
|
||||||
else:
|
else:
|
||||||
title_up = re.sub(r"\s+", " ", title.upper())
|
title_up = re.sub(r"\s+", " ", title.upper())
|
||||||
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
|
for old, new in [
|
||||||
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
|
("\u2019", "'"),
|
||||||
|
("\u2018", "'"),
|
||||||
|
("\u201c", '"'),
|
||||||
|
("\u201d", '"'),
|
||||||
|
("\u2013", "-"),
|
||||||
|
("\u2014", "-"),
|
||||||
|
]:
|
||||||
title_up = title_up.replace(old, new)
|
title_up = title_up.replace(old, new)
|
||||||
big_rows = big_wrap(title_up, w - 4, lang_font)
|
big_rows = big_wrap(title_up, w - 4, lang_font)
|
||||||
hc = random.choice([
|
hc = random.choice(
|
||||||
"\033[38;5;46m", # matrix green
|
[
|
||||||
"\033[38;5;34m", # dark green
|
"\033[38;5;46m", # matrix green
|
||||||
"\033[38;5;82m", # lime
|
"\033[38;5;34m", # dark green
|
||||||
"\033[38;5;48m", # sea green
|
"\033[38;5;82m", # lime
|
||||||
"\033[38;5;37m", # teal
|
"\033[38;5;48m", # sea green
|
||||||
"\033[38;5;44m", # cyan
|
"\033[38;5;37m", # teal
|
||||||
"\033[38;5;87m", # sky
|
"\033[38;5;44m", # cyan
|
||||||
"\033[38;5;117m", # ice blue
|
"\033[38;5;87m", # sky
|
||||||
"\033[38;5;250m", # cool white
|
"\033[38;5;117m", # ice blue
|
||||||
"\033[38;5;156m", # pale green
|
"\033[38;5;250m", # cool white
|
||||||
"\033[38;5;120m", # mint
|
"\033[38;5;156m", # pale green
|
||||||
"\033[38;5;80m", # dark cyan
|
"\033[38;5;120m", # mint
|
||||||
"\033[38;5;108m", # grey-green
|
"\033[38;5;80m", # dark cyan
|
||||||
"\033[38;5;115m", # sage
|
"\033[38;5;108m", # grey-green
|
||||||
"\033[1;38;5;46m", # bold green
|
"\033[38;5;115m", # sage
|
||||||
"\033[1;38;5;250m", # bold white
|
"\033[1;38;5;46m", # bold green
|
||||||
])
|
"\033[1;38;5;250m", # bold white
|
||||||
|
]
|
||||||
|
)
|
||||||
content = [" " + r for r in big_rows]
|
content = [" " + r for r in big_rows]
|
||||||
content.append("")
|
content.append("")
|
||||||
meta = f"\u2591 {src} \u00b7 {ts}"
|
meta = f"\u2591 {src} \u00b7 {ts}"
|
||||||
|
|||||||
236
engine/scroll.py
236
engine/scroll.py
@@ -1,195 +1,161 @@
|
|||||||
"""
|
"""
|
||||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
Orchestrates viewport, frame timing, and layers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import random
|
import random
|
||||||
from datetime import datetime
|
import time
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.terminal import RST, W_COOL, CLR, tw, th
|
from engine.display import (
|
||||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
Display,
|
||||||
from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line
|
TerminalDisplay,
|
||||||
|
)
|
||||||
|
from engine.display import (
|
||||||
|
get_monitor as _get_display_monitor,
|
||||||
|
)
|
||||||
|
from engine.frame import calculate_scroll_step
|
||||||
|
from engine.layers import (
|
||||||
|
apply_glitch,
|
||||||
|
process_effects,
|
||||||
|
render_figment_overlay,
|
||||||
|
render_firehose,
|
||||||
|
render_message_overlay,
|
||||||
|
render_ticker_zone,
|
||||||
|
)
|
||||||
|
from engine.viewport import th, tw
|
||||||
|
|
||||||
|
USE_EFFECT_CHAIN = True
|
||||||
|
|
||||||
|
|
||||||
def stream(items, ntfy_poller, mic_monitor):
|
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
|
if display is None:
|
||||||
|
display = TerminalDisplay()
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
sys.stdout.write(CLR)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
|
display.init(w, h)
|
||||||
|
display.clear()
|
||||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
ticker_view_h = h - fh
|
||||||
GAP = 3 # blank rows between headlines
|
GAP = 3
|
||||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||||
|
|
||||||
# Taxonomy:
|
|
||||||
# - message: centered ntfy overlay panel
|
|
||||||
# - ticker: large headline text content
|
|
||||||
# - scroll: upward camera motion applied to ticker content
|
|
||||||
# - firehose: fixed carriage-return style strip pinned at bottom
|
|
||||||
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
|
|
||||||
active = []
|
active = []
|
||||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
scroll_cam = 0
|
||||||
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
ticker_next_y = ticker_view_h
|
||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
|
msg_cache = (None, None)
|
||||||
|
frame_number = 0
|
||||||
|
|
||||||
def _noise_at(cy):
|
# Figment overlay (optional — requires cairosvg)
|
||||||
if cy not in noise_cache:
|
figment = None
|
||||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
if config.FIGMENT:
|
||||||
return noise_cache[cy]
|
try:
|
||||||
|
from effects_plugins.figment import FigmentEffect
|
||||||
|
|
||||||
# Message color: bright cyan/white — distinct from headline greens
|
figment = FigmentEffect()
|
||||||
MSG_META = "\033[38;5;245m" # cool grey
|
figment.config.enabled = True
|
||||||
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
|
||||||
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
except (ImportError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if queued >= config.HEADLINE_LIMIT and not active:
|
||||||
|
break
|
||||||
|
|
||||||
while queued < config.HEADLINE_LIMIT or active:
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
ticker_view_h = h - fh
|
ticker_view_h = h - fh
|
||||||
|
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||||
|
|
||||||
# ── Check for ntfy message ────────────────────────
|
|
||||||
msg_h = 0
|
|
||||||
msg_overlay = []
|
|
||||||
msg = ntfy_poller.get_active_message()
|
msg = ntfy_poller.get_active_message()
|
||||||
|
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||||
|
|
||||||
buf = []
|
buf = []
|
||||||
if msg is not None:
|
ticker_h = ticker_view_h
|
||||||
m_title, m_body, m_ts = msg
|
|
||||||
# ── Message overlay: centered in the viewport ──
|
|
||||||
display_text = m_body or m_title or "(empty)"
|
|
||||||
display_text = re.sub(r"\s+", " ", display_text.upper())
|
|
||||||
cache_key = (display_text, w)
|
|
||||||
if _msg_cache[0] != cache_key:
|
|
||||||
msg_rows = big_wrap(display_text, w - 4)
|
|
||||||
_msg_cache = (cache_key, msg_rows)
|
|
||||||
else:
|
|
||||||
msg_rows = _msg_cache[1]
|
|
||||||
msg_rows = lr_gradient_opposite(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
|
||||||
# Layout: rendered text + meta + border
|
|
||||||
elapsed_s = int(time.monotonic() - m_ts)
|
|
||||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
|
||||||
ts_str = datetime.now().strftime("%H:%M:%S")
|
|
||||||
panel_h = len(msg_rows) + 2 # meta + border
|
|
||||||
panel_top = max(0, (h - panel_h) // 2)
|
|
||||||
row_idx = 0
|
|
||||||
for mr in msg_rows:
|
|
||||||
ln = vis_trunc(mr, w)
|
|
||||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
|
|
||||||
row_idx += 1
|
|
||||||
# Meta line: title (if distinct) + source + countdown
|
|
||||||
meta_parts = []
|
|
||||||
if m_title and m_title != m_body:
|
|
||||||
meta_parts.append(m_title)
|
|
||||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
|
||||||
meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
|
|
||||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
|
|
||||||
row_idx += 1
|
|
||||||
# Border — constant boundary under message panel
|
|
||||||
bar = "\u2500" * (w - 4)
|
|
||||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
|
||||||
|
|
||||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
|
||||||
ticker_h = ticker_view_h - msg_h
|
|
||||||
|
|
||||||
# ── Ticker content + scroll motion (always runs) ──
|
|
||||||
scroll_motion_accum += config.FRAME_DT
|
scroll_motion_accum += config.FRAME_DT
|
||||||
while scroll_motion_accum >= scroll_step_interval:
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
scroll_motion_accum -= scroll_step_interval
|
scroll_motion_accum -= scroll_step_interval
|
||||||
scroll_cam += 1
|
scroll_cam += 1
|
||||||
|
|
||||||
# Enqueue new headlines when room at the bottom
|
while (
|
||||||
while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
|
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||||
|
and queued < config.HEADLINE_LIMIT
|
||||||
|
):
|
||||||
|
from engine.effects import next_headline
|
||||||
|
from engine.render import make_block
|
||||||
|
|
||||||
t, src, ts = next_headline(pool, items, seen)
|
t, src, ts = next_headline(pool, items, seen)
|
||||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||||
ticker_next_y += len(ticker_content) + GAP
|
ticker_next_y += len(ticker_content) + GAP
|
||||||
queued += 1
|
queued += 1
|
||||||
|
|
||||||
# Prune off-screen blocks and stale noise
|
active = [
|
||||||
active = [(c, hc, by, mi) for c, hc, by, mi in active
|
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||||
if by + len(c) > scroll_cam]
|
]
|
||||||
for k in list(noise_cache):
|
for k in list(noise_cache):
|
||||||
if k < scroll_cam:
|
if k < scroll_cam:
|
||||||
del noise_cache[k]
|
del noise_cache[k]
|
||||||
|
|
||||||
# Draw ticker zone (above fixed firehose strip)
|
|
||||||
top_zone = max(1, int(ticker_h * 0.25))
|
|
||||||
bot_zone = max(1, int(ticker_h * 0.10))
|
|
||||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
ticker_buf_start = len(buf)
|
||||||
for r in range(ticker_h):
|
|
||||||
scr_row = r + 1 # 1-indexed ANSI screen row
|
ticker_buf, noise_cache = render_ticker_zone(
|
||||||
cy = scroll_cam + r
|
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
)
|
||||||
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
buf.extend(ticker_buf)
|
||||||
row_fade = min(top_f, bot_f)
|
|
||||||
drawn = False
|
|
||||||
for content, hc, by, midx in active:
|
|
||||||
cr = cy - by
|
|
||||||
if 0 <= cr < len(content):
|
|
||||||
raw = content[cr]
|
|
||||||
if cr != midx:
|
|
||||||
colored = lr_gradient([raw], grad_offset)[0]
|
|
||||||
else:
|
|
||||||
colored = raw
|
|
||||||
ln = vis_trunc(colored, w)
|
|
||||||
if row_fade < 1.0:
|
|
||||||
ln = fade_line(ln, row_fade)
|
|
||||||
if cr == midx:
|
|
||||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
|
||||||
elif ln.strip():
|
|
||||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{scr_row};1H\033[K")
|
|
||||||
drawn = True
|
|
||||||
break
|
|
||||||
if not drawn:
|
|
||||||
n = _noise_at(cy)
|
|
||||||
if row_fade < 1.0 and n:
|
|
||||||
n = fade_line(n, row_fade)
|
|
||||||
if n:
|
|
||||||
buf.append(f"\033[{scr_row};1H{n}")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{scr_row};1H\033[K")
|
|
||||||
|
|
||||||
# Glitch — base rate + mic-reactive spikes (ticker zone only)
|
|
||||||
mic_excess = mic_monitor.excess
|
mic_excess = mic_monitor.excess
|
||||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
render_start = time.perf_counter()
|
||||||
n_hits = 4 + int(mic_excess / 2)
|
|
||||||
ticker_buf_len = len(buf) - ticker_buf_start
|
if USE_EFFECT_CHAIN:
|
||||||
if random.random() < glitch_prob and ticker_buf_len > 0:
|
buf = process_effects(
|
||||||
for _ in range(min(n_hits, ticker_buf_len)):
|
buf,
|
||||||
gi = random.randint(0, ticker_buf_len - 1)
|
w,
|
||||||
scr_row = gi + 1
|
h,
|
||||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
scroll_cam,
|
||||||
|
ticker_h,
|
||||||
|
mic_excess,
|
||||||
|
grad_offset,
|
||||||
|
frame_number,
|
||||||
|
msg is not None,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||||
|
firehose_buf = render_firehose(items, w, fh, h)
|
||||||
|
buf.extend(firehose_buf)
|
||||||
|
|
||||||
|
# Figment overlay (between effects and ntfy message)
|
||||||
|
if figment and figment.config.enabled:
|
||||||
|
figment_state = figment.get_figment_state(frame_number, w, h)
|
||||||
|
if figment_state is not None:
|
||||||
|
figment_buf = render_figment_overlay(figment_state, w, h)
|
||||||
|
buf.extend(figment_buf)
|
||||||
|
|
||||||
if config.FIREHOSE and fh > 0:
|
|
||||||
for fr in range(fh):
|
|
||||||
scr_row = h - fh + fr + 1
|
|
||||||
fline = firehose_line(items, w)
|
|
||||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
|
||||||
if msg_overlay:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
sys.stdout.buffer.write("".join(buf).encode())
|
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||||
sys.stdout.flush()
|
monitor = _get_display_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars = sum(len(line) for line in buf)
|
||||||
|
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||||
|
|
||||||
|
display.show(buf)
|
||||||
|
|
||||||
# Precise frame timing
|
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
|
frame_number += 1
|
||||||
|
|
||||||
sys.stdout.write(CLR)
|
display.cleanup()
|
||||||
sys.stdout.flush()
|
|
||||||
|
|||||||
@@ -47,69 +47,69 @@ FEEDS = {
|
|||||||
# ─── POETRY / LITERATURE ─────────────────────────────────
|
# ─── POETRY / LITERATURE ─────────────────────────────────
|
||||||
# Public domain via Project Gutenberg
|
# Public domain via Project Gutenberg
|
||||||
POETRY_SOURCES = {
|
POETRY_SOURCES = {
|
||||||
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
||||||
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
|
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
|
||||||
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
|
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
|
||||||
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
|
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
|
||||||
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
|
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
|
||||||
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
|
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
|
||||||
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
|
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
|
||||||
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
|
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
|
||||||
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
|
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
|
||||||
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
|
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
|
||||||
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
|
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
|
||||||
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
|
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
|
# ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
|
||||||
# Headlines from these outlets render in their cultural home language
|
# Headlines from these outlets render in their cultural home language
|
||||||
SOURCE_LANGS = {
|
SOURCE_LANGS = {
|
||||||
"Der Spiegel": "de",
|
"Der Spiegel": "de",
|
||||||
"DW": "de",
|
"DW": "de",
|
||||||
"France24": "fr",
|
"France24": "fr",
|
||||||
"Japan Times": "ja",
|
"Japan Times": "ja",
|
||||||
"The Hindu": "hi",
|
"The Hindu": "hi",
|
||||||
"SCMP": "zh-cn",
|
"SCMP": "zh-cn",
|
||||||
"Al Jazeera": "ar",
|
"Al Jazeera": "ar",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── LOCATION → LANGUAGE ─────────────────────────────────
|
# ─── LOCATION → LANGUAGE ─────────────────────────────────
|
||||||
LOCATION_LANGS = {
|
LOCATION_LANGS = {
|
||||||
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
|
r"\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b": "zh-cn",
|
||||||
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
|
r"\b(?:japan|japanese|tokyo|osaka|kishida)\b": "ja",
|
||||||
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
|
r"\b(?:korea|korean|seoul|pyongyang)\b": "ko",
|
||||||
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
|
r"\b(?:russia|russian|moscow|kremlin|putin)\b": "ru",
|
||||||
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
|
r"\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b": "ar",
|
||||||
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
|
r"\b(?:india|indian|delhi|mumbai|modi)\b": "hi",
|
||||||
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
|
r"\b(?:germany|german|berlin|munich|scholz)\b": "de",
|
||||||
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
|
r"\b(?:france|french|paris|lyon|macron)\b": "fr",
|
||||||
r'\b(?:spain|spanish|madrid)\b': 'es',
|
r"\b(?:spain|spanish|madrid)\b": "es",
|
||||||
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
|
r"\b(?:italy|italian|rome|milan|meloni)\b": "it",
|
||||||
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
|
r"\b(?:portugal|portuguese|lisbon)\b": "pt",
|
||||||
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
|
r"\b(?:brazil|brazilian|são paulo|lula)\b": "pt",
|
||||||
r'\b(?:greece|greek|athens)\b': 'el',
|
r"\b(?:greece|greek|athens)\b": "el",
|
||||||
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
|
r"\b(?:turkey|turkish|istanbul|ankara|erdogan)\b": "tr",
|
||||||
r'\b(?:iran|iranian|tehran)\b': 'fa',
|
r"\b(?:iran|iranian|tehran)\b": "fa",
|
||||||
r'\b(?:thailand|thai|bangkok)\b': 'th',
|
r"\b(?:thailand|thai|bangkok)\b": "th",
|
||||||
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
|
r"\b(?:vietnam|vietnamese|hanoi)\b": "vi",
|
||||||
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
|
r"\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b": "uk",
|
||||||
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
|
r"\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b": "he",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
|
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
|
||||||
SCRIPT_FONTS = {
|
SCRIPT_FONTS = {
|
||||||
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
|
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||||
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
|
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||||
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
|
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||||
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
'ar': '/System/Library/Fonts/GeezaPro.ttc',
|
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
'fa': '/System/Library/Fonts/GeezaPro.ttc',
|
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
'hi': '/System/Library/Fonts/Kohinoor.ttc',
|
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||||
'th': '/System/Library/Fonts/ThonburiUI.ttc',
|
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Scripts that have no uppercase
|
# Scripts that have no uppercase
|
||||||
NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}
|
NO_UPPER = {"zh-cn", "ja", "ko", "ar", "fa", "hi", "th", "he"}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ No internal dependencies.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import random
|
import random
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# ─── ANSI ─────────────────────────────────────────────────
|
# ─── ANSI ─────────────────────────────────────────────────
|
||||||
@@ -49,7 +49,7 @@ def type_out(text, color=G_HI):
|
|||||||
while i < len(text):
|
while i < len(text):
|
||||||
if random.random() < 0.3:
|
if random.random() < 0.3:
|
||||||
b = random.randint(2, 5)
|
b = random.randint(2, 5)
|
||||||
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
|
sys.stdout.write(f"{color}{text[i : i + b]}{RST}")
|
||||||
i += b
|
i += b
|
||||||
else:
|
else:
|
||||||
sys.stdout.write(f"{color}{text[i]}{RST}")
|
sys.stdout.write(f"{color}{text[i]}{RST}")
|
||||||
|
|||||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Theme definitions with color gradients for terminal rendering.
|
||||||
|
|
||||||
|
This module is data-only and does not import config or render
|
||||||
|
to prevent circular dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Represents a color theme with two gradients."""
|
||||||
|
|
||||||
|
def __init__(self, name, main_gradient, message_gradient):
|
||||||
|
"""Initialize a theme with name and color gradients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Theme identifier string
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||||
|
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||||
|
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||||
|
|
||||||
|
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||||
|
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||||
|
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id):
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object matching the ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in registry
|
||||||
|
"""
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
@@ -3,14 +3,33 @@ Google Translate wrapper and location→language detection.
|
|||||||
Depends on: sources (for LOCATION_LANGS).
|
Depends on: sources (for LOCATION_LANGS).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
import urllib.request
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from engine.sources import LOCATION_LANGS
|
from engine.sources import LOCATION_LANGS
|
||||||
|
|
||||||
_TRANSLATE_CACHE = {}
|
TRANSLATE_CACHE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
||||||
|
def _translate_cached(title: str, target_lang: str) -> str:
|
||||||
|
"""Cached translation implementation."""
|
||||||
|
try:
|
||||||
|
q = urllib.parse.quote(title)
|
||||||
|
url = (
|
||||||
|
"https://translate.googleapis.com/translate_a/single"
|
||||||
|
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||||
|
except Exception:
|
||||||
|
result = title
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def detect_location_language(title):
|
def detect_location_language(title):
|
||||||
@@ -22,20 +41,6 @@ def detect_location_language(title):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def translate_headline(title, target_lang):
|
def translate_headline(title: str, target_lang: str) -> str:
|
||||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||||
key = (title, target_lang)
|
return _translate_cached(title, target_lang)
|
||||||
if key in _TRANSLATE_CACHE:
|
|
||||||
return _TRANSLATE_CACHE[key]
|
|
||||||
try:
|
|
||||||
q = urllib.parse.quote(title)
|
|
||||||
url = ("https://translate.googleapis.com/translate_a/single"
|
|
||||||
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
|
||||||
resp = urllib.request.urlopen(req, timeout=5)
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
result = "".join(p[0] for p in data[0] if p[0]) or title
|
|
||||||
except Exception:
|
|
||||||
result = title
|
|
||||||
_TRANSLATE_CACHE[key] = result
|
|
||||||
return result
|
|
||||||
|
|||||||
60
engine/types.py
Normal file
60
engine/types.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Shared dataclasses for the mainline application.
|
||||||
|
Provides named types for tuple returns across modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadlineItem:
|
||||||
|
"""A single headline item: title, source, and timestamp."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
def to_tuple(self) -> tuple[str, str, str]:
|
||||||
|
"""Convert to tuple for backward compatibility."""
|
||||||
|
return (self.title, self.source, self.timestamp)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tuple(cls, t: tuple[str, str, str]) -> "HeadlineItem":
|
||||||
|
"""Create from tuple for backward compatibility."""
|
||||||
|
return cls(title=t[0], source=t[1], timestamp=t[2])
|
||||||
|
|
||||||
|
|
||||||
|
def items_to_tuples(items: list[HeadlineItem]) -> list[tuple[str, str, str]]:
|
||||||
|
"""Convert list of HeadlineItem to list of tuples."""
|
||||||
|
return [item.to_tuple() for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
def tuples_to_items(tuples: list[tuple[str, str, str]]) -> list[HeadlineItem]:
|
||||||
|
"""Convert list of tuples to list of HeadlineItem."""
|
||||||
|
return [HeadlineItem.from_tuple(t) for t in tuples]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchResult:
|
||||||
|
"""Result from fetch_all() or fetch_poetry()."""
|
||||||
|
|
||||||
|
items: list[HeadlineItem]
|
||||||
|
linked: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
def to_legacy_tuple(self) -> tuple[list[tuple], int, int]:
|
||||||
|
"""Convert to legacy tuple format for backward compatibility."""
|
||||||
|
return ([item.to_tuple() for item in self.items], self.linked, self.failed)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Block:
|
||||||
|
"""Rendered headline block from make_block()."""
|
||||||
|
|
||||||
|
content: list[str]
|
||||||
|
color: str
|
||||||
|
meta_row_index: int
|
||||||
|
|
||||||
|
def to_legacy_tuple(self) -> tuple[list[str], str, int]:
|
||||||
|
"""Convert to legacy tuple format for backward compatibility."""
|
||||||
|
return (self.content, self.color, self.meta_row_index)
|
||||||
37
engine/viewport.py
Normal file
37
engine/viewport.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Viewport utilities — terminal dimensions and ANSI positioning helpers.
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def tw() -> int:
|
||||||
|
"""Get terminal width (columns)."""
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().columns
|
||||||
|
except Exception:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
|
||||||
|
def th() -> int:
|
||||||
|
"""Get terminal height (lines)."""
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
return 24
|
||||||
|
|
||||||
|
|
||||||
|
def move_to(row: int, col: int = 1) -> str:
|
||||||
|
"""Generate ANSI escape to move cursor to row, col (1-indexed)."""
|
||||||
|
return f"\033[{row};{col}H"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_screen() -> str:
|
||||||
|
"""Clear screen and move cursor to home."""
|
||||||
|
return "\033[2J\033[H"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_line() -> str:
|
||||||
|
"""Clear current line."""
|
||||||
|
return "\033[K"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 577.362 577.362"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_21_">
|
||||||
|
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
|
||||||
|
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
|
||||||
|
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
|
||||||
|
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
|
||||||
|
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
|
||||||
|
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
|
||||||
|
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
|
||||||
|
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
|
||||||
|
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
|
||||||
|
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
|
||||||
|
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
|
||||||
|
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
|
||||||
|
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
|
||||||
|
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
|
||||||
|
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
|
||||||
|
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
|
||||||
|
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
|
||||||
|
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
|
||||||
|
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
|
||||||
|
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
|
||||||
|
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 559.731 559.731"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_36_">
|
||||||
|
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
|
||||||
|
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
|
||||||
|
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
|
||||||
|
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
|
||||||
|
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
|
||||||
|
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
|
||||||
|
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
|
||||||
|
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
|
||||||
|
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
|
||||||
|
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
|
||||||
|
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
|
||||||
|
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
|
||||||
|
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
|
||||||
|
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
|
||||||
|
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
|
||||||
|
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
|
||||||
|
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
|
||||||
|
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
|
||||||
|
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
|
||||||
|
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
|
||||||
|
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
|
||||||
|
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
|
||||||
|
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
|
||||||
|
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
|
||||||
|
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
|
||||||
|
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
|
||||||
|
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
|
||||||
|
/>
|
||||||
|
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
|
||||||
|
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
|
||||||
|
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
|
||||||
|
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
|
||||||
|
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
|
||||||
|
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
|
||||||
|
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
|
||||||
|
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
|
||||||
|
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
|
||||||
|
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
|
||||||
|
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
|
||||||
|
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
|
||||||
|
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
|
||||||
|
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
|
||||||
|
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
|
||||||
|
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
|
||||||
|
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
|
||||||
|
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
|
||||||
|
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
|
||||||
|
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
|
||||||
|
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 589.748 589.748"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_2_">
|
||||||
|
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
|
||||||
|
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
|
||||||
|
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
|
||||||
|
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
|
||||||
|
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
|
||||||
|
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
|
||||||
|
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
|
||||||
|
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
|
||||||
|
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
|
||||||
|
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
|
||||||
|
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
|
||||||
|
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
|
||||||
|
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
|
||||||
|
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
|
||||||
|
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
|
||||||
|
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
|
||||||
|
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
|
||||||
|
/>
|
||||||
|
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
|
||||||
|
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
|
||||||
|
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
|
||||||
|
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
|
||||||
|
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
|
||||||
|
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
|
||||||
|
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
|
||||||
|
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
|
||||||
|
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
|
||||||
|
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
|
||||||
|
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
|
||||||
|
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
|
||||||
|
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
|
||||||
|
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
|
||||||
|
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
|
||||||
|
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
|
||||||
|
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
|
||||||
|
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
|
||||||
|
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
|
||||||
|
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
|
||||||
|
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
|
||||||
|
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
|
||||||
|
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
|
||||||
|
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
|
||||||
|
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
|
||||||
|
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
|
||||||
|
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
|
||||||
|
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
|
||||||
|
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
|
||||||
|
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
|
||||||
|
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
|
||||||
|
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
|
||||||
|
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
|
||||||
|
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
|
||||||
|
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
|
||||||
|
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
|
||||||
|
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
|
||||||
|
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
|
||||||
|
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
|
||||||
|
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
|
||||||
|
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
|
||||||
|
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
|
||||||
|
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
|
||||||
|
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
|
||||||
|
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
|
||||||
|
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
|
||||||
|
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
|
||||||
|
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
|
||||||
|
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
|
||||||
|
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
|
||||||
|
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
|
||||||
|
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
|
||||||
|
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
|
||||||
|
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
|
||||||
|
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
|
||||||
|
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
|
||||||
|
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
|
||||||
|
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
|
||||||
|
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
|
||||||
|
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
|
||||||
|
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
|
||||||
|
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
|
||||||
|
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
|
||||||
|
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
|
||||||
|
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
|
||||||
|
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
|
||||||
|
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
|
||||||
|
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
|
||||||
|
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
|
||||||
|
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
|
||||||
|
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
|
||||||
|
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
|
||||||
|
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
|
||||||
|
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
|
||||||
|
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
|
||||||
|
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
|
||||||
|
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
|
||||||
|
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
|
||||||
|
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
|
||||||
|
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
|
||||||
|
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Kapiler.otf
Normal file
BIN
fonts/Kapiler.otf
Normal file
Binary file not shown.
BIN
fonts/Kapiler.ttf
Normal file
BIN
fonts/Kapiler.ttf
Normal file
Binary file not shown.
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Resond-Regular.otf
Normal file
BIN
fonts/Resond-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Synthetix.otf
Normal file
BIN
fonts/Synthetix.otf
Normal file
Binary file not shown.
27
hk.pkl
Normal file
27
hk.pkl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
amends "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Config.pkl"
|
||||||
|
import "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Builtins.pkl"
|
||||||
|
|
||||||
|
hooks {
|
||||||
|
["pre-commit"] {
|
||||||
|
fix = true
|
||||||
|
stash = "git"
|
||||||
|
steps {
|
||||||
|
["ruff-format"] = (Builtins.ruff_format) {
|
||||||
|
prefix = "uv run"
|
||||||
|
}
|
||||||
|
["ruff"] = (Builtins.ruff) {
|
||||||
|
prefix = "uv run"
|
||||||
|
check = "ruff check engine/ tests/"
|
||||||
|
fix = "ruff check --fix --unsafe-fixes engine/ tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["pre-push"] {
|
||||||
|
steps {
|
||||||
|
["ruff"] = (Builtins.ruff) {
|
||||||
|
prefix = "uv run"
|
||||||
|
check = "ruff check engine/ tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
mainline.py
35
mainline.py
@@ -5,40 +5,7 @@ Digital news consciousness stream.
|
|||||||
Matrix aesthetic · THX-1138 hue.
|
Matrix aesthetic · THX-1138 hue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess, sys, pathlib
|
from engine.app import main
|
||||||
|
|
||||||
# ─── BOOTSTRAP VENV ───────────────────────────────────────
|
|
||||||
_VENV = pathlib.Path(__file__).resolve().parent / ".mainline_venv"
|
|
||||||
_MARKER = _VENV / ".installed_v3"
|
|
||||||
|
|
||||||
def _ensure_venv():
|
|
||||||
"""Create a local venv and install deps if needed."""
|
|
||||||
if _MARKER.exists():
|
|
||||||
return
|
|
||||||
import venv
|
|
||||||
print("\033[2;38;5;34m > first run — creating environment...\033[0m")
|
|
||||||
venv.create(str(_VENV), with_pip=True, clear=True)
|
|
||||||
pip = str(_VENV / "bin" / "pip")
|
|
||||||
subprocess.check_call(
|
|
||||||
[pip, "install", "feedparser", "Pillow", "-q"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
_MARKER.touch()
|
|
||||||
|
|
||||||
_ensure_venv()
|
|
||||||
|
|
||||||
# Install sounddevice on first run after v3
|
|
||||||
_MARKER_SD = _VENV / ".installed_sd"
|
|
||||||
if not _MARKER_SD.exists():
|
|
||||||
_pip = str(_VENV / "bin" / "pip")
|
|
||||||
subprocess.check_call([_pip, "install", "sounddevice", "numpy", "-q"],
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
||||||
_MARKER_SD.touch()
|
|
||||||
|
|
||||||
sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
|
|
||||||
|
|
||||||
# ─── DELEGATE TO ENGINE ───────────────────────────────────
|
|
||||||
from engine.app import main # noqa: E402
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
56
mise.toml
Normal file
56
mise.toml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
[env]
|
||||||
|
_.path = ["/opt/homebrew/lib"]
|
||||||
|
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
python = "3.12"
|
||||||
|
hk = "latest"
|
||||||
|
pkl = "latest"
|
||||||
|
|
||||||
|
[tasks]
|
||||||
|
# =====================
|
||||||
|
# Development
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
test = "uv run pytest"
|
||||||
|
test-v = "uv run pytest -v"
|
||||||
|
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||||
|
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||||
|
|
||||||
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Runtime
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
run = "uv run mainline.py"
|
||||||
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Environment
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
sync = "uv sync"
|
||||||
|
sync-all = "uv sync --all-extras"
|
||||||
|
install = "uv sync"
|
||||||
|
install-dev = "uv sync --group dev"
|
||||||
|
|
||||||
|
bootstrap = "uv sync && uv run mainline.py --help"
|
||||||
|
|
||||||
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# CI/CD
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
ci = "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"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Git Hooks (via hk)
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
pre-commit = "hk run pre-commit"
|
||||||
92
pyproject.toml
Normal file
92
pyproject.toml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
[project]
|
||||||
|
name = "mainline"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Terminal news ticker with Matrix aesthetic"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{ name = "Mainline", email = "mainline@example.com" }
|
||||||
|
]
|
||||||
|
license = { text = "MIT" }
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Console",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Terminals",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"feedparser>=6.0.0",
|
||||||
|
"Pillow>=10.0.0",
|
||||||
|
"pyright>=1.1.408",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
mic = [
|
||||||
|
"sounddevice>=0.4.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
]
|
||||||
|
figment = [
|
||||||
|
"cairosvg>=2.7.0",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mainline = "engine.app:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"--strict-markers",
|
||||||
|
"--tb=short",
|
||||||
|
"-v",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["engine"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"@abstractmethod",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
|
||||||
|
ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pytest>=8.0.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
pytest-mock>=3.12.0
|
||||||
|
ruff>=0.1.0
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
feedparser>=6.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
sounddevice>=0.4.0
|
||||||
|
numpy>=1.24.0
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
236
tests/fixtures/__init__.py
vendored
Normal file
236
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
Pytest fixtures for mocking external dependencies (network, filesystem).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feed_response():
|
||||||
|
"""Mock RSS feed response data."""
|
||||||
|
return b"""<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Test Feed</title>
|
||||||
|
<link>https://example.com</link>
|
||||||
|
<item>
|
||||||
|
<title>Test Headline One</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 12:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Test Headline Two</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 11:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Sports: Team Wins Championship</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_gutenberg_response():
|
||||||
|
"""Mock Project Gutenberg text response."""
|
||||||
|
return """Project Gutenberg's Collection, by Various
|
||||||
|
|
||||||
|
*** START OF SOME TEXT ***
|
||||||
|
This is a test poem with multiple lines
|
||||||
|
that should be parsed as stanzas.
|
||||||
|
|
||||||
|
Another stanza here with different content
|
||||||
|
and more lines to test the parsing logic.
|
||||||
|
|
||||||
|
Yet another stanza for variety
|
||||||
|
in the test data.
|
||||||
|
|
||||||
|
*** END OF SOME TEXT ***"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_gutenberg_empty():
|
||||||
|
"""Mock Gutenberg response with no valid stanzas."""
|
||||||
|
return """Project Gutenberg's Collection
|
||||||
|
|
||||||
|
*** START OF TEXT ***
|
||||||
|
THIS IS ALL CAPS AND SHOULD BE SKIPPED
|
||||||
|
|
||||||
|
I.
|
||||||
|
|
||||||
|
*** END OF TEXT ***"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ntfy_message():
|
||||||
|
"""Mock ntfy.sh SSE message."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"id": "test123",
|
||||||
|
"event": "message",
|
||||||
|
"title": "Test Title",
|
||||||
|
"message": "Test message body",
|
||||||
|
"time": 1234567890,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ntfy_keepalive():
|
||||||
|
"""Mock ntfy.sh keepalive message."""
|
||||||
|
return b'data: {"event":"keepalive"}\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_google_translate_response():
|
||||||
|
"""Mock Google Translate API response."""
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
[["Translated text", "Original text", None, 0.8], None, "en"],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feedparser():
|
||||||
|
"""Create a mock feedparser.parse function."""
|
||||||
|
|
||||||
|
def _mock(data):
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.bozo = False
|
||||||
|
mock_result.entries = [
|
||||||
|
{
|
||||||
|
"title": "Test Headline",
|
||||||
|
"published_parsed": (2025, 3, 15, 12, 0, 0, 0, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Another Headline",
|
||||||
|
"updated_parsed": (2025, 3, 15, 11, 0, 0, 0, 0, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return mock_result
|
||||||
|
|
||||||
|
return _mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_urllib_open(mock_feed_response):
|
||||||
|
"""Create a mock urllib.request.urlopen that returns feed data."""
|
||||||
|
|
||||||
|
def _mock(url):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = mock_feed_response
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
return _mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_items():
|
||||||
|
"""Sample items as returned by fetch module (title, source, timestamp)."""
|
||||||
|
return [
|
||||||
|
("Headline One", "Test Source", "12:00"),
|
||||||
|
("Headline Two", "Another Source", "11:30"),
|
||||||
|
("Headline Three", "Third Source", "10:45"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_config():
|
||||||
|
"""Sample config for testing."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="news",
|
||||||
|
firehose=False,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def poetry_config():
|
||||||
|
"""Sample config for poetry mode."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="poetry",
|
||||||
|
firehose=False,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def firehose_config():
|
||||||
|
"""Sample config with firehose enabled."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="news",
|
||||||
|
firehose=True,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
3
tests/fixtures/test.svg
vendored
Normal file
3
tests/fixtures/test.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||||
|
<rect x="10" y="10" width="80" height="80" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 155 B |
301
tests/test_config.py
Normal file
301
tests/test_config.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.config module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
|
||||||
|
class TestArgValue:
|
||||||
|
"""Tests for _arg_value helper."""
|
||||||
|
|
||||||
|
def test_returns_value_when_flag_present(self):
|
||||||
|
"""Returns the value following the flag."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-file", "test.otf"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result == "test.otf"
|
||||||
|
|
||||||
|
def test_returns_none_when_flag_missing(self):
|
||||||
|
"""Returns None when flag is not present."""
|
||||||
|
with patch.object(sys, "argv", ["prog"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_value(self):
|
||||||
|
"""Returns None when flag is last."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-file"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestArgInt:
|
||||||
|
"""Tests for _arg_int helper."""
|
||||||
|
|
||||||
|
def test_parses_valid_int(self):
|
||||||
|
"""Parses valid integer."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-index", "5"]):
|
||||||
|
result = config._arg_int("--font-index", 0)
|
||||||
|
assert result == 5
|
||||||
|
|
||||||
|
def test_returns_default_on_invalid(self):
|
||||||
|
"""Returns default on invalid input."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-index", "abc"]):
|
||||||
|
result = config._arg_int("--font-index", 0)
|
||||||
|
assert result == 0
|
||||||
|
|
||||||
|
def test_returns_default_when_missing(self):
|
||||||
|
"""Returns default when flag missing."""
|
||||||
|
with patch.object(sys, "argv", ["prog"]):
|
||||||
|
result = config._arg_int("--font-index", 10)
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveFontPath:
|
||||||
|
"""Tests for _resolve_font_path helper."""
|
||||||
|
|
||||||
|
def test_returns_absolute_paths(self):
|
||||||
|
"""Absolute paths are returned as-is."""
|
||||||
|
result = config._resolve_font_path("/absolute/path.otf")
|
||||||
|
assert result == "/absolute/path.otf"
|
||||||
|
|
||||||
|
def test_resolves_relative_paths(self):
|
||||||
|
"""Relative paths are resolved to repo root."""
|
||||||
|
result = config._resolve_font_path("fonts/test.otf")
|
||||||
|
assert str(config._REPO_ROOT) in result
|
||||||
|
|
||||||
|
def test_expands_user_home(self):
|
||||||
|
"""Tilde paths are expanded."""
|
||||||
|
with patch("pathlib.Path.expanduser", return_value=Path("/home/user/fonts")):
|
||||||
|
result = config._resolve_font_path("~/fonts/test.otf")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListFontFiles:
|
||||||
|
"""Tests for _list_font_files helper."""
|
||||||
|
|
||||||
|
def test_returns_empty_for_missing_dir(self):
|
||||||
|
"""Returns empty list for missing directory."""
|
||||||
|
result = config._list_font_files("/nonexistent/directory")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_filters_by_extension(self):
|
||||||
|
"""Only returns valid font extensions."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
Path(tmpdir, "valid.otf").touch()
|
||||||
|
Path(tmpdir, "valid.ttf").touch()
|
||||||
|
Path(tmpdir, "invalid.txt").touch()
|
||||||
|
Path(tmpdir, "image.png").touch()
|
||||||
|
|
||||||
|
result = config._list_font_files(tmpdir)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(f.endswith((".otf", ".ttf")) for f in result)
|
||||||
|
|
||||||
|
def test_sorts_alphabetically(self):
|
||||||
|
"""Results are sorted alphabetically."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
Path(tmpdir, "zfont.otf").touch()
|
||||||
|
Path(tmpdir, "afont.otf").touch()
|
||||||
|
|
||||||
|
result = config._list_font_files(tmpdir)
|
||||||
|
filenames = [Path(f).name for f in result]
|
||||||
|
assert filenames == ["afont.otf", "zfont.otf"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""Tests for default configuration values."""
|
||||||
|
|
||||||
|
def test_headline_limit(self):
|
||||||
|
"""HEADLINE_LIMIT has sensible default."""
|
||||||
|
assert config.HEADLINE_LIMIT > 0
|
||||||
|
|
||||||
|
def test_feed_timeout(self):
|
||||||
|
"""FEED_TIMEOUT has sensible default."""
|
||||||
|
assert config.FEED_TIMEOUT > 0
|
||||||
|
|
||||||
|
def test_font_extensions(self):
|
||||||
|
"""Font extensions are defined."""
|
||||||
|
assert ".otf" in config._FONT_EXTENSIONS
|
||||||
|
assert ".ttf" in config._FONT_EXTENSIONS
|
||||||
|
assert ".ttc" in config._FONT_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlyphs:
|
||||||
|
"""Tests for glyph constants."""
|
||||||
|
|
||||||
|
def test_glitch_glyphs_defined(self):
|
||||||
|
"""GLITCH glyphs are defined."""
|
||||||
|
assert len(config.GLITCH) > 0
|
||||||
|
|
||||||
|
def test_kata_glyphs_defined(self):
|
||||||
|
"""KATA glyphs are defined."""
|
||||||
|
assert len(config.KATA) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFontSelection:
|
||||||
|
"""Tests for set_font_selection function."""
|
||||||
|
|
||||||
|
def test_updates_font_path(self):
|
||||||
|
"""Updates FONT_PATH globally."""
|
||||||
|
original = config.FONT_PATH
|
||||||
|
config.set_font_selection(font_path="/new/path.otf")
|
||||||
|
assert config.FONT_PATH == "/new/path.otf"
|
||||||
|
config.FONT_PATH = original
|
||||||
|
|
||||||
|
def test_updates_font_index(self):
|
||||||
|
"""Updates FONT_INDEX globally."""
|
||||||
|
original = config.FONT_INDEX
|
||||||
|
config.set_font_selection(font_index=5)
|
||||||
|
assert config.FONT_INDEX == 5
|
||||||
|
config.FONT_INDEX = original
|
||||||
|
|
||||||
|
def test_handles_none_values(self):
|
||||||
|
"""Handles None values gracefully."""
|
||||||
|
original_path = config.FONT_PATH
|
||||||
|
original_index = config.FONT_INDEX
|
||||||
|
|
||||||
|
config.set_font_selection(font_path=None, font_index=None)
|
||||||
|
assert original_path == config.FONT_PATH
|
||||||
|
assert original_index == config.FONT_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDataclass:
|
||||||
|
"""Tests for Config dataclass."""
|
||||||
|
|
||||||
|
def test_config_has_required_fields(self):
|
||||||
|
"""Config has all required fields."""
|
||||||
|
c = config.Config()
|
||||||
|
assert hasattr(c, "headline_limit")
|
||||||
|
assert hasattr(c, "feed_timeout")
|
||||||
|
assert hasattr(c, "mic_threshold_db")
|
||||||
|
assert hasattr(c, "mode")
|
||||||
|
assert hasattr(c, "firehose")
|
||||||
|
assert hasattr(c, "ntfy_topic")
|
||||||
|
assert hasattr(c, "ntfy_reconnect_delay")
|
||||||
|
assert hasattr(c, "message_display_secs")
|
||||||
|
assert hasattr(c, "font_dir")
|
||||||
|
assert hasattr(c, "font_path")
|
||||||
|
assert hasattr(c, "font_index")
|
||||||
|
assert hasattr(c, "font_picker")
|
||||||
|
assert hasattr(c, "font_sz")
|
||||||
|
assert hasattr(c, "render_h")
|
||||||
|
assert hasattr(c, "ssaa")
|
||||||
|
assert hasattr(c, "scroll_dur")
|
||||||
|
assert hasattr(c, "frame_dt")
|
||||||
|
assert hasattr(c, "firehose_h")
|
||||||
|
assert hasattr(c, "grad_speed")
|
||||||
|
assert hasattr(c, "glitch_glyphs")
|
||||||
|
assert hasattr(c, "kata_glyphs")
|
||||||
|
assert hasattr(c, "script_fonts")
|
||||||
|
|
||||||
|
def test_config_defaults(self):
|
||||||
|
"""Config has sensible defaults."""
|
||||||
|
c = config.Config()
|
||||||
|
assert c.headline_limit == 1000
|
||||||
|
assert c.feed_timeout == 10
|
||||||
|
assert c.mic_threshold_db == 50
|
||||||
|
assert c.mode == "news"
|
||||||
|
assert c.firehose is False
|
||||||
|
assert c.ntfy_reconnect_delay == 5
|
||||||
|
assert c.message_display_secs == 30
|
||||||
|
|
||||||
|
def test_config_is_immutable(self):
|
||||||
|
"""Config is frozen (immutable)."""
|
||||||
|
c = config.Config()
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
c.headline_limit = 500 # type: ignore
|
||||||
|
|
||||||
|
def test_config_custom_values(self):
|
||||||
|
"""Config accepts custom values."""
|
||||||
|
c = config.Config(
|
||||||
|
headline_limit=500,
|
||||||
|
mode="poetry",
|
||||||
|
firehose=True,
|
||||||
|
ntfy_topic="https://ntfy.sh/test",
|
||||||
|
)
|
||||||
|
assert c.headline_limit == 500
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
assert c.firehose is True
|
||||||
|
assert c.ntfy_topic == "https://ntfy.sh/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigFromArgs:
|
||||||
|
"""Tests for Config.from_args method."""
|
||||||
|
|
||||||
|
def test_from_args_defaults(self):
|
||||||
|
"""from_args creates config with defaults from empty argv."""
|
||||||
|
c = config.Config.from_args(["prog"])
|
||||||
|
assert c.mode == "news"
|
||||||
|
assert c.firehose is False
|
||||||
|
assert c.font_picker is True
|
||||||
|
|
||||||
|
def test_from_args_poetry_mode(self):
|
||||||
|
"""from_args detects --poetry flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--poetry"])
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
|
||||||
|
def test_from_args_poetry_short_flag(self):
|
||||||
|
"""from_args detects -p short flag."""
|
||||||
|
c = config.Config.from_args(["prog", "-p"])
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
|
||||||
|
def test_from_args_firehose(self):
|
||||||
|
"""from_args detects --firehose flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--firehose"])
|
||||||
|
assert c.firehose is True
|
||||||
|
|
||||||
|
def test_from_args_no_font_picker(self):
|
||||||
|
"""from_args detects --no-font-picker flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--no-font-picker"])
|
||||||
|
assert c.font_picker is False
|
||||||
|
|
||||||
|
def test_from_args_font_index(self):
|
||||||
|
"""from_args parses --font-index."""
|
||||||
|
c = config.Config.from_args(["prog", "--font-index", "3"])
|
||||||
|
assert c.font_index == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSetConfig:
|
||||||
|
"""Tests for get_config and set_config functions."""
|
||||||
|
|
||||||
|
def test_get_config_returns_config(self):
|
||||||
|
"""get_config returns a Config instance."""
|
||||||
|
c = config.get_config()
|
||||||
|
assert isinstance(c, config.Config)
|
||||||
|
|
||||||
|
def test_set_config_allows_injection(self):
|
||||||
|
"""set_config allows injecting a custom config."""
|
||||||
|
custom = config.Config(mode="poetry", headline_limit=100)
|
||||||
|
config.set_config(custom)
|
||||||
|
assert config.get_config().mode == "poetry"
|
||||||
|
assert config.get_config().headline_limit == 100
|
||||||
|
|
||||||
|
def test_set_config_then_get_config(self):
|
||||||
|
"""set_config followed by get_config returns the set config."""
|
||||||
|
original = config.get_config()
|
||||||
|
test_config = config.Config(headline_limit=42)
|
||||||
|
config.set_config(test_config)
|
||||||
|
result = config.get_config()
|
||||||
|
assert result.headline_limit == 42
|
||||||
|
config.set_config(original)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlatformFontPaths:
|
||||||
|
"""Tests for platform font path detection."""
|
||||||
|
|
||||||
|
def test_get_platform_font_paths_returns_dict(self):
|
||||||
|
"""_get_platform_font_paths returns a dictionary."""
|
||||||
|
fonts = config._get_platform_font_paths()
|
||||||
|
assert isinstance(fonts, dict)
|
||||||
|
|
||||||
|
def test_platform_font_paths_common_languages(self):
|
||||||
|
"""Common language font mappings exist."""
|
||||||
|
fonts = config._get_platform_font_paths()
|
||||||
|
common = {"ja", "zh-cn", "ko", "ru", "ar", "hi"}
|
||||||
|
found = set(fonts.keys()) & common
|
||||||
|
assert len(found) > 0
|
||||||
117
tests/test_controller.py
Normal file
117
tests/test_controller.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.controller module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.controller import StreamController
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamController:
|
||||||
|
"""Tests for StreamController class."""
|
||||||
|
|
||||||
|
def test_init_default_config(self):
|
||||||
|
"""StreamController initializes with default config."""
|
||||||
|
controller = StreamController()
|
||||||
|
assert controller.config is not None
|
||||||
|
assert isinstance(controller.config, config.Config)
|
||||||
|
|
||||||
|
def test_init_custom_config(self):
|
||||||
|
"""StreamController accepts custom config."""
|
||||||
|
custom_config = config.Config(headline_limit=500)
|
||||||
|
controller = StreamController(config=custom_config)
|
||||||
|
assert controller.config.headline_limit == 500
|
||||||
|
|
||||||
|
def test_init_sources_none_by_default(self):
|
||||||
|
"""Sources are None until initialized."""
|
||||||
|
controller = StreamController()
|
||||||
|
assert controller.mic is None
|
||||||
|
assert controller.ntfy is None
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
@patch("engine.controller.NtfyPoller")
|
||||||
|
def test_initialize_sources(self, mock_ntfy, mock_mic):
|
||||||
|
"""initialize_sources creates mic and ntfy instances."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic_instance.available = True
|
||||||
|
mock_mic_instance.start.return_value = True
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
mock_ntfy_instance = MagicMock()
|
||||||
|
mock_ntfy_instance.start.return_value = True
|
||||||
|
mock_ntfy.return_value = mock_ntfy_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
|
|
||||||
|
assert mic_ok is True
|
||||||
|
assert ntfy_ok is True
|
||||||
|
assert controller.mic is not None
|
||||||
|
assert controller.ntfy is not None
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
@patch("engine.controller.NtfyPoller")
|
||||||
|
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
|
||||||
|
"""initialize_sources handles unavailable mic."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic_instance.available = False
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
mock_ntfy_instance = MagicMock()
|
||||||
|
mock_ntfy_instance.start.return_value = True
|
||||||
|
mock_ntfy.return_value = mock_ntfy_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
|
|
||||||
|
assert mic_ok is False
|
||||||
|
assert ntfy_ok is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerCleanup:
|
||||||
|
"""Tests for StreamController cleanup."""
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
def test_cleanup_stops_mic(self, mock_mic):
|
||||||
|
"""cleanup stops the microphone if running."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
controller.mic = mock_mic_instance
|
||||||
|
controller.cleanup()
|
||||||
|
|
||||||
|
mock_mic_instance.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerWarmup:
|
||||||
|
"""Tests for StreamController topic warmup."""
|
||||||
|
|
||||||
|
def test_warmup_topics_idempotent(self):
|
||||||
|
"""warmup_topics can be called multiple times."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert mock_urlopen.call_count >= 3
|
||||||
|
|
||||||
|
def test_warmup_topics_sets_flag(self):
|
||||||
|
"""warmup_topics sets the warmed flag."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen"):
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert StreamController._topics_warmed is True
|
||||||
|
|
||||||
|
def test_warmup_topics_skips_after_first(self):
|
||||||
|
"""warmup_topics skips after first call."""
|
||||||
|
StreamController._topics_warmed = True
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
mock_urlopen.assert_not_called()
|
||||||
79
tests/test_display.py
Normal file
79
tests/test_display.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.display import NullDisplay, TerminalDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayProtocol:
|
||||||
|
"""Test that display backends satisfy the Display protocol."""
|
||||||
|
|
||||||
|
def test_terminal_display_is_display(self):
|
||||||
|
"""TerminalDisplay satisfies Display protocol."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
def test_null_display_is_display(self):
|
||||||
|
"""NullDisplay satisfies Display protocol."""
|
||||||
|
display = NullDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDisplay:
|
||||||
|
"""Tests for TerminalDisplay class."""
|
||||||
|
|
||||||
|
def test_init_sets_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_show_returns_none(self):
|
||||||
|
"""show returns None after writing to stdout."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.width = 80
|
||||||
|
display.height = 24
|
||||||
|
display.show(["line1", "line2"])
|
||||||
|
|
||||||
|
def test_clear_does_not_error(self):
|
||||||
|
"""clear works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_not_error(self):
|
||||||
|
"""cleanup works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNullDisplay:
|
||||||
|
"""Tests for NullDisplay class."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores dimensions."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.init(100, 50)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 50
|
||||||
|
|
||||||
|
def test_show_does_nothing(self):
|
||||||
|
"""show discards buffer without error."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.show(["line1", "line2", "line3"])
|
||||||
|
|
||||||
|
def test_clear_does_nothing(self):
|
||||||
|
"""clear does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_nothing(self):
|
||||||
|
"""cleanup does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.cleanup()
|
||||||
427
tests/test_effects.py
Normal file
427
tests/test_effects.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class MockEffect:
|
||||||
|
name = "mock"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processed = False
|
||||||
|
self.last_ctx = None
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
self.processed = True
|
||||||
|
self.last_ctx = ctx
|
||||||
|
return buf + ["processed"]
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectConfig:
|
||||||
|
def test_defaults(self):
|
||||||
|
cfg = EffectConfig()
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.intensity == 1.0
|
||||||
|
assert cfg.params == {}
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.intensity == 0.5
|
||||||
|
assert cfg.params == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectContext:
|
||||||
|
def test_defaults(self):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
assert ctx.terminal_width == 80
|
||||||
|
assert ctx.terminal_height == 24
|
||||||
|
assert ctx.ticker_height == 20
|
||||||
|
assert ctx.items == []
|
||||||
|
|
||||||
|
def test_with_items(self):
|
||||||
|
items = [("Title", "Source", "12:00")]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
assert ctx.items == items
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectRegistry:
|
||||||
|
def test_init_empty(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert len(registry.list_all()) == 0
|
||||||
|
|
||||||
|
def test_register(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
assert "mock" in registry.list_all()
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
retrieved = registry.get("mock")
|
||||||
|
assert retrieved is effect
|
||||||
|
|
||||||
|
def test_get_nonexistent(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_enable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
registry.enable("mock")
|
||||||
|
assert effect.config.enabled is True
|
||||||
|
|
||||||
|
def test_disable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
registry.disable("mock")
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
|
||||||
|
def test_list_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
|
||||||
|
class EnabledEffect:
|
||||||
|
name = "enabled_effect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
class DisabledEffect:
|
||||||
|
name = "disabled_effect"
|
||||||
|
config = EffectConfig(enabled=False, intensity=1.0)
|
||||||
|
|
||||||
|
registry.register(EnabledEffect())
|
||||||
|
registry.register(DisabledEffect())
|
||||||
|
enabled = registry.list_enabled()
|
||||||
|
assert len(enabled) == 1
|
||||||
|
assert enabled[0].name == "enabled_effect"
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||||
|
registry.configure("mock", new_config)
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
assert effect.config.intensity == 0.3
|
||||||
|
|
||||||
|
def test_is_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
assert registry.is_enabled("mock") is True
|
||||||
|
assert registry.is_enabled("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectChain:
|
||||||
|
def test_init(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
assert chain.get_order() == []
|
||||||
|
|
||||||
|
def test_set_order(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2"])
|
||||||
|
assert chain.get_order() == ["effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_add_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.add_effect("test_effect")
|
||||||
|
assert "test_effect" in chain.get_order()
|
||||||
|
|
||||||
|
def test_add_effect_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.add_effect("nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_remove_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
chain.remove_effect("test_effect")
|
||||||
|
assert "test_effect" not in chain.get_order()
|
||||||
|
|
||||||
|
def test_reorder(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
effect3 = MockEffect()
|
||||||
|
effect3.name = "effect3"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
registry.register(effect3)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2", "effect3"])
|
||||||
|
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||||
|
assert result is True
|
||||||
|
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_reorder_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "effect1"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.reorder(["effect1", "nonexistent"])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_process_empty_chain(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == buf
|
||||||
|
|
||||||
|
def test_process_with_effects(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1", "line2", "processed"]
|
||||||
|
assert effect.processed is True
|
||||||
|
assert effect.last_ctx is ctx
|
||||||
|
|
||||||
|
def test_process_disabled_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1"]
|
||||||
|
assert effect.processed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectsExports:
|
||||||
|
def test_all_exports_are_importable(self):
|
||||||
|
"""Verify all exports in __all__ can actually be imported."""
|
||||||
|
import engine.effects as effects_module
|
||||||
|
|
||||||
|
for name in effects_module.__all__:
|
||||||
|
getattr(effects_module, name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceMonitor:
|
||||||
|
def test_empty_stats(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
def test_record_and_retrieve(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||||
|
monitor.end_frame(1, 2.0)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["frame_count"] == 1
|
||||||
|
assert "test_effect" in stats["effects"]
|
||||||
|
|
||||||
|
def test_multiple_frames(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=3)
|
||||||
|
for i in range(5):
|
||||||
|
monitor.start_frame(i)
|
||||||
|
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||||
|
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||||
|
monitor.end_frame(i, 1.5)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert stats["frame_count"] == 3
|
||||||
|
assert "effect1" in stats["effects"]
|
||||||
|
assert "effect2" in stats["effects"]
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test", 1.0, 100, 100)
|
||||||
|
monitor.end_frame(1, 1.0)
|
||||||
|
|
||||||
|
monitor.reset()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectPipelinePerformance:
|
||||||
|
def test_pipeline_stays_within_frame_budget(self):
|
||||||
|
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyEffect:
|
||||||
|
name = "dummy"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
return [line * 2 for line in buf]
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(DummyEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=10)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["dummy"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 20
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["pipeline"]["max_ms"] < 33.0
|
||||||
|
|
||||||
|
def test_individual_effects_performance(self):
|
||||||
|
"""Verify individual effects don't exceed 10ms per frame."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SlowEffect:
|
||||||
|
name = "slow"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
result = []
|
||||||
|
for line in buf:
|
||||||
|
result.append(line)
|
||||||
|
result.append(line + line)
|
||||||
|
return result
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(SlowEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=5)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["slow"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 10
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||||
117
tests/test_effects_controller.py
Normal file
117
tests/test_effects_controller.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects.controller module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.effects.controller import (
|
||||||
|
handle_effects_command,
|
||||||
|
set_effect_chain_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleEffectsCommand:
|
||||||
|
"""Tests for handle_effects_command function."""
|
||||||
|
|
||||||
|
def test_list_effects(self):
|
||||||
|
"""list command returns formatted effects list."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.enabled = True
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain.return_value.get_order.return_value = ["noise"]
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects list")
|
||||||
|
|
||||||
|
assert "noise: ON" in result
|
||||||
|
assert "intensity=0.5" in result
|
||||||
|
|
||||||
|
def test_enable_effect(self):
|
||||||
|
"""enable command calls registry.enable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise on")
|
||||||
|
|
||||||
|
assert "Enabled: noise" in result
|
||||||
|
mock_registry.return_value.enable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_disable_effect(self):
|
||||||
|
"""disable command calls registry.disable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise off")
|
||||||
|
|
||||||
|
assert "Disabled: noise" in result
|
||||||
|
mock_registry.return_value.disable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_set_intensity(self):
|
||||||
|
"""intensity command sets plugin intensity."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 0.8")
|
||||||
|
|
||||||
|
assert "intensity to 0.8" in result
|
||||||
|
assert mock_plugin.config.intensity == 0.8
|
||||||
|
|
||||||
|
def test_invalid_intensity_range(self):
|
||||||
|
"""intensity outside 0.0-1.0 returns error."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 1.5")
|
||||||
|
|
||||||
|
assert "between 0.0 and 1.0" in result
|
||||||
|
|
||||||
|
def test_reorder_pipeline(self):
|
||||||
|
"""reorder command calls chain.reorder."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_registry.return_value.list_all.return_value = {}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain_instance = MagicMock()
|
||||||
|
mock_chain_instance.reorder.return_value = True
|
||||||
|
mock_chain.return_value = mock_chain_instance
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects reorder noise,fade")
|
||||||
|
|
||||||
|
assert "Reordered pipeline" in result
|
||||||
|
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
||||||
|
|
||||||
|
def test_unknown_command(self):
|
||||||
|
"""unknown command returns error."""
|
||||||
|
result = handle_effects_command("/unknown")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
def test_non_effects_command(self):
|
||||||
|
"""non-effects command returns error."""
|
||||||
|
result = handle_effects_command("not a command")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetEffectChainRef:
|
||||||
|
"""Tests for set_effect_chain_ref function."""
|
||||||
|
|
||||||
|
def test_sets_global_ref(self):
|
||||||
|
"""set_effect_chain_ref updates global reference."""
|
||||||
|
mock_chain = MagicMock()
|
||||||
|
set_effect_chain_ref(mock_chain)
|
||||||
|
|
||||||
|
from engine.effects.controller import _get_effect_chain
|
||||||
|
|
||||||
|
result = _get_effect_chain()
|
||||||
|
assert result == mock_chain
|
||||||
69
tests/test_emitters.py
Normal file
69
tests/test_emitters.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.emitters module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.emitters import EventEmitter, Startable, Stoppable
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventEmitterProtocol:
|
||||||
|
"""Tests for EventEmitter protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""EventEmitter protocol is defined."""
|
||||||
|
assert EventEmitter is not None
|
||||||
|
|
||||||
|
def test_protocol_has_subscribe_method(self):
|
||||||
|
"""EventEmitter has subscribe method in protocol."""
|
||||||
|
assert hasattr(EventEmitter, "subscribe")
|
||||||
|
|
||||||
|
def test_protocol_has_unsubscribe_method(self):
|
||||||
|
"""EventEmitter has unsubscribe method in protocol."""
|
||||||
|
assert hasattr(EventEmitter, "unsubscribe")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartableProtocol:
|
||||||
|
"""Tests for Startable protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""Startable protocol is defined."""
|
||||||
|
assert Startable is not None
|
||||||
|
|
||||||
|
def test_protocol_has_start_method(self):
|
||||||
|
"""Startable has start method in protocol."""
|
||||||
|
assert hasattr(Startable, "start")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoppableProtocol:
|
||||||
|
"""Tests for Stoppable protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""Stoppable protocol is defined."""
|
||||||
|
assert Stoppable is not None
|
||||||
|
|
||||||
|
def test_protocol_has_stop_method(self):
|
||||||
|
"""Stoppable has stop method in protocol."""
|
||||||
|
assert hasattr(Stoppable, "stop")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtocolCompliance:
|
||||||
|
"""Tests that existing classes comply with protocols."""
|
||||||
|
|
||||||
|
def test_ntfy_poller_complies_with_protocol(self):
|
||||||
|
"""NtfyPoller implements EventEmitter protocol."""
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
assert hasattr(poller, "subscribe")
|
||||||
|
assert hasattr(poller, "unsubscribe")
|
||||||
|
assert callable(poller.subscribe)
|
||||||
|
assert callable(poller.unsubscribe)
|
||||||
|
|
||||||
|
def test_mic_monitor_complies_with_protocol(self):
|
||||||
|
"""MicMonitor implements EventEmitter and Startable protocols."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert hasattr(monitor, "subscribe")
|
||||||
|
assert hasattr(monitor, "unsubscribe")
|
||||||
|
assert hasattr(monitor, "start")
|
||||||
|
assert hasattr(monitor, "stop")
|
||||||
202
tests/test_eventbus.py
Normal file
202
tests/test_eventbus.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.eventbus module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from engine.eventbus import EventBus, get_event_bus, set_event_bus
|
||||||
|
from engine.events import EventType, NtfyMessageEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusInit:
|
||||||
|
"""Tests for EventBus initialization."""
|
||||||
|
|
||||||
|
def test_init_creates_empty_subscribers(self):
|
||||||
|
"""EventBus starts with no subscribers."""
|
||||||
|
bus = EventBus()
|
||||||
|
assert bus.subscriber_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusSubscribe:
|
||||||
|
"""Tests for EventBus.subscribe method."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback for an event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||||
|
|
||||||
|
def test_subscribe_multiple_callbacks_same_event(self):
|
||||||
|
"""Multiple callbacks can be subscribed to the same event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb2)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 2
|
||||||
|
|
||||||
|
def test_subscribe_different_event_types(self):
|
||||||
|
"""Callbacks can be subscribed to different event types."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||||
|
assert bus.subscriber_count(EventType.MIC_LEVEL) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusUnsubscribe:
|
||||||
|
"""Tests for EventBus.unsubscribe method."""
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 0
|
||||||
|
|
||||||
|
def test_unsubscribe_nonexistent_callback_no_error(self):
|
||||||
|
"""unsubscribe() handles non-existent callback gracefully."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusPublish:
|
||||||
|
"""Tests for EventBus.publish method."""
|
||||||
|
|
||||||
|
def test_publish_calls_subscriber(self):
|
||||||
|
"""publish() calls the subscriber callback."""
|
||||||
|
bus = EventBus()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].title == "Test"
|
||||||
|
|
||||||
|
def test_publish_multiple_subscribers(self):
|
||||||
|
"""publish() calls all subscribers for an event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
received1 = []
|
||||||
|
received2 = []
|
||||||
|
|
||||||
|
def callback1(event):
|
||||||
|
received1.append(event)
|
||||||
|
|
||||||
|
def callback2(event):
|
||||||
|
received2.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback1)
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback2)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(received1) == 1
|
||||||
|
assert len(received2) == 1
|
||||||
|
|
||||||
|
def test_publish_different_event_types(self):
|
||||||
|
"""publish() only calls subscribers for the specific event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
ntfy_received = []
|
||||||
|
mic_received = []
|
||||||
|
|
||||||
|
def ntfy_callback(event):
|
||||||
|
ntfy_received.append(event)
|
||||||
|
|
||||||
|
def mic_callback(event):
|
||||||
|
mic_received.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, ntfy_callback)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, mic_callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(ntfy_received) == 1
|
||||||
|
assert len(mic_received) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusClear:
|
||||||
|
"""Tests for EventBus.clear method."""
|
||||||
|
|
||||||
|
def test_clear_removes_all_subscribers(self):
|
||||||
|
"""clear() removes all subscribers."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||||
|
bus.clear()
|
||||||
|
|
||||||
|
assert bus.subscriber_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusThreadSafety:
|
||||||
|
"""Tests for EventBus thread safety."""
|
||||||
|
|
||||||
|
def test_concurrent_subscribe_unsubscribe(self):
|
||||||
|
"""subscribe and unsubscribe can be called concurrently."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
callbacks = [lambda e: None for _ in range(10)]
|
||||||
|
|
||||||
|
def subscribe():
|
||||||
|
for cb in callbacks:
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb)
|
||||||
|
|
||||||
|
def unsubscribe():
|
||||||
|
for cb in callbacks:
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, cb)
|
||||||
|
|
||||||
|
t1 = threading.Thread(target=subscribe)
|
||||||
|
t2 = threading.Thread(target=unsubscribe)
|
||||||
|
t1.start()
|
||||||
|
t2.start()
|
||||||
|
t1.join()
|
||||||
|
t2.join()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalEventBus:
|
||||||
|
"""Tests for global event bus functions."""
|
||||||
|
|
||||||
|
def test_get_event_bus_returns_singleton(self):
|
||||||
|
"""get_event_bus() returns the same instance."""
|
||||||
|
bus1 = get_event_bus()
|
||||||
|
bus2 = get_event_bus()
|
||||||
|
assert bus1 is bus2
|
||||||
|
|
||||||
|
def test_set_event_bus_replaces_singleton(self):
|
||||||
|
"""set_event_bus() replaces the global event bus."""
|
||||||
|
new_bus = EventBus()
|
||||||
|
set_event_bus(new_bus)
|
||||||
|
try:
|
||||||
|
assert get_event_bus() is new_bus
|
||||||
|
finally:
|
||||||
|
set_event_bus(None)
|
||||||
112
tests/test_events.py
Normal file
112
tests/test_events.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.events module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import events
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventType:
|
||||||
|
"""Tests for EventType enum."""
|
||||||
|
|
||||||
|
def test_event_types_exist(self):
|
||||||
|
"""All expected event types exist."""
|
||||||
|
assert hasattr(events.EventType, "NEW_HEADLINE")
|
||||||
|
assert hasattr(events.EventType, "FRAME_TICK")
|
||||||
|
assert hasattr(events.EventType, "MIC_LEVEL")
|
||||||
|
assert hasattr(events.EventType, "NTFY_MESSAGE")
|
||||||
|
assert hasattr(events.EventType, "STREAM_START")
|
||||||
|
assert hasattr(events.EventType, "STREAM_END")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadlineEvent:
|
||||||
|
"""Tests for HeadlineEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_headline_event(self):
|
||||||
|
"""HeadlineEvent can be created with required fields."""
|
||||||
|
e = events.HeadlineEvent(
|
||||||
|
title="Test Headline",
|
||||||
|
source="Test Source",
|
||||||
|
timestamp="12:00",
|
||||||
|
)
|
||||||
|
assert e.title == "Test Headline"
|
||||||
|
assert e.source == "Test Source"
|
||||||
|
assert e.timestamp == "12:00"
|
||||||
|
|
||||||
|
def test_headline_event_optional_language(self):
|
||||||
|
"""HeadlineEvent supports optional language field."""
|
||||||
|
e = events.HeadlineEvent(
|
||||||
|
title="Test",
|
||||||
|
source="Test",
|
||||||
|
timestamp="12:00",
|
||||||
|
language="ja",
|
||||||
|
)
|
||||||
|
assert e.language == "ja"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameTickEvent:
|
||||||
|
"""Tests for FrameTickEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_frame_tick_event(self):
|
||||||
|
"""FrameTickEvent can be created."""
|
||||||
|
now = datetime.now()
|
||||||
|
e = events.FrameTickEvent(
|
||||||
|
frame_number=100,
|
||||||
|
timestamp=now,
|
||||||
|
delta_seconds=0.05,
|
||||||
|
)
|
||||||
|
assert e.frame_number == 100
|
||||||
|
assert e.timestamp == now
|
||||||
|
assert e.delta_seconds == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicLevelEvent:
|
||||||
|
"""Tests for MicLevelEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_mic_level_event(self):
|
||||||
|
"""MicLevelEvent can be created."""
|
||||||
|
now = datetime.now()
|
||||||
|
e = events.MicLevelEvent(
|
||||||
|
db_level=60.0,
|
||||||
|
excess_above_threshold=10.0,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
assert e.db_level == 60.0
|
||||||
|
assert e.excess_above_threshold == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyMessageEvent:
|
||||||
|
"""Tests for NtfyMessageEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_ntfy_message_event(self):
|
||||||
|
"""NtfyMessageEvent can be created with required fields."""
|
||||||
|
e = events.NtfyMessageEvent(
|
||||||
|
title="Test Title",
|
||||||
|
body="Test Body",
|
||||||
|
)
|
||||||
|
assert e.title == "Test Title"
|
||||||
|
assert e.body == "Test Body"
|
||||||
|
assert e.message_id is None
|
||||||
|
|
||||||
|
def test_ntfy_message_event_with_id(self):
|
||||||
|
"""NtfyMessageEvent supports optional message_id."""
|
||||||
|
e = events.NtfyMessageEvent(
|
||||||
|
title="Test",
|
||||||
|
body="Test",
|
||||||
|
message_id="abc123",
|
||||||
|
)
|
||||||
|
assert e.message_id == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamEvent:
|
||||||
|
"""Tests for StreamEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_stream_event(self):
|
||||||
|
"""StreamEvent can be created."""
|
||||||
|
e = events.StreamEvent(
|
||||||
|
event_type=events.EventType.STREAM_START,
|
||||||
|
headline_count=100,
|
||||||
|
)
|
||||||
|
assert e.event_type == events.EventType.STREAM_START
|
||||||
|
assert e.headline_count == 100
|
||||||
35
tests/test_fetch_code.py
Normal file
35
tests/test_fetch_code.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_shape():
|
||||||
|
items, line_count, ignored = fetch_code()
|
||||||
|
assert isinstance(items, list)
|
||||||
|
assert line_count == len(items)
|
||||||
|
assert ignored == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_are_tuples():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
assert items, "expected at least one code line"
|
||||||
|
for item in items:
|
||||||
|
assert isinstance(item, tuple) and len(item) == 3
|
||||||
|
text, src, ts = item
|
||||||
|
assert isinstance(text, str)
|
||||||
|
assert isinstance(src, str)
|
||||||
|
assert isinstance(ts, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_comment_lines_excluded():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
for text, _, _ in items:
|
||||||
|
assert text.strip(), "blank line should have been filtered"
|
||||||
|
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_path_format():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
pattern = re.compile(r"^engine\.\w+$")
|
||||||
|
for _, _, ts in items:
|
||||||
|
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
||||||
151
tests/test_figment.py
Normal file
151
tests/test_figment.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Tests for the FigmentEffect plugin."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||||
|
|
||||||
|
from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext
|
||||||
|
|
||||||
|
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
||||||
|
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentPhase:
|
||||||
|
def test_is_enum(self):
|
||||||
|
assert issubclass(FigmentPhase, Enum)
|
||||||
|
|
||||||
|
def test_has_all_phases(self):
|
||||||
|
assert hasattr(FigmentPhase, "REVEAL")
|
||||||
|
assert hasattr(FigmentPhase, "HOLD")
|
||||||
|
assert hasattr(FigmentPhase, "DISSOLVE")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentState:
|
||||||
|
def test_creation(self):
|
||||||
|
state = FigmentState(
|
||||||
|
phase=FigmentPhase.REVEAL,
|
||||||
|
progress=0.5,
|
||||||
|
rows=["█▀▄", " █ "],
|
||||||
|
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
||||||
|
center_row=5,
|
||||||
|
center_col=10,
|
||||||
|
)
|
||||||
|
assert state.phase == FigmentPhase.REVEAL
|
||||||
|
assert state.progress == 0.5
|
||||||
|
assert len(state.rows) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentEffectInit:
|
||||||
|
def test_name(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
assert effect.name == "figment"
|
||||||
|
|
||||||
|
def test_default_config(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
assert effect.config.intensity == 1.0
|
||||||
|
assert effect.config.params["interval_secs"] == 60
|
||||||
|
assert effect.config.params["display_secs"] == 4.5
|
||||||
|
|
||||||
|
def test_process_is_noop(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
)
|
||||||
|
result = effect.process(buf, ctx)
|
||||||
|
assert result == buf
|
||||||
|
assert result is buf
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
new_cfg = EffectConfig(enabled=True, intensity=0.5)
|
||||||
|
effect.configure(new_cfg)
|
||||||
|
assert effect.config.enabled is True
|
||||||
|
assert effect.config.intensity == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentStateMachine:
|
||||||
|
def test_idle_initially(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
effect.config.enabled = True
|
||||||
|
state = effect.get_figment_state(0, 80, 24)
|
||||||
|
# Timer hasn't fired yet, should be None (idle)
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
def test_trigger_starts_reveal(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
effect.config.enabled = True
|
||||||
|
effect.trigger(80, 24)
|
||||||
|
state = effect.get_figment_state(1, 80, 24)
|
||||||
|
assert state is not None
|
||||||
|
assert state.phase == FigmentPhase.REVEAL
|
||||||
|
|
||||||
|
def test_full_cycle(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
effect.config.enabled = True
|
||||||
|
effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s
|
||||||
|
|
||||||
|
effect.trigger(40, 20)
|
||||||
|
|
||||||
|
# Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
|
||||||
|
# With display_secs=0.15, each phase is 0.05s = 1 frame
|
||||||
|
state = effect.get_figment_state(1, 40, 20)
|
||||||
|
assert state is not None
|
||||||
|
assert state.phase == FigmentPhase.REVEAL
|
||||||
|
|
||||||
|
# Advance enough frames to get through all phases
|
||||||
|
for frame in range(2, 100):
|
||||||
|
state = effect.get_figment_state(frame, 40, 20)
|
||||||
|
if state is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Should have completed the full cycle back to idle
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
def test_timer_fires_at_interval(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
effect.config.enabled = True
|
||||||
|
effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps
|
||||||
|
|
||||||
|
# Frame 0: idle
|
||||||
|
state = effect.get_figment_state(0, 40, 20)
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
# Advance past interval (0.1s = 2 frames)
|
||||||
|
state = effect.get_figment_state(1, 40, 20)
|
||||||
|
state = effect.get_figment_state(2, 40, 20)
|
||||||
|
state = effect.get_figment_state(3, 40, 20)
|
||||||
|
# Timer should have fired by now
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentEdgeCases:
|
||||||
|
def test_empty_figment_dir(self, tmp_path):
|
||||||
|
effect = FigmentEffect(figment_dir=str(tmp_path))
|
||||||
|
effect.config.enabled = True
|
||||||
|
effect.trigger(40, 20)
|
||||||
|
state = effect.get_figment_state(1, 40, 20)
|
||||||
|
# No SVGs available — should stay idle
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
def test_missing_figment_dir(self):
|
||||||
|
effect = FigmentEffect(figment_dir="/nonexistent/path")
|
||||||
|
effect.config.enabled = True
|
||||||
|
effect.trigger(40, 20)
|
||||||
|
state = effect.get_figment_state(1, 40, 20)
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
def test_disabled_ignores_trigger(self):
|
||||||
|
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
|
||||||
|
effect.config.enabled = False
|
||||||
|
effect.trigger(80, 24)
|
||||||
|
state = effect.get_figment_state(1, 80, 24)
|
||||||
|
assert state is None
|
||||||
64
tests/test_figment_overlay.py
Normal file
64
tests/test_figment_overlay.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Tests for render_figment_overlay in engine.layers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||||
|
|
||||||
|
from effects_plugins.figment import FigmentPhase, FigmentState
|
||||||
|
from engine.layers import render_figment_overlay
|
||||||
|
|
||||||
|
|
||||||
|
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
|
||||||
|
return FigmentState(
|
||||||
|
phase=phase,
|
||||||
|
progress=progress,
|
||||||
|
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
|
||||||
|
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
|
||||||
|
center_row=10,
|
||||||
|
center_col=37,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFigmentOverlay:
|
||||||
|
def test_returns_list_of_strings(self):
|
||||||
|
state = _make_state()
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(s, str) for s in result)
|
||||||
|
|
||||||
|
def test_contains_ansi_positioning(self):
|
||||||
|
state = _make_state()
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
# Should contain cursor positioning escape codes
|
||||||
|
assert any("\033[" in s for s in result)
|
||||||
|
|
||||||
|
def test_reveal_phase_partial(self):
|
||||||
|
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
# At progress 0.0, very few cells should be visible
|
||||||
|
# Result should still be a valid list
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
def test_hold_phase_full(self):
|
||||||
|
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
# During hold, content should be present
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_dissolve_phase(self):
|
||||||
|
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
# At high dissolve progress, most cells are gone
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
state = FigmentState(
|
||||||
|
phase=FigmentPhase.HOLD,
|
||||||
|
progress=0.5,
|
||||||
|
rows=[],
|
||||||
|
gradient=[46] * 12,
|
||||||
|
center_row=0,
|
||||||
|
center_col=0,
|
||||||
|
)
|
||||||
|
result = render_figment_overlay(state, 80, 24)
|
||||||
|
assert result == []
|
||||||
52
tests/test_figment_render.py
Normal file
52
tests/test_figment_render.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for engine.figment_render module."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
|
||||||
|
|
||||||
|
from engine.figment_render import rasterize_svg
|
||||||
|
|
||||||
|
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRasterizeSvg:
|
||||||
|
def test_returns_list_of_strings(self):
|
||||||
|
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
assert isinstance(rows, list)
|
||||||
|
assert all(isinstance(r, str) for r in rows)
|
||||||
|
|
||||||
|
def test_output_height_matches_terminal_height(self):
|
||||||
|
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
assert len(rows) == 20
|
||||||
|
|
||||||
|
def test_output_contains_block_characters(self):
|
||||||
|
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
all_chars = "".join(rows)
|
||||||
|
block_chars = {"█", "▀", "▄"}
|
||||||
|
assert any(ch in all_chars for ch in block_chars)
|
||||||
|
|
||||||
|
def test_different_sizes_produce_different_output(self):
|
||||||
|
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
|
||||||
|
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
|
||||||
|
assert len(rows_small) == 10
|
||||||
|
assert len(rows_large) == 40
|
||||||
|
|
||||||
|
def test_nonexistent_file_raises(self):
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises((FileNotFoundError, OSError)):
|
||||||
|
rasterize_svg("/nonexistent/file.svg", 40, 20)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRasterizeCache:
|
||||||
|
def test_cache_returns_same_result(self):
|
||||||
|
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
assert rows1 == rows2
|
||||||
|
|
||||||
|
def test_cache_invalidated_by_size_change(self):
|
||||||
|
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
|
||||||
|
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
|
||||||
|
assert len(rows1) != len(rows2)
|
||||||
40
tests/test_figment_trigger.py
Normal file
40
tests/test_figment_trigger.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Tests for engine.figment_trigger module."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from engine.figment_trigger import FigmentAction, FigmentCommand
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentAction:
|
||||||
|
def test_is_enum(self):
|
||||||
|
assert issubclass(FigmentAction, Enum)
|
||||||
|
|
||||||
|
def test_has_trigger(self):
|
||||||
|
assert FigmentAction.TRIGGER.value == "trigger"
|
||||||
|
|
||||||
|
def test_has_set_intensity(self):
|
||||||
|
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
|
||||||
|
|
||||||
|
def test_has_set_interval(self):
|
||||||
|
assert FigmentAction.SET_INTERVAL.value == "set_interval"
|
||||||
|
|
||||||
|
def test_has_set_color(self):
|
||||||
|
assert FigmentAction.SET_COLOR.value == "set_color"
|
||||||
|
|
||||||
|
def test_has_stop(self):
|
||||||
|
assert FigmentAction.STOP.value == "stop"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFigmentCommand:
|
||||||
|
def test_trigger_command(self):
|
||||||
|
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
|
||||||
|
assert cmd.action == FigmentAction.TRIGGER
|
||||||
|
assert cmd.value is None
|
||||||
|
|
||||||
|
def test_set_intensity_command(self):
|
||||||
|
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
|
||||||
|
assert cmd.value == 0.8
|
||||||
|
|
||||||
|
def test_set_color_command(self):
|
||||||
|
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
|
||||||
|
assert cmd.value == "orange"
|
||||||
93
tests/test_filter.py
Normal file
93
tests/test_filter.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.filter module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.filter import skip, strip_tags
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripTags:
|
||||||
|
"""Tests for strip_tags function."""
|
||||||
|
|
||||||
|
def test_strips_simple_html(self):
|
||||||
|
"""Basic HTML tags are removed."""
|
||||||
|
assert strip_tags("<p>Hello</p>") == "Hello"
|
||||||
|
assert strip_tags("<b>Bold</b>") == "Bold"
|
||||||
|
assert strip_tags("<em>Italic</em>") == "Italic"
|
||||||
|
|
||||||
|
def test_strips_nested_html(self):
|
||||||
|
"""Nested HTML tags are handled."""
|
||||||
|
assert strip_tags("<div><p>Nested</p></div>") == "Nested"
|
||||||
|
assert strip_tags("<span><strong>Deep</strong></span>") == "Deep"
|
||||||
|
|
||||||
|
def test_strips_html_with_attributes(self):
|
||||||
|
"""HTML with attributes is handled."""
|
||||||
|
assert strip_tags('<a href="http://example.com">Link</a>') == "Link"
|
||||||
|
assert strip_tags('<img src="test.jpg" alt="test">') == ""
|
||||||
|
|
||||||
|
def test_handles_empty_string(self):
|
||||||
|
"""Empty string returns empty string."""
|
||||||
|
assert strip_tags("") == ""
|
||||||
|
assert strip_tags(None) == ""
|
||||||
|
|
||||||
|
def test_handles_plain_text(self):
|
||||||
|
"""Plain text without tags passes through."""
|
||||||
|
assert strip_tags("Plain text") == "Plain text"
|
||||||
|
|
||||||
|
def test_unescapes_html_entities(self):
|
||||||
|
"""HTML entities are decoded and tags are stripped."""
|
||||||
|
assert strip_tags(" test") == "test"
|
||||||
|
assert strip_tags("Hello & World") == "Hello & World"
|
||||||
|
|
||||||
|
def test_handles_malformed_html(self):
|
||||||
|
"""Malformed HTML is handled gracefully."""
|
||||||
|
assert strip_tags("<p>Unclosed") == "Unclosed"
|
||||||
|
assert strip_tags("</p>No start") == "No start"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkip:
|
||||||
|
"""Tests for skip function - content filtering."""
|
||||||
|
|
||||||
|
def test_skips_sports_content(self):
|
||||||
|
"""Sports-related headlines are skipped."""
|
||||||
|
assert skip("Football: Team wins championship") is True
|
||||||
|
assert skip("NBA Finals Game 7 results") is True
|
||||||
|
assert skip("Soccer match ends in draw") is True
|
||||||
|
assert skip("Premier League transfer news") is True
|
||||||
|
assert skip("Super Bowl halftime show") is True
|
||||||
|
|
||||||
|
def test_skips_vapid_content(self):
|
||||||
|
"""Vapid/celebrity content is skipped."""
|
||||||
|
assert skip("Kim Kardashian's new look") is True
|
||||||
|
assert skip("Influencer goes viral") is True
|
||||||
|
assert skip("Red carpet best dressed") is True
|
||||||
|
assert skip("Celebrity couple splits") is True
|
||||||
|
|
||||||
|
def test_allows_real_news(self):
|
||||||
|
"""Legitimate news headlines are allowed."""
|
||||||
|
assert skip("Scientists discover new planet") is False
|
||||||
|
assert skip("Economy grows by 3%") is False
|
||||||
|
assert skip("World leaders meet for summit") is False
|
||||||
|
assert skip("New technology breakthrough") is False
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Filter is case insensitive."""
|
||||||
|
assert skip("FOOTBALL scores") is True
|
||||||
|
assert skip("Football SCORES") is True
|
||||||
|
assert skip("Kardashian") is True
|
||||||
|
|
||||||
|
def test_word_boundary_matching(self):
|
||||||
|
"""Word boundary matching works correctly."""
|
||||||
|
assert skip("The football stadium") is True
|
||||||
|
assert skip("Footballer scores") is False
|
||||||
|
assert skip("Footballs on sale") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
"""Integration tests combining filter functions."""
|
||||||
|
|
||||||
|
def test_full_pipeline(self):
|
||||||
|
"""Test strip_tags followed by skip."""
|
||||||
|
html = '<p><a href="#">Breaking: Football championship final</a></p>'
|
||||||
|
text = strip_tags(html)
|
||||||
|
assert text == "Breaking: Football championship final"
|
||||||
|
assert skip(text) is True
|
||||||
63
tests/test_frame.py
Normal file
63
tests/test_frame.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.frame module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine.frame import FrameTimer, calculate_scroll_step
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameTimer:
|
||||||
|
"""Tests for FrameTimer class."""
|
||||||
|
|
||||||
|
def test_init_default(self):
|
||||||
|
"""FrameTimer initializes with default values."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
assert timer.target_frame_dt == 0.05
|
||||||
|
assert timer.fps >= 0
|
||||||
|
|
||||||
|
def test_init_custom(self):
|
||||||
|
"""FrameTimer accepts custom frame duration."""
|
||||||
|
timer = FrameTimer(target_frame_dt=0.1)
|
||||||
|
assert timer.target_frame_dt == 0.1
|
||||||
|
|
||||||
|
def test_fps_calculation(self):
|
||||||
|
"""FrameTimer calculates FPS correctly."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
timer._frame_count = 10
|
||||||
|
timer._start_time = time.monotonic() - 1.0
|
||||||
|
assert timer.fps >= 9.0
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
"""FrameTimer.reset() clears frame count."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
timer._frame_count = 100
|
||||||
|
timer.reset()
|
||||||
|
assert timer._frame_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateScrollStep:
|
||||||
|
"""Tests for calculate_scroll_step function."""
|
||||||
|
|
||||||
|
def test_basic_calculation(self):
|
||||||
|
"""calculate_scroll_step returns positive value."""
|
||||||
|
result = calculate_scroll_step(5.0, 24)
|
||||||
|
assert result > 0
|
||||||
|
|
||||||
|
def test_with_padding(self):
|
||||||
|
"""calculate_scroll_step respects padding parameter."""
|
||||||
|
without_padding = calculate_scroll_step(5.0, 24, padding=0)
|
||||||
|
with_padding = calculate_scroll_step(5.0, 24, padding=15)
|
||||||
|
assert with_padding < without_padding
|
||||||
|
|
||||||
|
def test_larger_view_slower_scroll(self):
|
||||||
|
"""Larger view height results in slower scroll steps."""
|
||||||
|
small = calculate_scroll_step(5.0, 10)
|
||||||
|
large = calculate_scroll_step(5.0, 50)
|
||||||
|
assert large < small
|
||||||
|
|
||||||
|
def test_longer_duration_slower_scroll(self):
|
||||||
|
"""Longer scroll duration results in slower scroll steps."""
|
||||||
|
fast = calculate_scroll_step(2.0, 24)
|
||||||
|
slow = calculate_scroll_step(10.0, 24)
|
||||||
|
assert slow > fast
|
||||||
96
tests/test_layers.py
Normal file
96
tests/test_layers.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.layers module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine import layers
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderMessageOverlay:
|
||||||
|
"""Tests for render_message_overlay function."""
|
||||||
|
|
||||||
|
def test_no_message_returns_empty(self):
|
||||||
|
"""Returns empty list when msg is None."""
|
||||||
|
result, cache = layers.render_message_overlay(None, 80, 24, (None, None))
|
||||||
|
assert result == []
|
||||||
|
assert cache[0] is None
|
||||||
|
|
||||||
|
def test_message_returns_overlay_lines(self):
|
||||||
|
"""Returns non-empty list when message is present."""
|
||||||
|
msg = ("Test Title", "Test Body", time.monotonic())
|
||||||
|
result, cache = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||||
|
assert len(result) > 0
|
||||||
|
assert cache[0] is not None
|
||||||
|
|
||||||
|
def test_cache_key_changes_with_text(self):
|
||||||
|
"""Cache key changes when message text changes."""
|
||||||
|
msg1 = ("Title1", "Body1", time.monotonic())
|
||||||
|
msg2 = ("Title2", "Body2", time.monotonic())
|
||||||
|
|
||||||
|
_, cache1 = layers.render_message_overlay(msg1, 80, 24, (None, None))
|
||||||
|
_, cache2 = layers.render_message_overlay(msg2, 80, 24, cache1)
|
||||||
|
|
||||||
|
assert cache1[0] != cache2[0]
|
||||||
|
|
||||||
|
def test_cache_reuse_avoids_recomputation(self):
|
||||||
|
"""Cache is returned when same message is passed (interface test)."""
|
||||||
|
msg = ("Same Title", "Same Body", time.monotonic())
|
||||||
|
|
||||||
|
result1, cache1 = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||||
|
result2, cache2 = layers.render_message_overlay(msg, 80, 24, cache1)
|
||||||
|
|
||||||
|
assert len(result1) > 0
|
||||||
|
assert len(result2) > 0
|
||||||
|
assert cache1[0] == cache2[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFirehose:
|
||||||
|
"""Tests for render_firehose function."""
|
||||||
|
|
||||||
|
def test_no_firehose_returns_empty(self):
|
||||||
|
"""Returns empty list when firehose height is 0."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 0, 24)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_firehose_returns_lines(self):
|
||||||
|
"""Returns lines when firehose height > 0."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 4, 24)
|
||||||
|
assert len(result) == 4
|
||||||
|
|
||||||
|
def test_firehose_includes_ansi_escapes(self):
|
||||||
|
"""Returns lines containing ANSI escape sequences."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 1, 24)
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyGlitch:
|
||||||
|
"""Tests for apply_glitch function."""
|
||||||
|
|
||||||
|
def test_empty_buffer_unchanged(self):
|
||||||
|
"""Empty buffer is returned unchanged."""
|
||||||
|
result = layers.apply_glitch([], 0, 0.0, 80)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_buffer_length_preserved(self):
|
||||||
|
"""Buffer length is preserved after glitch application."""
|
||||||
|
buf = [f"\033[{i + 1};1Htest\033[K" for i in range(10)]
|
||||||
|
result = layers.apply_glitch(buf, 0, 0.5, 80)
|
||||||
|
assert len(result) == len(buf)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTickerZone:
|
||||||
|
"""Tests for render_ticker_zone function - focusing on interface."""
|
||||||
|
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Returns a list of strings."""
|
||||||
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
def test_returns_dict_for_cache(self):
|
||||||
|
"""Returns a dict for the noise cache."""
|
||||||
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
|
assert isinstance(cache, dict)
|
||||||
149
tests/test_mic.py
Normal file
149
tests/test_mic.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.mic module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine.events import MicLevelEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorImport:
|
||||||
|
"""Tests for module import behavior."""
|
||||||
|
|
||||||
|
def test_mic_monitor_imports_without_error(self):
|
||||||
|
"""MicMonitor can be imported even without sounddevice."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
assert MicMonitor is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorInit:
|
||||||
|
"""Tests for MicMonitor initialization."""
|
||||||
|
|
||||||
|
def test_init_sets_threshold(self):
|
||||||
|
"""Threshold is set correctly."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=60)
|
||||||
|
assert monitor.threshold_db == 60
|
||||||
|
|
||||||
|
def test_init_defaults(self):
|
||||||
|
"""Default values are set correctly."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert monitor.threshold_db == 50
|
||||||
|
|
||||||
|
def test_init_db_starts_at_negative(self):
|
||||||
|
"""_db starts at negative value."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert monitor.db == -99.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorProperties:
|
||||||
|
"""Tests for MicMonitor properties."""
|
||||||
|
|
||||||
|
def test_excess_returns_positive_when_above_threshold(self):
|
||||||
|
"""excess returns positive value when above threshold."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=50)
|
||||||
|
with patch.object(monitor, "_db", 60.0):
|
||||||
|
assert monitor.excess == 10.0
|
||||||
|
|
||||||
|
def test_excess_returns_zero_when_below_threshold(self):
|
||||||
|
"""excess returns zero when below threshold."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=50)
|
||||||
|
with patch.object(monitor, "_db", 40.0):
|
||||||
|
assert monitor.excess == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorAvailable:
|
||||||
|
"""Tests for MicMonitor.available property."""
|
||||||
|
|
||||||
|
def test_available_is_bool(self):
|
||||||
|
"""available returns a boolean."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert isinstance(monitor.available, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorStop:
|
||||||
|
"""Tests for MicMonitor.stop method."""
|
||||||
|
|
||||||
|
def test_stop_does_nothing_when_no_stream(self):
|
||||||
|
"""stop() does nothing if no stream exists."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
monitor.stop()
|
||||||
|
assert monitor._stream is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorEventEmission:
|
||||||
|
"""Tests for MicMonitor event emission."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
|
||||||
|
assert callback in monitor._subscribers
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
|
||||||
|
monitor.unsubscribe(callback)
|
||||||
|
|
||||||
|
assert callback not in monitor._subscribers
|
||||||
|
|
||||||
|
def test_emit_calls_subscribers(self):
|
||||||
|
"""_emit() calls all subscribers."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
monitor._emit(event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].db_level == 60.0
|
||||||
|
|
||||||
|
def test_emit_handles_subscriber_exception(self):
|
||||||
|
"""_emit() handles exceptions in subscribers gracefully."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
|
||||||
|
def bad_callback(event):
|
||||||
|
raise RuntimeError("test")
|
||||||
|
|
||||||
|
monitor.subscribe(bad_callback)
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
monitor._emit(event)
|
||||||
122
tests/test_ntfy.py
Normal file
122
tests/test_ntfy.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.ntfy module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.events import NtfyMessageEvent
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerInit:
|
||||||
|
"""Tests for NtfyPoller initialization."""
|
||||||
|
|
||||||
|
def test_init_sets_defaults(self):
|
||||||
|
"""Default values are set correctly."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
assert poller.topic_url == "http://example.com/topic"
|
||||||
|
assert poller.reconnect_delay == 5
|
||||||
|
assert poller.display_secs == 30
|
||||||
|
|
||||||
|
def test_init_custom_values(self):
|
||||||
|
"""Custom values are set correctly."""
|
||||||
|
poller = NtfyPoller(
|
||||||
|
"http://example.com/topic", reconnect_delay=10, display_secs=60
|
||||||
|
)
|
||||||
|
assert poller.reconnect_delay == 10
|
||||||
|
assert poller.display_secs == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerStart:
|
||||||
|
"""Tests for NtfyPoller.start method."""
|
||||||
|
|
||||||
|
@patch("engine.ntfy.threading.Thread")
|
||||||
|
def test_start_creates_daemon_thread(self, mock_thread):
|
||||||
|
"""start() creates and starts a daemon thread."""
|
||||||
|
mock_thread_instance = MagicMock()
|
||||||
|
mock_thread.return_value = mock_thread_instance
|
||||||
|
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
result = poller.start()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
args, kwargs = mock_thread.call_args
|
||||||
|
assert kwargs.get("daemon") is True
|
||||||
|
mock_thread_instance.start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerGetActiveMessage:
|
||||||
|
"""Tests for NtfyPoller.get_active_message method."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_message(self):
|
||||||
|
"""Returns None when no message has been received."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
result = poller.get_active_message()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerDismiss:
|
||||||
|
"""Tests for NtfyPoller.dismiss method."""
|
||||||
|
|
||||||
|
def test_dismiss_clears_message(self):
|
||||||
|
"""dismiss() clears the current message."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
|
||||||
|
with patch.object(poller, "_lock"):
|
||||||
|
poller._message = ("Title", "Body", time.monotonic())
|
||||||
|
poller.dismiss()
|
||||||
|
|
||||||
|
assert poller._message is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerEventEmission:
|
||||||
|
"""Tests for NtfyPoller event emission."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
poller.subscribe(callback)
|
||||||
|
|
||||||
|
assert callback in poller._subscribers
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
poller.subscribe(callback)
|
||||||
|
|
||||||
|
poller.unsubscribe(callback)
|
||||||
|
|
||||||
|
assert callback not in poller._subscribers
|
||||||
|
|
||||||
|
def test_emit_calls_subscribers(self):
|
||||||
|
"""_emit() calls all subscribers."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
poller.subscribe(callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
poller._emit(event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].title == "Test"
|
||||||
|
|
||||||
|
def test_emit_handles_subscriber_exception(self):
|
||||||
|
"""_emit() handles exceptions in subscribers gracefully."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
|
||||||
|
def bad_callback(event):
|
||||||
|
raise RuntimeError("test")
|
||||||
|
|
||||||
|
poller.subscribe(bad_callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
poller._emit(event)
|
||||||
301
tests/test_render.py
Normal file
301
tests/test_render.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.render module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultGradients:
|
||||||
|
"""Tests for default gradient fallback functions."""
|
||||||
|
|
||||||
|
def test_default_green_gradient_length(self):
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_green_gradient_is_list(self):
|
||||||
|
"""_default_green_gradient returns a list."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_green_gradient_all_strings(self):
|
||||||
|
"""_default_green_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_length(self):
|
||||||
|
"""_default_magenta_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_is_list(self):
|
||||||
|
"""_default_magenta_gradient returns a list."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_all_strings(self):
|
||||||
|
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientUsesActiveTheme:
|
||||||
|
"""Tests for lr_gradient using active theme."""
|
||||||
|
|
||||||
|
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||||
|
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (cols=None)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(self):
|
||||||
|
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (should use fallback)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||||
|
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||||
|
# Custom gradient
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
# Should use the provided cols
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||||
|
"""lr_gradient accepts cols as keyword argument."""
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
# Call with cols as keyword
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientBasicFunctionality:
|
||||||
|
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||||
|
|
||||||
|
def test_lr_gradient_colors_non_space_chars(self):
|
||||||
|
"""lr_gradient colors non-space characters."""
|
||||||
|
rows = ["hello"]
|
||||||
|
|
||||||
|
# Set a theme for the test
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Result should have ANSI codes
|
||||||
|
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_preserves_spaces(self):
|
||||||
|
"""lr_gradient preserves spaces in output."""
|
||||||
|
rows = ["a b c"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved (not colored)
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_empty_rows(self):
|
||||||
|
"""lr_gradient handles empty rows correctly."""
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_multiple_rows(self):
|
||||||
|
"""lr_gradient handles multiple rows."""
|
||||||
|
rows = ["row1", "row2", "row3"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
|
||||||
|
class TestMsgGradient:
|
||||||
|
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||||
|
|
||||||
|
def test_msg_gradient_uses_active_theme(self):
|
||||||
|
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using theme's message_gradient
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes from the message gradient
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_fallback_when_no_theme(self):
|
||||||
|
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using default magenta
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_returns_colored_rows(self):
|
||||||
|
"""msg_gradient returns properly colored rows with animation offset."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
rows = ["NTFY", "ALERT"]
|
||||||
|
|
||||||
|
# Call with offset
|
||||||
|
result = render.msg_gradient(rows, offset=0.5)
|
||||||
|
|
||||||
|
# Should return same number of rows
|
||||||
|
assert len(result) == 2
|
||||||
|
# Both should be colored
|
||||||
|
assert all("\033[" in r for r in result)
|
||||||
|
# Should not be the original text
|
||||||
|
assert result != rows
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||||
|
"""msg_gradient produces different colors for different themes."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = ["TEST"]
|
||||||
|
|
||||||
|
# Get result with green theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result_green = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Get result with orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Results should be different (different message gradients)
|
||||||
|
assert result_green != result_orange
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_preserves_spacing(self):
|
||||||
|
"""msg_gradient preserves spaces in rows."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("purple")
|
||||||
|
rows = ["M E S S A G E"]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_empty_rows(self):
|
||||||
|
"""msg_gradient handles empty rows correctly."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Empty row should stay empty
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
93
tests/test_sources.py
Normal file
93
tests/test_sources.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.sources module - data validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine import sources
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeeds:
|
||||||
|
"""Tests for FEEDS data."""
|
||||||
|
|
||||||
|
def test_feeds_is_dict(self):
|
||||||
|
"""FEEDS is a dictionary."""
|
||||||
|
assert isinstance(sources.FEEDS, dict)
|
||||||
|
|
||||||
|
def test_feeds_has_entries(self):
|
||||||
|
"""FEEDS has feed entries."""
|
||||||
|
assert len(sources.FEEDS) > 0
|
||||||
|
|
||||||
|
def test_feeds_have_valid_urls(self):
|
||||||
|
"""All feeds have valid URL format."""
|
||||||
|
for name, url in sources.FEEDS.items():
|
||||||
|
assert name
|
||||||
|
assert url.startswith("http://") or url.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoetrySources:
|
||||||
|
"""Tests for POETRY_SOURCES data."""
|
||||||
|
|
||||||
|
def test_poetry_is_dict(self):
|
||||||
|
"""POETRY_SOURCES is a dictionary."""
|
||||||
|
assert isinstance(sources.POETRY_SOURCES, dict)
|
||||||
|
|
||||||
|
def test_poetry_has_entries(self):
|
||||||
|
"""POETRY_SOURCES has entries."""
|
||||||
|
assert len(sources.POETRY_SOURCES) > 0
|
||||||
|
|
||||||
|
def test_poetry_have_gutenberg_urls(self):
|
||||||
|
"""All poetry sources are from Gutenberg."""
|
||||||
|
for _name, url in sources.POETRY_SOURCES.items():
|
||||||
|
assert "gutenberg.org" in url
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceLangs:
|
||||||
|
"""Tests for SOURCE_LANGS mapping."""
|
||||||
|
|
||||||
|
def test_source_langs_is_dict(self):
|
||||||
|
"""SOURCE_LANGS is a dictionary."""
|
||||||
|
assert isinstance(sources.SOURCE_LANGS, dict)
|
||||||
|
|
||||||
|
def test_source_langs_valid_language_codes(self):
|
||||||
|
"""Language codes are valid ISO codes."""
|
||||||
|
valid_codes = {"de", "fr", "ja", "zh-cn", "ar", "hi"}
|
||||||
|
for code in sources.SOURCE_LANGS.values():
|
||||||
|
assert code in valid_codes
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocationLangs:
|
||||||
|
"""Tests for LOCATION_LANGS mapping."""
|
||||||
|
|
||||||
|
def test_location_langs_is_dict(self):
|
||||||
|
"""LOCATION_LANGS is a dictionary."""
|
||||||
|
assert isinstance(sources.LOCATION_LANGS, dict)
|
||||||
|
|
||||||
|
def test_location_langs_has_patterns(self):
|
||||||
|
"""LOCATION_LANGS has regex patterns."""
|
||||||
|
assert len(sources.LOCATION_LANGS) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestScriptFonts:
|
||||||
|
"""Tests for SCRIPT_FONTS mapping."""
|
||||||
|
|
||||||
|
def test_script_fonts_is_dict(self):
|
||||||
|
"""SCRIPT_FONTS is a dictionary."""
|
||||||
|
assert isinstance(sources.SCRIPT_FONTS, dict)
|
||||||
|
|
||||||
|
def test_script_fonts_has_paths(self):
|
||||||
|
"""All script fonts have paths."""
|
||||||
|
for _lang, path in sources.SCRIPT_FONTS.items():
|
||||||
|
assert path
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoUpper:
|
||||||
|
"""Tests for NO_UPPER set."""
|
||||||
|
|
||||||
|
def test_no_upper_is_set(self):
|
||||||
|
"""NO_UPPER is a set."""
|
||||||
|
assert isinstance(sources.NO_UPPER, set)
|
||||||
|
|
||||||
|
def test_no_upper_contains_scripts(self):
|
||||||
|
"""NO_UPPER contains non-Latin scripts."""
|
||||||
|
assert "zh-cn" in sources.NO_UPPER
|
||||||
|
assert "ja" in sources.NO_UPPER
|
||||||
|
assert "ar" in sources.NO_UPPER
|
||||||
130
tests/test_terminal.py
Normal file
130
tests/test_terminal.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.terminal module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine import terminal
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDimensions:
|
||||||
|
"""Tests for terminal width/height functions."""
|
||||||
|
|
||||||
|
def test_tw_returns_columns(self):
|
||||||
|
"""tw() returns terminal width."""
|
||||||
|
with (
|
||||||
|
patch.object(sys.stdout, "isatty", return_value=True),
|
||||||
|
patch("os.get_terminal_size") as mock_size,
|
||||||
|
):
|
||||||
|
mock_size.return_value = io.StringIO("columns=120")
|
||||||
|
mock_size.columns = 120
|
||||||
|
result = terminal.tw()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_th_returns_lines(self):
|
||||||
|
"""th() returns terminal height."""
|
||||||
|
with (
|
||||||
|
patch.object(sys.stdout, "isatty", return_value=True),
|
||||||
|
patch("os.get_terminal_size") as mock_size,
|
||||||
|
):
|
||||||
|
mock_size.return_value = io.StringIO("lines=30")
|
||||||
|
mock_size.lines = 30
|
||||||
|
result = terminal.th()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_tw_fallback_on_error(self):
|
||||||
|
"""tw() falls back to 80 on error."""
|
||||||
|
with patch("os.get_terminal_size", side_effect=OSError):
|
||||||
|
result = terminal.tw()
|
||||||
|
assert result == 80
|
||||||
|
|
||||||
|
def test_th_fallback_on_error(self):
|
||||||
|
"""th() falls back to 24 on error."""
|
||||||
|
with patch("os.get_terminal_size", side_effect=OSError):
|
||||||
|
result = terminal.th()
|
||||||
|
assert result == 24
|
||||||
|
|
||||||
|
|
||||||
|
class TestANSICodes:
|
||||||
|
"""Tests for ANSI escape code constants."""
|
||||||
|
|
||||||
|
def test_ansi_constants_exist(self):
|
||||||
|
"""All ANSI constants are defined."""
|
||||||
|
assert terminal.RST == "\033[0m"
|
||||||
|
assert terminal.BOLD == "\033[1m"
|
||||||
|
assert terminal.DIM == "\033[2m"
|
||||||
|
|
||||||
|
def test_green_shades_defined(self):
|
||||||
|
"""Green gradient colors are defined."""
|
||||||
|
assert terminal.G_HI == "\033[38;5;46m"
|
||||||
|
assert terminal.G_MID == "\033[38;5;34m"
|
||||||
|
assert terminal.G_LO == "\033[38;5;22m"
|
||||||
|
|
||||||
|
def test_white_shades_defined(self):
|
||||||
|
"""White/gray tones are defined."""
|
||||||
|
assert terminal.W_COOL == "\033[38;5;250m"
|
||||||
|
assert terminal.W_DIM == "\033[2;38;5;245m"
|
||||||
|
|
||||||
|
def test_cursor_controls_defined(self):
|
||||||
|
"""Cursor control codes are defined."""
|
||||||
|
assert "?" in terminal.CURSOR_OFF
|
||||||
|
assert "?" in terminal.CURSOR_ON
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeOut:
|
||||||
|
"""Tests for type_out function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_type_out_writes_text(self, mock_sleep, mock_stdout):
|
||||||
|
"""type_out writes text to stdout."""
|
||||||
|
with patch("random.random", return_value=0.5):
|
||||||
|
terminal.type_out("Hi", color=terminal.G_HI)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert len(output) > 0
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_type_out_uses_color(self, mock_sleep):
|
||||||
|
"""type_out applies color codes."""
|
||||||
|
with (
|
||||||
|
patch("sys.stdout", new_callable=io.StringIO),
|
||||||
|
patch("random.random", return_value=0.5),
|
||||||
|
):
|
||||||
|
terminal.type_out("Test", color=terminal.G_HI)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlowPrint:
|
||||||
|
"""Tests for slow_print function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_slow_print_writes_text(self, mock_sleep, mock_stdout):
|
||||||
|
"""slow_print writes text to stdout."""
|
||||||
|
terminal.slow_print("Hi", color=terminal.G_DIM, delay=0)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert len(output) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBootLn:
|
||||||
|
"""Tests for boot_ln function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_boot_ln_writes_label_and_status(self, mock_sleep, mock_stdout):
|
||||||
|
"""boot_ln shows label and status."""
|
||||||
|
with patch("random.uniform", return_value=0):
|
||||||
|
terminal.boot_ln("Loading", "OK", ok=True)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert "Loading" in output
|
||||||
|
assert "OK" in output
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_boot_ln_error_status(self, mock_sleep, mock_stdout):
|
||||||
|
"""boot_ln shows red for error status."""
|
||||||
|
with patch("random.uniform", return_value=0):
|
||||||
|
terminal.boot_ln("Loading", "FAIL", ok=False)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert "FAIL" in output
|
||||||
169
tests/test_themes.py
Normal file
169
tests/test_themes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.themes module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeConstruction:
|
||||||
|
"""Tests for Theme class initialization."""
|
||||||
|
|
||||||
|
def test_theme_construction(self):
|
||||||
|
"""Theme stores name and gradients correctly."""
|
||||||
|
main_grad = ["color1", "color2", "color3"]
|
||||||
|
msg_grad = ["msg1", "msg2", "msg3"]
|
||||||
|
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||||
|
|
||||||
|
assert theme.name == "test_theme"
|
||||||
|
assert theme.main_gradient == main_grad
|
||||||
|
assert theme.message_gradient == msg_grad
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradientLength:
|
||||||
|
"""Tests for gradient length validation."""
|
||||||
|
|
||||||
|
def test_gradient_length_green(self):
|
||||||
|
"""Green theme has exactly 12 colors in each gradient."""
|
||||||
|
green = themes.THEME_REGISTRY["green"]
|
||||||
|
assert len(green.main_gradient) == 12
|
||||||
|
assert len(green.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_orange(self):
|
||||||
|
"""Orange theme has exactly 12 colors in each gradient."""
|
||||||
|
orange = themes.THEME_REGISTRY["orange"]
|
||||||
|
assert len(orange.main_gradient) == 12
|
||||||
|
assert len(orange.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_purple(self):
|
||||||
|
"""Purple theme has exactly 12 colors in each gradient."""
|
||||||
|
purple = themes.THEME_REGISTRY["purple"]
|
||||||
|
assert len(purple.main_gradient) == 12
|
||||||
|
assert len(purple.message_gradient) == 12
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeRegistry:
|
||||||
|
"""Tests for THEME_REGISTRY dictionary."""
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes(self):
|
||||||
|
"""Registry contains exactly three themes: green, orange, purple."""
|
||||||
|
assert len(themes.THEME_REGISTRY) == 3
|
||||||
|
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||||
|
|
||||||
|
def test_registry_values_are_themes(self):
|
||||||
|
"""All registry values are Theme instances."""
|
||||||
|
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||||
|
assert isinstance(theme, themes.Theme)
|
||||||
|
assert theme.name == theme_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTheme:
|
||||||
|
"""Tests for get_theme function."""
|
||||||
|
|
||||||
|
def test_get_theme_valid_green(self):
|
||||||
|
"""get_theme('green') returns correct green Theme."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert isinstance(green, themes.Theme)
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
def test_get_theme_valid_orange(self):
|
||||||
|
"""get_theme('orange') returns correct orange Theme."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert isinstance(orange, themes.Theme)
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
def test_get_theme_valid_purple(self):
|
||||||
|
"""get_theme('purple') returns correct purple Theme."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert isinstance(purple, themes.Theme)
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
def test_get_theme_invalid(self):
|
||||||
|
"""get_theme with invalid ID raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme("invalid_theme")
|
||||||
|
|
||||||
|
def test_get_theme_invalid_none(self):
|
||||||
|
"""get_theme with None raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreenTheme:
|
||||||
|
"""Tests for green theme specific values."""
|
||||||
|
|
||||||
|
def test_green_theme_unchanged(self):
|
||||||
|
"""Green theme maintains original color sequence."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||||
|
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||||
|
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
assert green.main_gradient == expected_main
|
||||||
|
assert green.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_green_theme_name(self):
|
||||||
|
"""Green theme has correct name."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrangeTheme:
|
||||||
|
"""Tests for orange theme specific values."""
|
||||||
|
|
||||||
|
def test_orange_theme_unchanged(self):
|
||||||
|
"""Orange theme maintains original color sequence."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||||
|
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||||
|
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
assert orange.main_gradient == expected_main
|
||||||
|
assert orange.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_orange_theme_name(self):
|
||||||
|
"""Orange theme has correct name."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurpleTheme:
|
||||||
|
"""Tests for purple theme specific values."""
|
||||||
|
|
||||||
|
def test_purple_theme_unchanged(self):
|
||||||
|
"""Purple theme maintains original color sequence."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||||
|
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||||
|
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
assert purple.main_gradient == expected_main
|
||||||
|
assert purple.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_purple_theme_name(self):
|
||||||
|
"""Purple theme has correct name."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeDataOnly:
|
||||||
|
"""Tests to ensure themes module has no problematic imports."""
|
||||||
|
|
||||||
|
def test_themes_module_imports(self):
|
||||||
|
"""themes module should be data-only without config/render imports."""
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(themes)
|
||||||
|
# Verify no imports of config or render (look for actual import statements)
|
||||||
|
lines = source.split('\n')
|
||||||
|
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||||
|
# Filter out empty and comment lines
|
||||||
|
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||||
|
# Should have no import lines
|
||||||
|
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||||
95
tests/test_types.py
Normal file
95
tests/test_types.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.types module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.types import (
|
||||||
|
Block,
|
||||||
|
FetchResult,
|
||||||
|
HeadlineItem,
|
||||||
|
items_to_tuples,
|
||||||
|
tuples_to_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadlineItem:
|
||||||
|
"""Tests for HeadlineItem dataclass."""
|
||||||
|
|
||||||
|
def test_create_headline_item(self):
|
||||||
|
"""Can create HeadlineItem with required fields."""
|
||||||
|
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||||
|
assert item.title == "Test"
|
||||||
|
assert item.source == "Source"
|
||||||
|
assert item.timestamp == "12:00"
|
||||||
|
|
||||||
|
def test_to_tuple(self):
|
||||||
|
"""to_tuple returns correct tuple."""
|
||||||
|
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||||
|
assert item.to_tuple() == ("Test", "Source", "12:00")
|
||||||
|
|
||||||
|
def test_from_tuple(self):
|
||||||
|
"""from_tuple creates HeadlineItem from tuple."""
|
||||||
|
item = HeadlineItem.from_tuple(("Test", "Source", "12:00"))
|
||||||
|
assert item.title == "Test"
|
||||||
|
assert item.source == "Source"
|
||||||
|
assert item.timestamp == "12:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemsConversion:
|
||||||
|
"""Tests for list conversion functions."""
|
||||||
|
|
||||||
|
def test_items_to_tuples(self):
|
||||||
|
"""Converts list of HeadlineItem to list of tuples."""
|
||||||
|
items = [
|
||||||
|
HeadlineItem(title="A", source="S", timestamp="10:00"),
|
||||||
|
HeadlineItem(title="B", source="T", timestamp="11:00"),
|
||||||
|
]
|
||||||
|
result = items_to_tuples(items)
|
||||||
|
assert result == [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||||
|
|
||||||
|
def test_tuples_to_items(self):
|
||||||
|
"""Converts list of tuples to list of HeadlineItem."""
|
||||||
|
tuples = [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||||
|
result = tuples_to_items(tuples)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].title == "A"
|
||||||
|
assert result[1].title == "B"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchResult:
|
||||||
|
"""Tests for FetchResult dataclass."""
|
||||||
|
|
||||||
|
def test_create_fetch_result(self):
|
||||||
|
"""Can create FetchResult."""
|
||||||
|
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||||
|
result = FetchResult(items=items, linked=1, failed=0)
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.linked == 1
|
||||||
|
assert result.failed == 0
|
||||||
|
|
||||||
|
def test_to_legacy_tuple(self):
|
||||||
|
"""to_legacy_tuple returns correct format."""
|
||||||
|
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||||
|
result = FetchResult(items=items, linked=1, failed=0)
|
||||||
|
legacy = result.to_legacy_tuple()
|
||||||
|
assert legacy[0] == [("Test", "Source", "12:00")]
|
||||||
|
assert legacy[1] == 1
|
||||||
|
assert legacy[2] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlock:
|
||||||
|
"""Tests for Block dataclass."""
|
||||||
|
|
||||||
|
def test_create_block(self):
|
||||||
|
"""Can create Block."""
|
||||||
|
block = Block(
|
||||||
|
content=["line1", "line2"], color="\033[38;5;46m", meta_row_index=1
|
||||||
|
)
|
||||||
|
assert len(block.content) == 2
|
||||||
|
assert block.color == "\033[38;5;46m"
|
||||||
|
assert block.meta_row_index == 1
|
||||||
|
|
||||||
|
def test_to_legacy_tuple(self):
|
||||||
|
"""to_legacy_tuple returns correct format."""
|
||||||
|
block = Block(content=["line1"], color="green", meta_row_index=0)
|
||||||
|
legacy = block.to_legacy_tuple()
|
||||||
|
assert legacy == (["line1"], "green", 0)
|
||||||
64
tests/test_viewport.py
Normal file
64
tests/test_viewport.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.viewport module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine import viewport
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportTw:
|
||||||
|
"""Tests for tw() function."""
|
||||||
|
|
||||||
|
def test_tw_returns_int(self):
|
||||||
|
"""tw() returns an integer."""
|
||||||
|
result = viewport.tw()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_tw_positive(self):
|
||||||
|
"""tw() returns a positive value."""
|
||||||
|
assert viewport.tw() > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportTh:
|
||||||
|
"""Tests for th() function."""
|
||||||
|
|
||||||
|
def test_th_returns_int(self):
|
||||||
|
"""th() returns an integer."""
|
||||||
|
result = viewport.th()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_th_positive(self):
|
||||||
|
"""th() returns a positive value."""
|
||||||
|
assert viewport.th() > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportMoveTo:
|
||||||
|
"""Tests for move_to() function."""
|
||||||
|
|
||||||
|
def test_move_to_format(self):
|
||||||
|
"""move_to() returns correctly formatted ANSI escape."""
|
||||||
|
result = viewport.move_to(5, 10)
|
||||||
|
assert result == "\033[5;10H"
|
||||||
|
|
||||||
|
def test_move_to_default_col(self):
|
||||||
|
"""move_to() defaults to column 1."""
|
||||||
|
result = viewport.move_to(5)
|
||||||
|
assert result == "\033[5;1H"
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportClearScreen:
|
||||||
|
"""Tests for clear_screen() function."""
|
||||||
|
|
||||||
|
def test_clear_screen_format(self):
|
||||||
|
"""clear_screen() returns clear screen ANSI escape."""
|
||||||
|
result = viewport.clear_screen()
|
||||||
|
assert "\033[2J" in result
|
||||||
|
assert "\033[H" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewportClearLine:
|
||||||
|
"""Tests for clear_line() function."""
|
||||||
|
|
||||||
|
def test_clear_line_format(self):
|
||||||
|
"""clear_line() returns clear line ANSI escape."""
|
||||||
|
result = viewport.clear_line()
|
||||||
|
assert result == "\033[K"
|
||||||
Reference in New Issue
Block a user