Compare commits
31 Commits
feat/code-
...
feature/ve
| Author | SHA1 | Date | |
|---|---|---|---|
| 828b8489e1 | |||
| 31cabe9128 | |||
| bcb4ef0cfe | |||
| 996ba14b1d | |||
| a1dcceac47 | |||
| c2d77ee358 | |||
| 8e27f89fa4 | |||
| 4d28f286db | |||
| 9b139a40f7 | |||
| e1408dcf16 | |||
| 0152e32115 | |||
| dc1adb2558 | |||
| fada11b58d | |||
| 3e9c1be6d2 | |||
| 0f2d8bf5c2 | |||
| f5de2c62e0 | |||
| f9991c24af | |||
| 20ed014491 | |||
| 9e4d54a82e | |||
| dcd31469a5 | |||
| 829c4ab63d | |||
| 22dd063baa | |||
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
coverage.xml
|
||||
|
||||
137
AGENTS.md
137
AGENTS.md
@@ -16,7 +16,7 @@ This project uses:
|
||||
mise run install
|
||||
|
||||
# Or equivalently:
|
||||
uv sync
|
||||
uv sync --all-extras # includes mic, websocket, sixel support
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
@@ -25,10 +25,24 @@ uv sync
|
||||
mise run test # Run tests
|
||||
mise run test-v # Run tests verbose
|
||||
mise run test-cov # Run tests with coverage report
|
||||
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||
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)
|
||||
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
||||
```
|
||||
|
||||
### Runtime Commands
|
||||
|
||||
```bash
|
||||
mise run run # Run mainline (terminal)
|
||||
mise run run-poetry # Run with poetry feed
|
||||
mise run run-firehose # Run in firehose mode
|
||||
mise run run-websocket # Run with WebSocket display only
|
||||
mise run run-sixel # Run with Sixel graphics display
|
||||
mise run run-both # Run with both terminal and WebSocket
|
||||
mise run run-client # Run both + open browser
|
||||
mise run cmd # Run C&C command interface
|
||||
```
|
||||
|
||||
## Git Hooks
|
||||
@@ -46,9 +60,52 @@ hk init --mise
|
||||
mise run pre-commit
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always review the hk documentation before modifying `hk.pkl`:
|
||||
- [hk Configuration Guide](https://hk.jdx.dev/configuration.html)
|
||||
- [hk Hooks Reference](https://hk.jdx.dev/hooks.html)
|
||||
- [hk Builtins](https://hk.jdx.dev/builtins.html)
|
||||
|
||||
The project uses hk configured in `hk.pkl`:
|
||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||
- **pre-push**: runs ruff check
|
||||
- **pre-push**: runs ruff check + benchmark hook
|
||||
|
||||
## Benchmark Runner
|
||||
|
||||
Run performance benchmarks:
|
||||
|
||||
```bash
|
||||
mise run benchmark # Run all benchmarks (text output)
|
||||
mise run benchmark-json # Run benchmarks (JSON output)
|
||||
mise run benchmark-report # Run benchmarks (Markdown report)
|
||||
```
|
||||
|
||||
### Benchmark Commands
|
||||
|
||||
```bash
|
||||
# Run benchmarks
|
||||
uv run python -m engine.benchmark
|
||||
|
||||
# Run with specific displays/effects
|
||||
uv run python -m engine.benchmark --displays null,terminal --effects fade,glitch
|
||||
|
||||
# Save baseline for hook comparisons
|
||||
uv run python -m engine.benchmark --baseline
|
||||
|
||||
# Run in hook mode (compares against baseline)
|
||||
uv run python -m engine.benchmark --hook
|
||||
|
||||
# Hook mode with custom threshold (default: 20% degradation)
|
||||
uv run python -m engine.benchmark --hook --threshold 0.3
|
||||
|
||||
# Custom baseline location
|
||||
uv run python -m engine.benchmark --hook --cache /path/to/cache.json
|
||||
```
|
||||
|
||||
### Hook Mode
|
||||
|
||||
The `--hook` mode compares current benchmarks against a saved baseline. If performance degrades beyond the threshold (default 20%), it exits with code 1. This is useful for preventing performance regressions in feature branches.
|
||||
|
||||
The pre-push hook runs benchmark in hook mode to catch performance regressions before pushing.
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
@@ -102,9 +159,81 @@ mise run test-cov
|
||||
|
||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
|
||||
### Test Coverage Strategy
|
||||
|
||||
Current coverage: 56% (336 tests)
|
||||
|
||||
Key areas with lower coverage (acceptable for now):
|
||||
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
||||
- **scroll.py** (10%): Terminal-dependent rendering logic
|
||||
- **benchmark.py** (0%): Standalone benchmark tool, runs separately
|
||||
|
||||
Key areas with good coverage:
|
||||
- **display/backends/null.py** (95%): Easy to test headlessly
|
||||
- **display/backends/terminal.py** (96%): Uses mocking
|
||||
- **display/backends/multi.py** (100%): Simple forwarding logic
|
||||
- **effects/performance.py** (99%): Pure Python logic
|
||||
- **eventbus.py** (96%): Simple event system
|
||||
- **effects/controller.py** (95%): Effects command handling
|
||||
|
||||
Areas needing more tests:
|
||||
- **websocket.py** (48%): Network I/O, hard to test in CI
|
||||
- **ntfy.py** (50%): Network I/O, hard to test in CI
|
||||
- **mic.py** (61%): Audio I/O, hard to test in CI
|
||||
|
||||
Note: Terminal-dependent modules (scroll, layers render) are harder to test in CI.
|
||||
Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark.benchmark`.
|
||||
|
||||
## 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
|
||||
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
||||
- **effects/** - plugin architecture with performance monitoring
|
||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||
|
||||
### Display System
|
||||
|
||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||
- `display/backends/terminal.py` - ANSI terminal output
|
||||
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
||||
- `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency)
|
||||
- `display/backends/null.py` - headless display for testing
|
||||
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
||||
- `display/__init__.py` - DisplayRegistry for backend discovery
|
||||
|
||||
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
||||
- WebSocket server on port 8765
|
||||
- HTTP server on port 8766 (serves HTML client)
|
||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||
|
||||
- **Display modes** (`--display` flag):
|
||||
- `terminal` - Default ANSI terminal output
|
||||
- `websocket` - Web browser display (requires websockets package)
|
||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
||||
- `both` - Terminal + WebSocket simultaneously
|
||||
|
||||
### Effect Plugin System
|
||||
|
||||
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
||||
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
||||
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
||||
|
||||
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
||||
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
||||
|
||||
### Command & Control
|
||||
|
||||
- C&C uses separate ntfy topics for commands and responses
|
||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||
|
||||
### Pipeline Documentation
|
||||
|
||||
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
||||
|
||||
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
||||
1. Edit `docs/PIPELINE.md` with the new architecture
|
||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
||||
3. Commit both the markdown and any new diagram files
|
||||
234
README.md
234
README.md
@@ -15,7 +15,8 @@ python3 mainline.py # news stream
|
||||
python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||
python3 mainline.py --display websocket # web browser display only
|
||||
python3 mainline.py --display both # terminal + web browser
|
||||
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-dir ~/fonts # scan a different font folder
|
||||
@@ -28,7 +29,20 @@ Or with uv:
|
||||
uv run mainline.py
|
||||
```
|
||||
|
||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
|
||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||
|
||||
### Command & Control (C&C)
|
||||
|
||||
Control mainline remotely using `cmdline.py`:
|
||||
|
||||
```bash
|
||||
uv run cmdline.py # Interactive TUI
|
||||
uv run cmdline.py /effects list # List all effects
|
||||
uv run cmdline.py /effects stats # Show performance stats
|
||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
||||
```
|
||||
|
||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -39,20 +53,32 @@ All constants live in `engine/config.py`:
|
||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||
| `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_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||
|
||||
### 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
|
||||
|
||||
@@ -62,15 +88,15 @@ All constants live in `engine/config.py`:
|
||||
|
||||
### Fonts
|
||||
|
||||
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
||||
|
||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
||||
|
||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
||||
|
||||
### ntfy.sh
|
||||
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
||||
|
||||
To push a message:
|
||||
|
||||
@@ -78,108 +104,68 @@ To push a message:
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Internals
|
||||
|
||||
### 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
|
||||
- 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
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||
- 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 viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||
- The ticker uses a sweeping white-hot → deep green gradient
|
||||
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||
|
||||
### Architecture
|
||||
|
||||
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||
|
||||
```
|
||||
engine/
|
||||
__init__.py package marker
|
||||
app.py main(), font picker TUI, boot sequence, signal handler
|
||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects.py noise, glitch_bar, fade, firehose
|
||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||
effects/ plugin architecture for visual effects
|
||||
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
||||
registry.py effect registration and lookup
|
||||
chain.py effect pipeline chaining
|
||||
controller.py handles /effects commands
|
||||
performance.py performance monitoring
|
||||
legacy.py legacy functional effects
|
||||
effects_plugins/ effect plugin implementations
|
||||
noise.py noise effect
|
||||
fade.py fade effect
|
||||
glitch.py glitch effect
|
||||
firehose.py firehose effect
|
||||
fetch.py RSS/Gutenberg fetching + cache
|
||||
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)
|
||||
viewport.py terminal dimension tracking
|
||||
frame.py scroll step calculation, timing
|
||||
layers.py ticker zone, firehose, message overlay rendering
|
||||
eventbus.py thread-safe event publishing for decoupled communication
|
||||
layers.py ticker zone, firehose, message overlay
|
||||
eventbus.py thread-safe event publishing
|
||||
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
|
||||
controller.py coordinates ntfy/mic monitoring
|
||||
emitters.py background emitters
|
||||
types.py type 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
|
||||
```
|
||||
|
||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||
|
||||
---
|
||||
|
||||
## Extending
|
||||
|
||||
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
|
||||
|
||||
### NtfyPoller
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||
poller.start()
|
||||
|
||||
# in your render loop:
|
||||
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||
if msg:
|
||||
title, body, ts = msg
|
||||
render_my_message(title, body) # visualizer-specific
|
||||
```
|
||||
|
||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
|
||||
|
||||
### MicMonitor
|
||||
|
||||
```python
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
mic = MicMonitor(threshold_db=50)
|
||||
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
|
||||
if result:
|
||||
excess = mic.excess # dB above threshold, clamped to 0
|
||||
db = mic.db # raw RMS dB level
|
||||
```
|
||||
|
||||
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
|
||||
|
||||
### Render pipeline
|
||||
|
||||
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
|
||||
|
||||
```python
|
||||
# planned — serve.py does not yet exist
|
||||
from engine.render import render_line, big_wrap
|
||||
from engine.fetch import fetch_all
|
||||
|
||||
headlines = fetch_all()
|
||||
for h in headlines:
|
||||
rows = big_wrap(h.text, font, width=800) # list of half-block rows
|
||||
# threshold to 1-bit, pack bytes, serve over HTTP
|
||||
```
|
||||
|
||||
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
@@ -190,7 +176,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync # minimal (no mic)
|
||||
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||
uv sync --all-extras # with mic support
|
||||
uv sync --all-extras --group dev # full dev environment
|
||||
```
|
||||
|
||||
@@ -201,23 +187,46 @@ With [mise](https://mise.jdx.dev/):
|
||||
```bash
|
||||
mise run test # run test suite
|
||||
mise run test-cov # run with coverage report
|
||||
|
||||
mise run lint # ruff check
|
||||
mise run lint-fix # ruff check --fix
|
||||
mise run format # ruff format
|
||||
mise run run # uv run mainline.py
|
||||
mise run run-poetry # uv run mainline.py --poetry
|
||||
mise run run-firehose # uv run mainline.py --firehose
|
||||
|
||||
mise run run # terminal display
|
||||
mise run run-websocket # web display only
|
||||
mise run run-sixel # sixel graphics
|
||||
mise run run-both # terminal + web
|
||||
mise run run-client # both + open browser
|
||||
|
||||
mise run cmd # C&C command interface
|
||||
mise run cmd-stats # watch effects stats
|
||||
|
||||
mise run benchmark # run performance benchmarks
|
||||
mise run benchmark-json # save as JSON
|
||||
|
||||
mise run topics-init # initialize ntfy topics
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||
|
||||
```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
|
||||
@@ -232,28 +241,23 @@ Pre-commit hooks run lint automatically via `hk`.
|
||||
## Roadmap
|
||||
|
||||
### Performance
|
||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||
- Concurrent feed fetching with ThreadPoolExecutor
|
||||
- Background feed refresh daemon
|
||||
- Translation pre-fetch during boot
|
||||
|
||||
### Graphics
|
||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||
- Matrix rain katakana underlay
|
||||
- CRT scanline simulation
|
||||
- Sixel/iTerm2 inline images
|
||||
- Parallax secondary column
|
||||
|
||||
### Cyberpunk Vibes
|
||||
- **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
|
||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
||||
|
||||
### Extensibility
|
||||
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
||||
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
||||
- Keyword watch list with strobe effects
|
||||
- Breaking interrupt with synthesized audio
|
||||
- Live data overlay (BTC, ISS position)
|
||||
- Theme switcher (amber, ice, red)
|
||||
- Persona modes (surveillance, oracle, underground)
|
||||
|
||||
---
|
||||
|
||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||
366
client/index.html
Normal file
366
client/index.html
Normal file
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mainline Terminal</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #ccc;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
body.fullscreen {
|
||||
padding: 0;
|
||||
}
|
||||
body.fullscreen #controls {
|
||||
display: none;
|
||||
}
|
||||
#container {
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
body.fullscreen canvas {
|
||||
border: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
#controls button {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
#controls button:hover {
|
||||
background: #444;
|
||||
}
|
||||
#controls input {
|
||||
width: 60px;
|
||||
background: #222;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
#status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
#status.connected {
|
||||
color: #4f4;
|
||||
}
|
||||
#status.disconnected {
|
||||
color: #f44;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<canvas id="terminal"></canvas>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||
<button id="apply">Apply</button>
|
||||
<button id="fullscreen">Fullscreen</button>
|
||||
</div>
|
||||
<div id="status" class="disconnected">Connecting...</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('terminal');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('status');
|
||||
const colsInput = document.getElementById('cols');
|
||||
const rowsInput = document.getElementById('rows');
|
||||
const applyBtn = document.getElementById('apply');
|
||||
const fullscreenBtn = document.getElementById('fullscreen');
|
||||
|
||||
const CHAR_WIDTH = 9;
|
||||
const CHAR_HEIGHT = 16;
|
||||
|
||||
const ANSI_COLORS = {
|
||||
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||
};
|
||||
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
let ws = null;
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = cols * CHAR_WIDTH;
|
||||
canvas.height = rows * CHAR_HEIGHT;
|
||||
}
|
||||
|
||||
function parseAnsi(text) {
|
||||
if (!text) return [];
|
||||
|
||||
const tokens = [];
|
||||
let currentText = '';
|
||||
let fg = '#cccccc';
|
||||
let bg = '#000000';
|
||||
let bold = false;
|
||||
let i = 0;
|
||||
let inEscape = false;
|
||||
let escapeCode = '';
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
if (inEscape) {
|
||||
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||
escapeCode += char;
|
||||
}
|
||||
|
||||
if (char === 'm') {
|
||||
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||
|
||||
for (const code of codes) {
|
||||
const num = parseInt(code) || 0;
|
||||
|
||||
if (num === 0) {
|
||||
fg = '#cccccc';
|
||||
bg = '#000000';
|
||||
bold = false;
|
||||
} else if (num === 1) {
|
||||
bold = true;
|
||||
} else if (num === 22) {
|
||||
bold = false;
|
||||
} else if (num === 39) {
|
||||
fg = '#cccccc';
|
||||
} else if (num === 49) {
|
||||
bg = '#000000';
|
||||
} else if (num >= 30 && num <= 37) {
|
||||
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||
} else if (num >= 40 && num <= 47) {
|
||||
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||
} else if (num >= 90 && num <= 97) {
|
||||
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||
} else if (num >= 100 && num <= 107) {
|
||||
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||
} else if (num >= 1 && num <= 256) {
|
||||
// 256 colors
|
||||
if (num < 16) {
|
||||
fg = ANSI_COLORS[num] || '#cccccc';
|
||||
} else if (num < 232) {
|
||||
const c = num - 16;
|
||||
const r = Math.floor(c / 36) * 51;
|
||||
const g = Math.floor((c % 36) / 6) * 51;
|
||||
const b = (c % 6) * 51;
|
||||
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||
} else {
|
||||
const gray = (num - 232) * 10 + 8;
|
||||
fg = `#${gray.toString(16).repeat(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = false;
|
||||
escapeCode = '';
|
||||
}
|
||||
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = true;
|
||||
escapeCode = '';
|
||||
i++;
|
||||
} else {
|
||||
currentText += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function renderLine(text, x, y, lineHeight) {
|
||||
const tokens = parseAnsi(text);
|
||||
let xOffset = x;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.text) {
|
||||
if (token.bold) {
|
||||
ctx.font = 'bold 16px monospace';
|
||||
} else {
|
||||
ctx.font = '16px monospace';
|
||||
}
|
||||
|
||||
const metrics = ctx.measureText(token.text);
|
||||
|
||||
if (token.bg !== '#000000') {
|
||||
ctx.fillStyle = token.bg;
|
||||
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||
}
|
||||
|
||||
ctx.fillStyle = token.fg;
|
||||
ctx.fillText(token.text, xOffset, y);
|
||||
xOffset += metrics.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected';
|
||||
status.className = 'connected';
|
||||
sendSize();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.textContent = 'Disconnected - Reconnecting...';
|
||||
status.className = 'disconnected';
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
status.textContent = 'Connection error';
|
||||
status.className = 'disconnected';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'frame') {
|
||||
cols = data.width || 80;
|
||||
rows = data.height || 24;
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
render(data.lines || []);
|
||||
} else if (data.type === 'clear') {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendSize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
width: parseInt(colsInput.value),
|
||||
height: parseInt(rowsInput.value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function render(lines) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = '16px monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const lineHeight = CHAR_HEIGHT;
|
||||
const maxLines = Math.min(lines.length, rows);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const line = lines[i] || '';
|
||||
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateViewportSize() {
|
||||
const isFullscreen = document.fullscreenElement !== null;
|
||||
const padding = isFullscreen ? 0 : 40;
|
||||
const controlsHeight = isFullscreen ? 0 : 60;
|
||||
const availableWidth = window.innerWidth - padding;
|
||||
const availableHeight = window.innerHeight - controlsHeight;
|
||||
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||
sendSize();
|
||||
}
|
||||
|
||||
applyBtn.addEventListener('click', () => {
|
||||
cols = parseInt(colsInput.value);
|
||||
rows = parseInt(rowsInput.value);
|
||||
resizeCanvas();
|
||||
sendSize();
|
||||
});
|
||||
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.add('fullscreen');
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.remove('fullscreen');
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (document.fullscreenElement) {
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
256
cmdline.py
Normal file
256
cmdline.py
Normal file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
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 os
|
||||
|
||||
os.environ["FORCE_COLOR"] = "1"
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
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()
|
||||
199
docs/PIPELINE.md
Normal file
199
docs/PIPELINE.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Mainline Pipeline
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Sources (static/dynamic) → Fetch → Prepare → Scroll → Effects → Render → Display
|
||||
↓
|
||||
NtfyPoller ← MicMonitor (async)
|
||||
```
|
||||
|
||||
### Data Source Abstraction (sources_v2.py)
|
||||
|
||||
- **Static sources**: Data fetched once and cached (HeadlinesDataSource, PoetryDataSource)
|
||||
- **Dynamic sources**: Idempotent fetch for runtime updates (PipelineDataSource)
|
||||
- **SourceRegistry**: Discovery and management of data sources
|
||||
|
||||
### Camera Modes
|
||||
|
||||
- **Vertical**: Scroll up (default)
|
||||
- **Horizontal**: Scroll left
|
||||
- **Omni**: Diagonal scroll
|
||||
- **Floating**: Sinusoidal bobbing
|
||||
- **Trace**: Follow network path node-by-node (for pipeline viz)
|
||||
|
||||
## Content to Display Rendering Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["Data Sources (v2)"]
|
||||
Headlines[HeadlinesDataSource]
|
||||
Poetry[PoetryDataSource]
|
||||
Pipeline[PipelineDataSource]
|
||||
Registry[SourceRegistry]
|
||||
end
|
||||
|
||||
subgraph SourcesLegacy["Data Sources (legacy)"]
|
||||
RSS[("RSS Feeds")]
|
||||
PoetryFeed[("Poetry Feed")]
|
||||
Ntfy[("Ntfy Messages")]
|
||||
Mic[("Microphone")]
|
||||
end
|
||||
|
||||
subgraph Fetch["Fetch Layer"]
|
||||
FC[fetch_all]
|
||||
FP[fetch_poetry]
|
||||
Cache[(Cache)]
|
||||
end
|
||||
|
||||
subgraph Prepare["Prepare Layer"]
|
||||
MB[make_block]
|
||||
Strip[strip_tags]
|
||||
Trans[translate]
|
||||
end
|
||||
|
||||
subgraph Scroll["Scroll Engine"]
|
||||
SC[StreamController]
|
||||
CAM[Camera]
|
||||
RTZ[render_ticker_zone]
|
||||
Msg[render_message_overlay]
|
||||
Grad[lr_gradient]
|
||||
VT[vis_trunc / vis_offset]
|
||||
end
|
||||
|
||||
subgraph Effects["Effect Pipeline"]
|
||||
subgraph EffectsPlugins["Effect Plugins"]
|
||||
Noise[NoiseEffect]
|
||||
Fade[FadeEffect]
|
||||
Glitch[GlitchEffect]
|
||||
Firehose[FirehoseEffect]
|
||||
Hud[HudEffect]
|
||||
end
|
||||
EC[EffectChain]
|
||||
ER[EffectRegistry]
|
||||
end
|
||||
|
||||
subgraph Render["Render Layer"]
|
||||
BW[big_wrap]
|
||||
RL[render_line]
|
||||
end
|
||||
|
||||
subgraph Display["Display Backends"]
|
||||
TD[TerminalDisplay]
|
||||
PD[PygameDisplay]
|
||||
SD[SixelDisplay]
|
||||
KD[KittyDisplay]
|
||||
WSD[WebSocketDisplay]
|
||||
ND[NullDisplay]
|
||||
end
|
||||
|
||||
subgraph Async["Async Sources"]
|
||||
NTFY[NtfyPoller]
|
||||
MIC[MicMonitor]
|
||||
end
|
||||
|
||||
subgraph Animation["Animation System"]
|
||||
AC[AnimationController]
|
||||
PR[Preset]
|
||||
end
|
||||
|
||||
Sources --> Fetch
|
||||
RSS --> FC
|
||||
PoetryFeed --> FP
|
||||
FC --> Cache
|
||||
FP --> Cache
|
||||
Cache --> MB
|
||||
Strip --> MB
|
||||
Trans --> MB
|
||||
MB --> SC
|
||||
NTFY --> SC
|
||||
SC --> RTZ
|
||||
CAM --> RTZ
|
||||
Grad --> RTZ
|
||||
VT --> RTZ
|
||||
RTZ --> EC
|
||||
EC --> ER
|
||||
ER --> EffectsPlugins
|
||||
EffectsPlugins --> BW
|
||||
BW --> RL
|
||||
RL --> Display
|
||||
Ntfy --> RL
|
||||
Mic --> RL
|
||||
MIC --> RL
|
||||
|
||||
style Sources fill:#f9f,stroke:#333
|
||||
style Fetch fill:#bbf,stroke:#333
|
||||
style Prepare fill:#bff,stroke:#333
|
||||
style Scroll fill:#bfb,stroke:#333
|
||||
style Effects fill:#fbf,stroke:#333
|
||||
style Render fill:#ffb,stroke:#333
|
||||
style Display fill:#bbf,stroke:#333
|
||||
style Async fill:#fbb,stroke:#333
|
||||
style Animation fill:#bfb,stroke:#333
|
||||
```
|
||||
|
||||
## Animation & Presets
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Preset["Preset"]
|
||||
PP[PipelineParams]
|
||||
AC[AnimationController]
|
||||
end
|
||||
|
||||
subgraph AnimationController["AnimationController"]
|
||||
Clock[Clock]
|
||||
Events[Events]
|
||||
Triggers[Triggers]
|
||||
end
|
||||
|
||||
subgraph Triggers["Trigger Types"]
|
||||
TIME[TIME]
|
||||
FRAME[FRAME]
|
||||
CYCLE[CYCLE]
|
||||
COND[CONDITION]
|
||||
MANUAL[MANUAL]
|
||||
end
|
||||
|
||||
PP --> AC
|
||||
Clock --> AC
|
||||
Events --> AC
|
||||
Triggers --> Events
|
||||
```
|
||||
|
||||
## Camera Modes
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Vertical
|
||||
Vertical --> Horizontal: mode change
|
||||
Horizontal --> Omni: mode change
|
||||
Omni --> Floating: mode change
|
||||
Floating --> Trace: mode change
|
||||
Trace --> Vertical: mode change
|
||||
|
||||
state Vertical {
|
||||
[*] --> ScrollUp
|
||||
ScrollUp --> ScrollUp: +y each frame
|
||||
}
|
||||
|
||||
state Horizontal {
|
||||
[*] --> ScrollLeft
|
||||
ScrollLeft --> ScrollLeft: +x each frame
|
||||
}
|
||||
|
||||
state Omni {
|
||||
[*] --> Diagonal
|
||||
Diagonal --> Diagonal: +x, +y each frame
|
||||
}
|
||||
|
||||
state Floating {
|
||||
[*] --> Bobbing
|
||||
Bobbing --> Bobbing: sin(time) for x,y
|
||||
}
|
||||
|
||||
state Trace {
|
||||
[*] --> FollowPath
|
||||
FollowPath --> FollowPath: node by node
|
||||
}
|
||||
```
|
||||
38
effects_plugins/__init__.py
Normal file
38
effects_plugins/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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()
|
||||
if not isinstance(plugin, EffectPlugin):
|
||||
continue
|
||||
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, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
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, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
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, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
63
effects_plugins/hud.py
Normal file
63
effects_plugins/hud.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from engine.effects.performance import get_monitor
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class HudEffect(EffectPlugin):
|
||||
name = "hud"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
result = list(buf)
|
||||
monitor = get_monitor()
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
if stats and "pipeline" in stats:
|
||||
frame_time = stats["pipeline"].get("avg_ms", 0.0)
|
||||
frame_count = stats.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
|
||||
w = ctx.terminal_width
|
||||
h = ctx.terminal_height
|
||||
|
||||
effect_name = self.config.params.get("display_effect", "none")
|
||||
effect_intensity = self.config.params.get("display_intensity", 0.0)
|
||||
|
||||
hud_lines = []
|
||||
hud_lines.append(
|
||||
f"\033[1;1H\033[38;5;46mMAINLINE DEMO\033[0m \033[38;5;245m|\033[0m \033[38;5;39mFPS: {fps:.1f}\033[0m \033[38;5;245m|\033[0m \033[38;5;208m{frame_time:.1f}ms\033[0m"
|
||||
)
|
||||
|
||||
bar_width = 20
|
||||
filled = int(bar_width * effect_intensity)
|
||||
bar = (
|
||||
"\033[38;5;82m"
|
||||
+ "█" * filled
|
||||
+ "\033[38;5;240m"
|
||||
+ "░" * (bar_width - filled)
|
||||
+ "\033[0m"
|
||||
)
|
||||
hud_lines.append(
|
||||
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
|
||||
)
|
||||
|
||||
from engine.effects import get_effect_chain
|
||||
|
||||
chain = get_effect_chain()
|
||||
order = chain.get_order()
|
||||
pipeline_str = ",".join(order) if order else "(none)"
|
||||
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
|
||||
|
||||
for i, line in enumerate(hud_lines):
|
||||
if i < len(result):
|
||||
result[i] = line + result[i][len(line) :]
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
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, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
340
engine/animation.py
Normal file
340
engine/animation.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""
|
||||
Animation system - Clock, events, triggers, durations, and animation controller.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Clock:
|
||||
"""High-resolution clock for animation timing."""
|
||||
|
||||
def __init__(self):
|
||||
self._start_time = time.perf_counter()
|
||||
self._paused = False
|
||||
self._pause_offset = 0.0
|
||||
self._pause_start = 0.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self._start_time = time.perf_counter()
|
||||
self._paused = False
|
||||
self._pause_offset = 0.0
|
||||
self._pause_start = 0.0
|
||||
|
||||
def elapsed(self) -> float:
|
||||
if self._paused:
|
||||
return self._pause_start - self._start_time - self._pause_offset
|
||||
return time.perf_counter() - self._start_time - self._pause_offset
|
||||
|
||||
def elapsed_ms(self) -> float:
|
||||
return self.elapsed() * 1000
|
||||
|
||||
def elapsed_frames(self, fps: float = 60.0) -> int:
|
||||
return int(self.elapsed() * fps)
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._paused:
|
||||
self._paused = True
|
||||
self._pause_start = time.perf_counter()
|
||||
|
||||
def resume(self) -> None:
|
||||
if self._paused:
|
||||
self._pause_offset += time.perf_counter() - self._pause_start
|
||||
self._paused = False
|
||||
|
||||
|
||||
class TriggerType(Enum):
|
||||
TIME = auto() # Trigger after elapsed time
|
||||
FRAME = auto() # Trigger after N frames
|
||||
CYCLE = auto() # Trigger on cycle repeat
|
||||
CONDITION = auto() # Trigger when condition is met
|
||||
MANUAL = auto() # Trigger manually
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trigger:
|
||||
"""Event trigger configuration."""
|
||||
|
||||
type: TriggerType
|
||||
value: float | int = 0
|
||||
condition: Callable[["AnimationController"], bool] | None = None
|
||||
repeat: bool = False
|
||||
repeat_interval: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""An event with trigger, duration, and action."""
|
||||
|
||||
name: str
|
||||
trigger: Trigger
|
||||
action: Callable[["AnimationController", float], None]
|
||||
duration: float = 0.0
|
||||
ease: Callable[[float], float] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ease is None:
|
||||
self.ease = linear_ease
|
||||
|
||||
|
||||
def linear_ease(t: float) -> float:
|
||||
return t
|
||||
|
||||
|
||||
def ease_in_out(t: float) -> float:
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
def ease_out_bounce(t: float) -> float:
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2 / 2.75:
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
|
||||
class AnimationController:
|
||||
"""Controls animation parameters with clock and events."""
|
||||
|
||||
def __init__(self, fps: float = 60.0):
|
||||
self.clock = Clock()
|
||||
self.fps = fps
|
||||
self.frame = 0
|
||||
self._events: list[Event] = []
|
||||
self._active_events: dict[str, float] = {}
|
||||
self._params: dict[str, Any] = {}
|
||||
self._cycled = 0
|
||||
|
||||
def add_event(self, event: Event) -> "AnimationController":
|
||||
self._events.append(event)
|
||||
return self
|
||||
|
||||
def set_param(self, key: str, value: Any) -> None:
|
||||
self._params[key] = value
|
||||
|
||||
def get_param(self, key: str, default: Any = None) -> Any:
|
||||
return self._params.get(key, default)
|
||||
|
||||
def update(self) -> dict[str, Any]:
|
||||
"""Update animation state, return current params."""
|
||||
elapsed = self.clock.elapsed()
|
||||
|
||||
for event in self._events:
|
||||
triggered = False
|
||||
|
||||
if event.trigger.type == TriggerType.TIME:
|
||||
if self.clock.elapsed() >= event.trigger.value:
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.FRAME:
|
||||
if self.frame >= event.trigger.value:
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.CYCLE:
|
||||
cycle_duration = event.trigger.value
|
||||
if cycle_duration > 0:
|
||||
current_cycle = int(elapsed / cycle_duration)
|
||||
if current_cycle > self._cycled:
|
||||
self._cycled = current_cycle
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.CONDITION:
|
||||
if event.trigger.condition and event.trigger.condition(self):
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.MANUAL:
|
||||
pass
|
||||
|
||||
if triggered:
|
||||
if event.name not in self._active_events:
|
||||
self._active_events[event.name] = 0.0
|
||||
|
||||
progress = 0.0
|
||||
if event.duration > 0:
|
||||
self._active_events[event.name] += 1 / self.fps
|
||||
progress = min(
|
||||
1.0, self._active_events[event.name] / event.duration
|
||||
)
|
||||
eased_progress = event.ease(progress)
|
||||
event.action(self, eased_progress)
|
||||
|
||||
if progress >= 1.0:
|
||||
if event.trigger.repeat:
|
||||
self._active_events[event.name] = 0.0
|
||||
else:
|
||||
del self._active_events[event.name]
|
||||
else:
|
||||
event.action(self, 1.0)
|
||||
if not event.trigger.repeat:
|
||||
del self._active_events[event.name]
|
||||
else:
|
||||
self._active_events[event.name] = 0.0
|
||||
|
||||
self.frame += 1
|
||||
return dict(self._params)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
"""Snapshot of pipeline parameters for animation."""
|
||||
|
||||
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0
|
||||
camera_x: int = 0
|
||||
camera_y: int = 0
|
||||
display_backend: str = "terminal"
|
||||
scroll_speed: float = 1.0
|
||||
|
||||
|
||||
class Preset:
|
||||
"""Packages a starting pipeline config + Animation controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str = "",
|
||||
initial_params: PipelineParams | None = None,
|
||||
animation: AnimationController | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.initial_params = initial_params or PipelineParams()
|
||||
self.animation = animation or AnimationController()
|
||||
|
||||
def create_controller(self) -> AnimationController:
|
||||
controller = AnimationController()
|
||||
for key, value in self.initial_params.__dict__.items():
|
||||
controller.set_param(key, value)
|
||||
for event in self.animation._events:
|
||||
controller.add_event(event)
|
||||
return controller
|
||||
|
||||
|
||||
def create_demo_preset() -> Preset:
|
||||
"""Create the demo preset with effect cycling and camera modes."""
|
||||
animation = AnimationController(fps=60)
|
||||
|
||||
effects = ["noise", "fade", "glitch", "firehose"]
|
||||
camera_modes = ["vertical", "horizontal", "omni", "floating", "trace"]
|
||||
|
||||
def make_effect_action(eff):
|
||||
def action(ctrl, t):
|
||||
ctrl.set_param("current_effect", eff)
|
||||
ctrl.set_param("effect_intensity", t)
|
||||
|
||||
return action
|
||||
|
||||
def make_camera_action(cam_mode):
|
||||
def action(ctrl, t):
|
||||
ctrl.set_param("camera_mode", cam_mode)
|
||||
|
||||
return action
|
||||
|
||||
for i, effect in enumerate(effects):
|
||||
effect_duration = 5.0
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name=f"effect_{effect}",
|
||||
trigger=Trigger(
|
||||
type=TriggerType.TIME,
|
||||
value=i * effect_duration,
|
||||
repeat=True,
|
||||
repeat_interval=len(effects) * effect_duration,
|
||||
),
|
||||
duration=effect_duration,
|
||||
action=make_effect_action(effect),
|
||||
ease=ease_in_out,
|
||||
)
|
||||
)
|
||||
|
||||
for i, mode in enumerate(camera_modes):
|
||||
camera_duration = 10.0
|
||||
animation.add_event(
|
||||
Event(
|
||||
name=f"camera_{mode}",
|
||||
trigger=Trigger(
|
||||
type=TriggerType.TIME,
|
||||
value=i * camera_duration,
|
||||
repeat=True,
|
||||
repeat_interval=len(camera_modes) * camera_duration,
|
||||
),
|
||||
duration=0.5,
|
||||
action=make_camera_action(mode),
|
||||
)
|
||||
)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="pulse",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=2.0, repeat=True),
|
||||
duration=1.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("pulse", t),
|
||||
ease=ease_out_bounce,
|
||||
)
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
initial_params=PipelineParams(
|
||||
effect_enabled={
|
||||
"noise": False,
|
||||
"fade": False,
|
||||
"glitch": False,
|
||||
"firehose": False,
|
||||
"hud": True,
|
||||
},
|
||||
effect_intensity={
|
||||
"noise": 0.0,
|
||||
"fade": 0.0,
|
||||
"glitch": 0.0,
|
||||
"firehose": 0.0,
|
||||
},
|
||||
camera_mode="vertical",
|
||||
camera_speed=1.0,
|
||||
display_backend="pygame",
|
||||
),
|
||||
animation=animation,
|
||||
)
|
||||
|
||||
|
||||
def create_pipeline_preset() -> Preset:
|
||||
"""Create preset for pipeline visualization."""
|
||||
animation = AnimationController(fps=60)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="camera_trace",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=8.0, repeat=True),
|
||||
duration=8.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("camera_mode", "trace"),
|
||||
)
|
||||
)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="highlight_path",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=4.0, repeat=True),
|
||||
duration=4.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("path_progress", t),
|
||||
)
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name="pipeline",
|
||||
description="Pipeline visualization with trace camera",
|
||||
initial_params=PipelineParams(
|
||||
camera_mode="trace",
|
||||
camera_speed=1.0,
|
||||
display_backend="pygame",
|
||||
),
|
||||
animation=animation,
|
||||
)
|
||||
761
engine/app.py
761
engine/app.py
@@ -11,10 +11,8 @@ import time
|
||||
import tty
|
||||
|
||||
from engine import config, render
|
||||
from engine.controller import StreamController
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
from engine.terminal import (
|
||||
CLR,
|
||||
CURSOR_OFF,
|
||||
@@ -249,7 +247,621 @@ def pick_font_face():
|
||||
print()
|
||||
|
||||
|
||||
def pick_effects_config():
|
||||
"""Interactive picker for configuring effects pipeline."""
|
||||
import effects_plugins
|
||||
from engine.effects import get_effect_chain, get_registry
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
|
||||
effects = list(registry.list_all().values())
|
||||
if not effects:
|
||||
return
|
||||
|
||||
selected = 0
|
||||
editing_intensity = False
|
||||
intensity_value = 1.0
|
||||
|
||||
def _draw_effects_picker():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print("\033[1;1H", end="")
|
||||
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print()
|
||||
|
||||
for i, effect in enumerate(effects):
|
||||
prefix = " > " if i == selected else " "
|
||||
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||
if editing_intensity and i == selected:
|
||||
print(
|
||||
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||
print(
|
||||
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||
)
|
||||
|
||||
def _read_effects_key():
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x03":
|
||||
return "interrupt"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
if ch == " ":
|
||||
return "toggle"
|
||||
if ch == "q":
|
||||
return "quit"
|
||||
if ch == "+" or ch == "=":
|
||||
return "up"
|
||||
if ch == "-" or ch == "_":
|
||||
return "down"
|
||||
if ch == "\x1b":
|
||||
c1 = sys.stdin.read(1)
|
||||
if c1 != "[":
|
||||
return None
|
||||
c2 = sys.stdin.read(1)
|
||||
if c2 == "A":
|
||||
return "up"
|
||||
if c2 == "B":
|
||||
return "down"
|
||||
return None
|
||||
return None
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_effects_picker()
|
||||
key = _read_effects_key()
|
||||
|
||||
if key == "quit" or key == "enter":
|
||||
break
|
||||
elif key == "up" and editing_intensity:
|
||||
intensity_value = min(1.0, intensity_value + 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "down" and editing_intensity:
|
||||
intensity_value = max(0.0, intensity_value - 0.1)
|
||||
effects[selected].config.intensity = intensity_value
|
||||
elif key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "down":
|
||||
selected = min(len(effects) - 1, selected + 1)
|
||||
intensity_value = effects[selected].config.intensity
|
||||
elif key == "toggle":
|
||||
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
|
||||
def run_demo_mode():
|
||||
"""Run demo mode - showcases effects and camera modes with real content."""
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.camera import Camera, CameraMode
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.effects import (
|
||||
EffectContext,
|
||||
PerformanceMonitor,
|
||||
get_effect_chain,
|
||||
get_registry,
|
||||
set_monitor,
|
||||
)
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||
from engine.scroll import calculate_scroll_step
|
||||
|
||||
print(" \033[1;38;5;46mMAINLINE DEMO MODE\033[0m")
|
||||
print(" \033[38;5;245mInitializing...\033[0m")
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose", "hud"])
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
set_monitor(monitor)
|
||||
chain._monitor = monitor
|
||||
|
||||
display = DisplayRegistry.create("pygame")
|
||||
if not display:
|
||||
print(" \033[38;5;196mFailed to create pygame display\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
w, h = 80, 24
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
|
||||
print(" \033[38;5;245mFetching content...\033[0m")
|
||||
|
||||
cached = load_cache()
|
||||
if cached:
|
||||
items = cached
|
||||
elif config.MODE == "poetry":
|
||||
items, _, _ = fetch_poetry()
|
||||
else:
|
||||
items, _, _ = fetch_all()
|
||||
|
||||
if not items:
|
||||
print(" \033[38;5;196mNo content available\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
active = []
|
||||
ticker_next_y = 0
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
frame_number = 0
|
||||
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, h)
|
||||
|
||||
camera = Camera.vertical(speed=1.0)
|
||||
|
||||
effects_to_demo = ["noise", "fade", "glitch", "firehose"]
|
||||
effect_idx = 0
|
||||
effect_name = effects_to_demo[effect_idx]
|
||||
effect_start_time = time.time()
|
||||
current_intensity = 0.0
|
||||
ramping_up = True
|
||||
|
||||
camera_modes = [
|
||||
(CameraMode.VERTICAL, "vertical"),
|
||||
(CameraMode.HORIZONTAL, "horizontal"),
|
||||
(CameraMode.OMNI, "omni"),
|
||||
(CameraMode.FLOATING, "floating"),
|
||||
]
|
||||
camera_mode_idx = 0
|
||||
camera_start_time = time.time()
|
||||
|
||||
print(" \033[38;5;82mStarting effect & camera demo...\033[0m")
|
||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.time() - effect_start_time
|
||||
camera_elapsed = time.time() - camera_start_time
|
||||
duration = config.DEMO_EFFECT_DURATION
|
||||
|
||||
if elapsed >= duration:
|
||||
effect_idx = (effect_idx + 1) % len(effects_to_demo)
|
||||
effect_name = effects_to_demo[effect_idx]
|
||||
effect_start_time = time.time()
|
||||
elapsed = 0
|
||||
current_intensity = 0.0
|
||||
ramping_up = True
|
||||
|
||||
if camera_elapsed >= duration * 2:
|
||||
camera_mode_idx = (camera_mode_idx + 1) % len(camera_modes)
|
||||
mode, mode_name = camera_modes[camera_mode_idx]
|
||||
camera = Camera(mode=mode, speed=1.0)
|
||||
camera_start_time = time.time()
|
||||
camera_elapsed = 0
|
||||
|
||||
progress = elapsed / duration
|
||||
if ramping_up:
|
||||
current_intensity = progress
|
||||
if progress >= 1.0:
|
||||
ramping_up = False
|
||||
else:
|
||||
current_intensity = 1.0 - progress
|
||||
|
||||
for effect in registry.list_all().values():
|
||||
if effect.name == effect_name:
|
||||
effect.config.enabled = True
|
||||
effect.config.intensity = current_intensity
|
||||
elif effect.name not in ("hud",):
|
||||
effect.config.enabled = False
|
||||
|
||||
hud_effect = registry.get("hud")
|
||||
if hud_effect:
|
||||
mode_name = camera_modes[camera_mode_idx][1]
|
||||
hud_effect.config.params["display_effect"] = (
|
||||
f"{effect_name} / {mode_name}"
|
||||
)
|
||||
hud_effect.config.params["display_intensity"] = current_intensity
|
||||
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
camera.update(config.FRAME_DT)
|
||||
|
||||
while ticker_next_y < camera.y + h + 10 and len(active) < 50:
|
||||
from engine.effects import next_headline
|
||||
from engine.render import make_block
|
||||
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||
ticker_next_y += len(ticker_content) + GAP
|
||||
|
||||
active = [
|
||||
(c, hc, by, mi)
|
||||
for c, hc, by, mi in active
|
||||
if by + len(c) > camera.y
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < camera.y:
|
||||
del noise_cache[k]
|
||||
|
||||
grad_offset = (time.time() * config.GRAD_SPEED) % 1.0
|
||||
|
||||
from engine.layers import render_ticker_zone
|
||||
|
||||
buf, noise_cache = render_ticker_zone(
|
||||
active,
|
||||
scroll_cam=camera.y,
|
||||
camera_x=camera.x,
|
||||
ticker_h=h,
|
||||
w=w,
|
||||
noise_cache=noise_cache,
|
||||
grad_offset=grad_offset,
|
||||
)
|
||||
|
||||
from engine.layers import render_firehose
|
||||
|
||||
firehose_buf = render_firehose(items, w, 0, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=camera.y,
|
||||
ticker_height=h,
|
||||
camera_x=camera.x,
|
||||
mic_excess=0.0,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=False,
|
||||
items=items,
|
||||
)
|
||||
|
||||
result = chain.process(buf, ctx)
|
||||
display.show(result)
|
||||
|
||||
new_w, new_h = display.get_dimensions()
|
||||
if new_w != w or new_h != h:
|
||||
w, h = new_w, new_h
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, h)
|
||||
active = []
|
||||
noise_cache = {}
|
||||
|
||||
frame_number += 1
|
||||
time.sleep(1 / 60)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mDemo ended\033[0m")
|
||||
|
||||
|
||||
def run_pipeline_demo():
|
||||
"""Run pipeline visualization demo mode - shows ASCII pipeline animation."""
|
||||
import time
|
||||
|
||||
from engine import config
|
||||
from engine.camera import Camera, CameraMode
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.effects import (
|
||||
EffectContext,
|
||||
PerformanceMonitor,
|
||||
get_effect_chain,
|
||||
get_registry,
|
||||
set_monitor,
|
||||
)
|
||||
from engine.pipeline_viz import generate_large_network_viewport
|
||||
|
||||
print(" \033[1;38;5;46mMAINLINE PIPELINE DEMO\033[0m")
|
||||
print(" \033[38;5;245mInitializing...\033[0m")
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose", "hud"])
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
set_monitor(monitor)
|
||||
chain._monitor = monitor
|
||||
|
||||
display = DisplayRegistry.create("pygame")
|
||||
if not display:
|
||||
print(" \033[38;5;196mFailed to create pygame display\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
w, h = 80, 24
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
|
||||
camera = Camera.vertical(speed=1.0)
|
||||
|
||||
effects_to_demo = ["noise", "fade", "glitch", "firehose"]
|
||||
effect_idx = 0
|
||||
effect_name = effects_to_demo[effect_idx]
|
||||
effect_start_time = time.time()
|
||||
current_intensity = 0.0
|
||||
ramping_up = True
|
||||
|
||||
camera_modes = [
|
||||
(CameraMode.VERTICAL, "vertical"),
|
||||
(CameraMode.HORIZONTAL, "horizontal"),
|
||||
(CameraMode.OMNI, "omni"),
|
||||
(CameraMode.FLOATING, "floating"),
|
||||
]
|
||||
camera_mode_idx = 0
|
||||
camera_start_time = time.time()
|
||||
|
||||
frame_number = 0
|
||||
|
||||
print(" \033[38;5;82mStarting pipeline visualization...\033[0m")
|
||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
elapsed = time.time() - effect_start_time
|
||||
camera_elapsed = time.time() - camera_start_time
|
||||
duration = config.DEMO_EFFECT_DURATION
|
||||
|
||||
if elapsed >= duration:
|
||||
effect_idx = (effect_idx + 1) % len(effects_to_demo)
|
||||
effect_name = effects_to_demo[effect_idx]
|
||||
effect_start_time = time.time()
|
||||
elapsed = 0
|
||||
current_intensity = 0.0
|
||||
ramping_up = True
|
||||
|
||||
if camera_elapsed >= duration * 2:
|
||||
camera_mode_idx = (camera_mode_idx + 1) % len(camera_modes)
|
||||
mode, mode_name = camera_modes[camera_mode_idx]
|
||||
camera = Camera(mode=mode, speed=1.0)
|
||||
camera_start_time = time.time()
|
||||
camera_elapsed = 0
|
||||
|
||||
progress = elapsed / duration
|
||||
if ramping_up:
|
||||
current_intensity = progress
|
||||
if progress >= 1.0:
|
||||
ramping_up = False
|
||||
else:
|
||||
current_intensity = 1.0 - progress
|
||||
|
||||
for effect in registry.list_all().values():
|
||||
if effect.name == effect_name:
|
||||
effect.config.enabled = True
|
||||
effect.config.intensity = current_intensity
|
||||
elif effect.name not in ("hud",):
|
||||
effect.config.enabled = False
|
||||
|
||||
hud_effect = registry.get("hud")
|
||||
if hud_effect:
|
||||
mode_name = camera_modes[camera_mode_idx][1]
|
||||
hud_effect.config.params["display_effect"] = (
|
||||
f"{effect_name} / {mode_name}"
|
||||
)
|
||||
hud_effect.config.params["display_intensity"] = current_intensity
|
||||
|
||||
camera.update(config.FRAME_DT)
|
||||
|
||||
buf = generate_large_network_viewport(w, h, frame_number)
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=camera.y,
|
||||
ticker_height=h,
|
||||
camera_x=camera.x,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=frame_number,
|
||||
has_message=False,
|
||||
items=[],
|
||||
)
|
||||
|
||||
result = chain.process(buf, ctx)
|
||||
display.show(result)
|
||||
|
||||
new_w, new_h = display.get_dimensions()
|
||||
if new_w != w or new_h != h:
|
||||
w, h = new_w, new_h
|
||||
|
||||
frame_number += 1
|
||||
time.sleep(1 / 60)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mPipeline demo ended\033[0m")
|
||||
|
||||
|
||||
def run_preset_mode(preset_name: str):
|
||||
"""Run mode using animation presets."""
|
||||
from engine import config
|
||||
from engine.animation import (
|
||||
create_demo_preset,
|
||||
create_pipeline_preset,
|
||||
)
|
||||
from engine.camera import Camera
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.effects import (
|
||||
EffectContext,
|
||||
PerformanceMonitor,
|
||||
get_effect_chain,
|
||||
get_registry,
|
||||
set_monitor,
|
||||
)
|
||||
from engine.sources_v2 import (
|
||||
PipelineDataSource,
|
||||
get_source_registry,
|
||||
init_default_sources,
|
||||
)
|
||||
|
||||
w, h = 80, 24
|
||||
|
||||
if preset_name == "demo":
|
||||
preset = create_demo_preset()
|
||||
init_default_sources()
|
||||
source = get_source_registry().default()
|
||||
elif preset_name == "pipeline":
|
||||
preset = create_pipeline_preset()
|
||||
source = PipelineDataSource(w, h)
|
||||
else:
|
||||
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
||||
print(" Available: demo, pipeline")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" \033[1;38;5;46mMAINLINE PRESET: {preset.name}\033[0m")
|
||||
print(f" \033[38;5;245m{preset.description}\033[0m")
|
||||
print(" \033[38;5;245mInitializing...\033[0m")
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
registry = get_registry()
|
||||
chain = get_effect_chain()
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose", "hud"])
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
set_monitor(monitor)
|
||||
chain._monitor = monitor
|
||||
|
||||
display = DisplayRegistry.create(preset.initial_params.display_backend)
|
||||
if not display:
|
||||
print(
|
||||
f" \033[38;5;196mFailed to create {preset.initial_params.display_backend} display\033[0m"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
|
||||
camera = Camera.vertical()
|
||||
|
||||
print(" \033[38;5;82mStarting preset animation...\033[0m")
|
||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||
|
||||
controller = preset.create_controller()
|
||||
frame_number = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
params = controller.update()
|
||||
|
||||
effect_name = params.get("current_effect", "none")
|
||||
intensity = params.get("effect_intensity", 0.0)
|
||||
camera_mode = params.get("camera_mode", "vertical")
|
||||
|
||||
if camera_mode == "vertical":
|
||||
camera = Camera.vertical(speed=params.get("camera_speed", 1.0))
|
||||
elif camera_mode == "horizontal":
|
||||
camera = Camera.horizontal(speed=params.get("camera_speed", 1.0))
|
||||
elif camera_mode == "omni":
|
||||
camera = Camera.omni(speed=params.get("camera_speed", 1.0))
|
||||
elif camera_mode == "floating":
|
||||
camera = Camera.floating(speed=params.get("camera_speed", 1.0))
|
||||
|
||||
camera.update(config.FRAME_DT)
|
||||
|
||||
for eff in registry.list_all().values():
|
||||
if eff.name == effect_name:
|
||||
eff.config.enabled = True
|
||||
eff.config.intensity = intensity
|
||||
elif eff.name not in ("hud",):
|
||||
eff.config.enabled = False
|
||||
|
||||
hud_effect = registry.get("hud")
|
||||
if hud_effect:
|
||||
hud_effect.config.params["display_effect"] = (
|
||||
f"{effect_name} / {camera_mode}"
|
||||
)
|
||||
hud_effect.config.params["display_intensity"] = intensity
|
||||
|
||||
source.viewport_width = w
|
||||
source.viewport_height = h
|
||||
items = source.get_items()
|
||||
buffer = items[0].content.split("\n") if items else [""] * h
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=camera.y,
|
||||
ticker_height=h,
|
||||
camera_x=camera.x,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=frame_number,
|
||||
has_message=False,
|
||||
items=[],
|
||||
)
|
||||
|
||||
result = chain.process(buffer, ctx)
|
||||
display.show(result)
|
||||
|
||||
new_w, new_h = display.get_dimensions()
|
||||
if new_w != w or new_h != h:
|
||||
w, h = new_w, new_h
|
||||
|
||||
frame_number += 1
|
||||
time.sleep(1 / 60)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mPreset ended\033[0m")
|
||||
|
||||
|
||||
def main():
|
||||
from engine import config
|
||||
|
||||
if config.PIPELINE_DIAGRAM:
|
||||
from engine.pipeline import generate_pipeline_diagram
|
||||
|
||||
print(generate_pipeline_diagram())
|
||||
return
|
||||
|
||||
if config.PIPELINE_MODE:
|
||||
run_pipeline_mode(config.PIPELINE_PRESET)
|
||||
return
|
||||
|
||||
if config.PIPELINE_DEMO:
|
||||
run_pipeline_demo()
|
||||
return
|
||||
|
||||
if config.PRESET:
|
||||
run_preset_mode(config.PRESET)
|
||||
return
|
||||
|
||||
if config.DEMO:
|
||||
run_demo_mode()
|
||||
return
|
||||
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
|
||||
def handle_sigint(*_):
|
||||
@@ -259,10 +871,13 @@ def main():
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
StreamController.warmup_topics()
|
||||
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
pick_effects_config()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
@@ -314,9 +929,10 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||
mic_ok = mic.start()
|
||||
if mic.available:
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
if controller.mic and controller.mic.available:
|
||||
boot_ln(
|
||||
"Microphone",
|
||||
"ACTIVE"
|
||||
@@ -325,12 +941,6 @@ def main():
|
||||
bool(mic_ok),
|
||||
)
|
||||
|
||||
ntfy = NtfyPoller(
|
||||
config.NTFY_TOPIC,
|
||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||
)
|
||||
ntfy_ok = ntfy.start()
|
||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||
|
||||
if config.FIREHOSE:
|
||||
@@ -343,10 +953,135 @@ def main():
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
stream(items, ntfy, mic)
|
||||
controller.run(items)
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||
print(f" {W_GHOST}> end of stream{RST}")
|
||||
print()
|
||||
|
||||
|
||||
def run_pipeline_mode(preset_name: str = "demo"):
|
||||
"""Run using the new unified pipeline architecture."""
|
||||
import effects_plugins
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.effects import get_registry
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||
from engine.pipeline import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
get_preset,
|
||||
)
|
||||
from engine.pipeline.adapters import (
|
||||
RenderStage,
|
||||
create_items_stage,
|
||||
create_stage_from_display,
|
||||
create_stage_from_effect,
|
||||
)
|
||||
|
||||
print(" \033[1;38;5;46mPIPELINE MODE\033[0m")
|
||||
print(" \033[38;5;245mUsing unified pipeline architecture\033[0m")
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
preset = get_preset(preset_name)
|
||||
if not preset:
|
||||
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" \033[38;5;245mPreset: {preset.name} - {preset.description}\033[0m")
|
||||
|
||||
params = preset.to_params()
|
||||
params.viewport_width = 80
|
||||
params.viewport_height = 24
|
||||
|
||||
pipeline = Pipeline(
|
||||
config=PipelineConfig(
|
||||
source=preset.source,
|
||||
display=preset.display,
|
||||
camera=preset.camera,
|
||||
effects=preset.effects,
|
||||
)
|
||||
)
|
||||
|
||||
print(" \033[38;5;245mFetching content...\033[0m")
|
||||
cached = load_cache()
|
||||
if cached:
|
||||
items = cached
|
||||
elif preset.source == "poetry":
|
||||
items, _, _ = fetch_poetry()
|
||||
else:
|
||||
items, _, _ = fetch_all()
|
||||
|
||||
if not items:
|
||||
print(" \033[38;5;196mNo content available\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
||||
|
||||
display = DisplayRegistry.create(preset.display)
|
||||
if not display:
|
||||
print(f" \033[38;5;196mFailed to create display: {preset.display}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
display.init(80, 24)
|
||||
|
||||
effect_registry = get_registry()
|
||||
|
||||
pipeline.add_stage("source", create_items_stage(items, preset.source))
|
||||
pipeline.add_stage(
|
||||
"render",
|
||||
RenderStage(
|
||||
items,
|
||||
width=80,
|
||||
height=24,
|
||||
camera_speed=params.camera_speed,
|
||||
camera_mode=preset.camera,
|
||||
firehose_enabled=params.firehose_enabled,
|
||||
),
|
||||
)
|
||||
|
||||
for effect_name in preset.effects:
|
||||
effect = effect_registry.get(effect_name)
|
||||
if effect:
|
||||
pipeline.add_stage(
|
||||
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||
)
|
||||
|
||||
pipeline.add_stage("display", create_stage_from_display(display, preset.display))
|
||||
|
||||
pipeline.build()
|
||||
|
||||
if not pipeline.initialize():
|
||||
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||
|
||||
ctx = pipeline.context
|
||||
ctx.params = params
|
||||
ctx.set("display", display)
|
||||
ctx.set("items", items)
|
||||
ctx.set("pipeline", pipeline)
|
||||
|
||||
try:
|
||||
frame = 0
|
||||
while True:
|
||||
params.frame_number = frame
|
||||
ctx.params = params
|
||||
|
||||
result = pipeline.execute(items)
|
||||
if result.success:
|
||||
display.show(result.data)
|
||||
|
||||
time.sleep(1 / 60)
|
||||
frame += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
pipeline.cleanup()
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||
|
||||
4107
engine/beautiful_mermaid.py
Normal file
4107
engine/beautiful_mermaid.py
Normal file
File diff suppressed because it is too large
Load Diff
730
engine/benchmark.py
Normal file
730
engine/benchmark.py
Normal file
@@ -0,0 +1,730 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark runner for mainline - tests performance across effects and displays.
|
||||
|
||||
Usage:
|
||||
python -m engine.benchmark
|
||||
python -m engine.benchmark --output report.md
|
||||
python -m engine.benchmark --displays terminal,websocket --effects glitch,fade
|
||||
python -m engine.benchmark --format json --output benchmark.json
|
||||
|
||||
Headless mode (default): suppress all terminal output during benchmarks.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Result of a single benchmark run."""
|
||||
|
||||
name: str
|
||||
display: str
|
||||
effect: str | None
|
||||
iterations: int
|
||||
total_time_ms: float
|
||||
avg_time_ms: float
|
||||
std_dev_ms: float
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
fps: float
|
||||
chars_processed: int
|
||||
chars_per_sec: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkReport:
|
||||
"""Complete benchmark report."""
|
||||
|
||||
timestamp: str
|
||||
python_version: str
|
||||
results: list[BenchmarkResult] = field(default_factory=list)
|
||||
summary: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def get_sample_buffer(width: int = 80, height: int = 24) -> list[str]:
|
||||
"""Generate a sample buffer for benchmarking."""
|
||||
lines = []
|
||||
for i in range(height):
|
||||
line = f"\x1b[32mLine {i}\x1b[0m " + "A" * (width - 10)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def benchmark_display(
|
||||
display_class,
|
||||
buffer: list[str],
|
||||
iterations: int = 100,
|
||||
display=None,
|
||||
reuse: bool = False,
|
||||
) -> BenchmarkResult | None:
|
||||
"""Benchmark a single display.
|
||||
|
||||
Args:
|
||||
display_class: Display class to instantiate
|
||||
buffer: Buffer to display
|
||||
iterations: Number of iterations
|
||||
display: Optional existing display instance to reuse
|
||||
reuse: If True and display provided, use reuse mode
|
||||
"""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
try:
|
||||
sys.stdout = StringIO()
|
||||
sys.stderr = StringIO()
|
||||
|
||||
if display is None:
|
||||
display = display_class()
|
||||
display.init(80, 24, reuse=False)
|
||||
should_cleanup = True
|
||||
else:
|
||||
should_cleanup = False
|
||||
|
||||
times = []
|
||||
chars = sum(len(line) for line in buffer)
|
||||
|
||||
for _ in range(iterations):
|
||||
t0 = time.perf_counter()
|
||||
display.show(buffer)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
if should_cleanup and hasattr(display, "cleanup"):
|
||||
display.cleanup(quit_pygame=False)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
times_arr = np.array(times)
|
||||
|
||||
return BenchmarkResult(
|
||||
name=f"display_{display_class.__name__}",
|
||||
display=display_class.__name__,
|
||||
effect=None,
|
||||
iterations=iterations,
|
||||
total_time_ms=sum(times),
|
||||
avg_time_ms=float(np.mean(times_arr)),
|
||||
std_dev_ms=float(np.std(times_arr)),
|
||||
min_ms=float(np.min(times_arr)),
|
||||
max_ms=float(np.max(times_arr)),
|
||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||
chars_processed=chars * iterations,
|
||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||
if sum(times) > 0
|
||||
else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def benchmark_effect_with_display(
|
||||
effect_class, display, buffer: list[str], iterations: int = 100, reuse: bool = False
|
||||
) -> BenchmarkResult | None:
|
||||
"""Benchmark an effect with a display.
|
||||
|
||||
Args:
|
||||
effect_class: Effect class to instantiate
|
||||
display: Display instance to use
|
||||
buffer: Buffer to process and display
|
||||
iterations: Number of iterations
|
||||
reuse: If True, use reuse mode for display
|
||||
"""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
try:
|
||||
from engine.effects.types import EffectConfig, EffectContext
|
||||
|
||||
sys.stdout = StringIO()
|
||||
sys.stderr = StringIO()
|
||||
|
||||
effect = effect_class()
|
||||
effect.configure(EffectConfig(enabled=True, intensity=1.0))
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=0,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
|
||||
times = []
|
||||
chars = sum(len(line) for line in buffer)
|
||||
|
||||
for _ in range(iterations):
|
||||
processed = effect.process(buffer, ctx)
|
||||
t0 = time.perf_counter()
|
||||
display.show(processed)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
if not reuse and hasattr(display, "cleanup"):
|
||||
display.cleanup(quit_pygame=False)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
times_arr = np.array(times)
|
||||
|
||||
return BenchmarkResult(
|
||||
name=f"effect_{effect_class.__name__}_with_{display.__class__.__name__}",
|
||||
display=display.__class__.__name__,
|
||||
effect=effect_class.__name__,
|
||||
iterations=iterations,
|
||||
total_time_ms=sum(times),
|
||||
avg_time_ms=float(np.mean(times_arr)),
|
||||
std_dev_ms=float(np.std(times_arr)),
|
||||
min_ms=float(np.min(times_arr)),
|
||||
max_ms=float(np.max(times_arr)),
|
||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||
chars_processed=chars * iterations,
|
||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||
if sum(times) > 0
|
||||
else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def get_available_displays():
|
||||
"""Get available display classes."""
|
||||
from engine.display import (
|
||||
DisplayRegistry,
|
||||
NullDisplay,
|
||||
TerminalDisplay,
|
||||
)
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
displays = [
|
||||
("null", NullDisplay),
|
||||
("terminal", TerminalDisplay),
|
||||
]
|
||||
|
||||
try:
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
displays.append(("websocket", WebSocketDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
displays.append(("sixel", SixelDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
|
||||
displays.append(("pygame", PygameDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return displays
|
||||
|
||||
|
||||
def get_available_effects():
|
||||
"""Get available effect classes."""
|
||||
try:
|
||||
from engine.effects import get_registry
|
||||
|
||||
try:
|
||||
from effects_plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
effects = []
|
||||
registry = get_registry()
|
||||
|
||||
for name, effect in registry.list_all().items():
|
||||
if effect:
|
||||
effect_cls = type(effect)
|
||||
effects.append((name, effect_cls))
|
||||
|
||||
return effects
|
||||
|
||||
|
||||
def run_benchmarks(
|
||||
displays: list[tuple[str, Any]] | None = None,
|
||||
effects: list[tuple[str, Any]] | None = None,
|
||||
iterations: int = 100,
|
||||
verbose: bool = False,
|
||||
) -> BenchmarkReport:
|
||||
"""Run all benchmarks and return report."""
|
||||
from datetime import datetime
|
||||
|
||||
if displays is None:
|
||||
displays = get_available_displays()
|
||||
|
||||
if effects is None:
|
||||
effects = get_available_effects()
|
||||
|
||||
buffer = get_sample_buffer(80, 24)
|
||||
results = []
|
||||
|
||||
if verbose:
|
||||
print(f"Running benchmarks ({iterations} iterations each)...")
|
||||
|
||||
pygame_display = None
|
||||
for name, display_class in displays:
|
||||
if verbose:
|
||||
print(f"Benchmarking display: {name}")
|
||||
|
||||
result = benchmark_display(display_class, buffer, iterations)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||
|
||||
if name == "pygame":
|
||||
pygame_display = result
|
||||
|
||||
if verbose:
|
||||
print()
|
||||
|
||||
pygame_instance = None
|
||||
if pygame_display:
|
||||
try:
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
|
||||
PygameDisplay.reset_state()
|
||||
pygame_instance = PygameDisplay()
|
||||
pygame_instance.init(80, 24, reuse=False)
|
||||
except Exception:
|
||||
pygame_instance = None
|
||||
|
||||
for effect_name, effect_class in effects:
|
||||
for display_name, display_class in displays:
|
||||
if display_name == "websocket":
|
||||
continue
|
||||
|
||||
if display_name == "pygame":
|
||||
if verbose:
|
||||
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
||||
|
||||
if pygame_instance:
|
||||
result = benchmark_effect_with_display(
|
||||
effect_class, pygame_instance, buffer, iterations, reuse=True
|
||||
)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(
|
||||
f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg"
|
||||
)
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
||||
|
||||
display = display_class()
|
||||
display.init(80, 24)
|
||||
result = benchmark_effect_with_display(
|
||||
effect_class, display, buffer, iterations
|
||||
)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||
|
||||
if pygame_instance:
|
||||
try:
|
||||
pygame_instance.cleanup(quit_pygame=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
summary = generate_summary(results)
|
||||
|
||||
return BenchmarkReport(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
python_version=sys.version,
|
||||
results=results,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def generate_summary(results: list[BenchmarkResult]) -> dict[str, Any]:
|
||||
"""Generate summary statistics from results."""
|
||||
by_display: dict[str, list[BenchmarkResult]] = {}
|
||||
by_effect: dict[str, list[BenchmarkResult]] = {}
|
||||
|
||||
for r in results:
|
||||
if r.display not in by_display:
|
||||
by_display[r.display] = []
|
||||
by_display[r.display].append(r)
|
||||
|
||||
if r.effect:
|
||||
if r.effect not in by_effect:
|
||||
by_effect[r.effect] = []
|
||||
by_effect[r.effect].append(r)
|
||||
|
||||
summary = {
|
||||
"by_display": {},
|
||||
"by_effect": {},
|
||||
"overall": {
|
||||
"total_tests": len(results),
|
||||
"displays_tested": len(by_display),
|
||||
"effects_tested": len(by_effect),
|
||||
},
|
||||
}
|
||||
|
||||
for display, res in by_display.items():
|
||||
fps_values = [r.fps for r in res]
|
||||
summary["by_display"][display] = {
|
||||
"avg_fps": float(np.mean(fps_values)),
|
||||
"min_fps": float(np.min(fps_values)),
|
||||
"max_fps": float(np.max(fps_values)),
|
||||
"tests": len(res),
|
||||
}
|
||||
|
||||
for effect, res in by_effect.items():
|
||||
fps_values = [r.fps for r in res]
|
||||
summary["by_effect"][effect] = {
|
||||
"avg_fps": float(np.mean(fps_values)),
|
||||
"min_fps": float(np.min(fps_values)),
|
||||
"max_fps": float(np.max(fps_values)),
|
||||
"tests": len(res),
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
DEFAULT_CACHE_PATH = Path.home() / ".mainline_benchmark_cache.json"
|
||||
|
||||
|
||||
def load_baseline(cache_path: Path | None = None) -> dict[str, Any] | None:
|
||||
"""Load baseline benchmark results from cache."""
|
||||
path = cache_path or DEFAULT_CACHE_PATH
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_baseline(
|
||||
results: list[BenchmarkResult],
|
||||
cache_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Save benchmark results as baseline to cache."""
|
||||
path = cache_path or DEFAULT_CACHE_PATH
|
||||
baseline = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"results": {
|
||||
r.name: {
|
||||
"fps": r.fps,
|
||||
"avg_time_ms": r.avg_time_ms,
|
||||
"chars_per_sec": r.chars_per_sec,
|
||||
}
|
||||
for r in results
|
||||
},
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(baseline, f, indent=2)
|
||||
|
||||
|
||||
def compare_with_baseline(
|
||||
results: list[BenchmarkResult],
|
||||
baseline: dict[str, Any],
|
||||
threshold: float = 0.2,
|
||||
verbose: bool = True,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Compare current results with baseline. Returns (pass, messages)."""
|
||||
baseline_results = baseline.get("results", {})
|
||||
failures = []
|
||||
warnings = []
|
||||
|
||||
for r in results:
|
||||
if r.name not in baseline_results:
|
||||
warnings.append(f"New test: {r.name} (no baseline)")
|
||||
continue
|
||||
|
||||
b = baseline_results[r.name]
|
||||
if b["fps"] == 0:
|
||||
continue
|
||||
|
||||
degradation = (b["fps"] - r.fps) / b["fps"]
|
||||
if degradation > threshold:
|
||||
failures.append(
|
||||
f"{r.name}: FPS degraded {degradation * 100:.1f}% "
|
||||
f"(baseline: {b['fps']:.1f}, current: {r.fps:.1f})"
|
||||
)
|
||||
elif verbose:
|
||||
print(f" {r.name}: {r.fps:.1f} FPS (baseline: {b['fps']:.1f})")
|
||||
|
||||
passed = len(failures) == 0
|
||||
messages = []
|
||||
if failures:
|
||||
messages.extend(failures)
|
||||
if warnings:
|
||||
messages.extend(warnings)
|
||||
|
||||
return passed, messages
|
||||
|
||||
|
||||
def run_hook_mode(
|
||||
displays: list[tuple[str, Any]] | None = None,
|
||||
effects: list[tuple[str, Any]] | None = None,
|
||||
iterations: int = 20,
|
||||
threshold: float = 0.2,
|
||||
cache_path: Path | None = None,
|
||||
verbose: bool = False,
|
||||
) -> int:
|
||||
"""Run in hook mode: compare against baseline, exit 0 on pass, 1 on fail."""
|
||||
baseline = load_baseline(cache_path)
|
||||
|
||||
if baseline is None:
|
||||
print("No baseline found. Run with --baseline to create one.")
|
||||
return 1
|
||||
|
||||
report = run_benchmarks(displays, effects, iterations, verbose)
|
||||
|
||||
passed, messages = compare_with_baseline(
|
||||
report.results, baseline, threshold, verbose
|
||||
)
|
||||
|
||||
print("\n=== Benchmark Hook Results ===")
|
||||
if passed:
|
||||
print("PASSED - No significant performance degradation")
|
||||
return 0
|
||||
else:
|
||||
print("FAILED - Performance degradation detected:")
|
||||
for msg in messages:
|
||||
print(f" - {msg}")
|
||||
return 1
|
||||
|
||||
|
||||
def format_report_text(report: BenchmarkReport) -> str:
|
||||
"""Format report as human-readable text."""
|
||||
lines = [
|
||||
"# Mainline Performance Benchmark Report",
|
||||
"",
|
||||
f"Generated: {report.timestamp}",
|
||||
f"Python: {report.python_version}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"Total tests: {report.summary['overall']['total_tests']}",
|
||||
f"Displays tested: {report.summary['overall']['displays_tested']}",
|
||||
f"Effects tested: {report.summary['overall']['effects_tested']}",
|
||||
"",
|
||||
"## By Display",
|
||||
"",
|
||||
]
|
||||
|
||||
for display, stats in report.summary["by_display"].items():
|
||||
lines.append(f"### {display}")
|
||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||
lines.append(f"- Tests: {stats['tests']}")
|
||||
lines.append("")
|
||||
|
||||
if report.summary["by_effect"]:
|
||||
lines.append("## By Effect")
|
||||
lines.append("")
|
||||
|
||||
for effect, stats in report.summary["by_effect"].items():
|
||||
lines.append(f"### {effect}")
|
||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||
lines.append(f"- Tests: {stats['tests']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Detailed Results")
|
||||
lines.append("")
|
||||
lines.append("| Display | Effect | FPS | Avg ms | StdDev ms | Min ms | Max ms |")
|
||||
lines.append("|---------|--------|-----|--------|-----------|--------|--------|")
|
||||
|
||||
for r in report.results:
|
||||
effect_col = r.effect if r.effect else "-"
|
||||
lines.append(
|
||||
f"| {r.display} | {effect_col} | {r.fps:.1f} | {r.avg_time_ms:.2f} | "
|
||||
f"{r.std_dev_ms:.2f} | {r.min_ms:.2f} | {r.max_ms:.2f} |"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_report_json(report: BenchmarkReport) -> str:
|
||||
"""Format report as JSON."""
|
||||
data = {
|
||||
"timestamp": report.timestamp,
|
||||
"python_version": report.python_version,
|
||||
"summary": report.summary,
|
||||
"results": [
|
||||
{
|
||||
"name": r.name,
|
||||
"display": r.display,
|
||||
"effect": r.effect,
|
||||
"iterations": r.iterations,
|
||||
"total_time_ms": r.total_time_ms,
|
||||
"avg_time_ms": r.avg_time_ms,
|
||||
"std_dev_ms": r.std_dev_ms,
|
||||
"min_ms": r.min_ms,
|
||||
"max_ms": r.max_ms,
|
||||
"fps": r.fps,
|
||||
"chars_processed": r.chars_processed,
|
||||
"chars_per_sec": r.chars_per_sec,
|
||||
}
|
||||
for r in report.results
|
||||
],
|
||||
}
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run mainline benchmarks")
|
||||
parser.add_argument(
|
||||
"--displays",
|
||||
help="Comma-separated list of displays to test (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--effects",
|
||||
help="Comma-separated list of effects to test (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of iterations per test (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="Show progress during benchmarking",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode: compare against baseline, exit 0 pass, 1 fail",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
action="store_true",
|
||||
help="Save current results as baseline for future hook comparisons",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=0.2,
|
||||
help="Performance degradation threshold for hook mode (default: 0.2 = 20%%)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to baseline cache file (default: ~/.mainline_benchmark_cache.json)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
cache_path = Path(args.cache) if args.cache else DEFAULT_CACHE_PATH
|
||||
|
||||
if args.hook:
|
||||
displays = None
|
||||
if args.displays:
|
||||
display_map = dict(get_available_displays())
|
||||
displays = [
|
||||
(name, display_map[name])
|
||||
for name in args.displays.split(",")
|
||||
if name in display_map
|
||||
]
|
||||
|
||||
effects = None
|
||||
if args.effects:
|
||||
effect_map = dict(get_available_effects())
|
||||
effects = [
|
||||
(name, effect_map[name])
|
||||
for name in args.effects.split(",")
|
||||
if name in effect_map
|
||||
]
|
||||
|
||||
return run_hook_mode(
|
||||
displays,
|
||||
effects,
|
||||
iterations=args.iterations,
|
||||
threshold=args.threshold,
|
||||
cache_path=cache_path,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
displays = None
|
||||
if args.displays:
|
||||
display_map = dict(get_available_displays())
|
||||
displays = [
|
||||
(name, display_map[name])
|
||||
for name in args.displays.split(",")
|
||||
if name in display_map
|
||||
]
|
||||
|
||||
effects = None
|
||||
if args.effects:
|
||||
effect_map = dict(get_available_effects())
|
||||
effects = [
|
||||
(name, effect_map[name])
|
||||
for name in args.effects.split(",")
|
||||
if name in effect_map
|
||||
]
|
||||
|
||||
report = run_benchmarks(displays, effects, args.iterations, args.verbose)
|
||||
|
||||
if args.baseline:
|
||||
save_baseline(report.results, cache_path)
|
||||
print(f"Baseline saved to {cache_path}")
|
||||
return 0
|
||||
|
||||
if args.format == "json":
|
||||
output = format_report_json(report)
|
||||
else:
|
||||
output = format_report_text(report)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
109
engine/camera.py
Normal file
109
engine/camera.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Camera system for viewport scrolling.
|
||||
|
||||
Provides abstraction for camera motion in different modes:
|
||||
- Vertical: traditional upward scroll
|
||||
- Horizontal: left/right movement
|
||||
- Omni: combination of both
|
||||
- Floating: sinusoidal/bobbing motion
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class CameraMode(Enum):
|
||||
VERTICAL = auto()
|
||||
HORIZONTAL = auto()
|
||||
OMNI = auto()
|
||||
FLOATING = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Camera:
|
||||
"""Camera for viewport scrolling.
|
||||
|
||||
Attributes:
|
||||
x: Current horizontal offset (positive = scroll left)
|
||||
y: Current vertical offset (positive = scroll up)
|
||||
mode: Current camera mode
|
||||
speed: Base scroll speed
|
||||
custom_update: Optional custom update function
|
||||
"""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
mode: CameraMode = CameraMode.VERTICAL
|
||||
speed: float = 1.0
|
||||
custom_update: Callable[["Camera", float], None] | None = None
|
||||
_time: float = field(default=0.0, repr=False)
|
||||
|
||||
def update(self, dt: float) -> None:
|
||||
"""Update camera position based on mode.
|
||||
|
||||
Args:
|
||||
dt: Delta time in seconds
|
||||
"""
|
||||
self._time += dt
|
||||
|
||||
if self.custom_update:
|
||||
self.custom_update(self, dt)
|
||||
return
|
||||
|
||||
if self.mode == CameraMode.VERTICAL:
|
||||
self._update_vertical(dt)
|
||||
elif self.mode == CameraMode.HORIZONTAL:
|
||||
self._update_horizontal(dt)
|
||||
elif self.mode == CameraMode.OMNI:
|
||||
self._update_omni(dt)
|
||||
elif self.mode == CameraMode.FLOATING:
|
||||
self._update_floating(dt)
|
||||
|
||||
def _update_vertical(self, dt: float) -> None:
|
||||
self.y += int(self.speed * dt * 60)
|
||||
|
||||
def _update_horizontal(self, dt: float) -> None:
|
||||
self.x += int(self.speed * dt * 60)
|
||||
|
||||
def _update_omni(self, dt: float) -> None:
|
||||
speed = self.speed * dt * 60
|
||||
self.y += int(speed)
|
||||
self.x += int(speed * 0.5)
|
||||
|
||||
def _update_floating(self, dt: float) -> None:
|
||||
base = self.speed * 30
|
||||
self.y = int(math.sin(self._time * 2) * base)
|
||||
self.x = int(math.cos(self._time * 1.5) * base * 0.5)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset camera position."""
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self._time = 0.0
|
||||
|
||||
@classmethod
|
||||
def vertical(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a vertical scrolling camera."""
|
||||
return cls(mode=CameraMode.VERTICAL, speed=speed)
|
||||
|
||||
@classmethod
|
||||
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a horizontal scrolling camera."""
|
||||
return cls(mode=CameraMode.HORIZONTAL, speed=speed)
|
||||
|
||||
@classmethod
|
||||
def omni(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create an omnidirectional scrolling camera."""
|
||||
return cls(mode=CameraMode.OMNI, speed=speed)
|
||||
|
||||
@classmethod
|
||||
def floating(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a floating/bobbing camera."""
|
||||
return cls(mode=CameraMode.FLOATING, speed=speed)
|
||||
|
||||
@classmethod
|
||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||
"""Create a camera with custom update function."""
|
||||
return cls(custom_update=update_fn)
|
||||
@@ -105,6 +105,8 @@ class Config:
|
||||
firehose: bool = False
|
||||
|
||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
ntfy_reconnect_delay: int = 5
|
||||
message_display_secs: int = 30
|
||||
|
||||
@@ -127,6 +129,10 @@ class Config:
|
||||
|
||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||
|
||||
display: str = "pygame"
|
||||
websocket: bool = False
|
||||
websocket_port: int = 8765
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||
@@ -148,6 +154,8 @@ class Config:
|
||||
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_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir=font_dir,
|
||||
@@ -164,6 +172,9 @@ class Config:
|
||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||
script_fonts=_get_platform_font_paths(),
|
||||
display=_arg_value("--display", argv) or "terminal",
|
||||
websocket="--websocket" in argv,
|
||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +204,8 @@ FIREHOSE = "--firehose" in sys.argv
|
||||
|
||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||
|
||||
@@ -223,6 +236,26 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
|
||||
WEBSOCKET = "--websocket" in sys.argv
|
||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||
|
||||
# ─── DEMO MODE ────────────────────────────────────────────
|
||||
DEMO = "--demo" in sys.argv
|
||||
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
||||
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
||||
|
||||
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
||||
PIPELINE_MODE = "--pipeline" in sys.argv
|
||||
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
||||
|
||||
# ─── PRESET MODE ────────────────────────────────────────────
|
||||
PRESET = _arg_value("--preset", sys.argv)
|
||||
|
||||
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
||||
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
||||
|
||||
|
||||
def set_font_selection(font_path=None, font_index=None):
|
||||
"""Set runtime primary font selection."""
|
||||
|
||||
@@ -3,6 +3,17 @@ Stream controller - manages input sources and orchestrates the render stream.
|
||||
"""
|
||||
|
||||
from engine.config import Config, get_config
|
||||
from engine.display import (
|
||||
DisplayRegistry,
|
||||
KittyDisplay,
|
||||
MultiDisplay,
|
||||
NullDisplay,
|
||||
PygameDisplay,
|
||||
SixelDisplay,
|
||||
TerminalDisplay,
|
||||
WebSocketDisplay,
|
||||
)
|
||||
from engine.effects.controller import handle_effects_command
|
||||
from engine.eventbus import EventBus
|
||||
from engine.events import EventType, StreamEvent
|
||||
from engine.mic import MicMonitor
|
||||
@@ -10,14 +21,82 @@ from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
|
||||
|
||||
def _get_display(config: Config):
|
||||
"""Get the appropriate display based on config."""
|
||||
DisplayRegistry.initialize()
|
||||
display_mode = config.display.lower()
|
||||
|
||||
displays = []
|
||||
|
||||
if display_mode in ("terminal", "both"):
|
||||
displays.append(TerminalDisplay())
|
||||
|
||||
if display_mode in ("websocket", "both"):
|
||||
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
|
||||
ws.start_server()
|
||||
ws.start_http_server()
|
||||
displays.append(ws)
|
||||
|
||||
if display_mode == "sixel":
|
||||
displays.append(SixelDisplay())
|
||||
|
||||
if display_mode == "kitty":
|
||||
displays.append(KittyDisplay())
|
||||
|
||||
if display_mode == "pygame":
|
||||
displays.append(PygameDisplay())
|
||||
|
||||
if not displays:
|
||||
return NullDisplay()
|
||||
|
||||
if len(displays) == 1:
|
||||
return displays[0]
|
||||
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
class StreamController:
|
||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||
|
||||
_topics_warmed = False
|
||||
|
||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||
self.config = config or get_config()
|
||||
self.event_bus = event_bus
|
||||
self.mic: MicMonitor | None = None
|
||||
self.ntfy: NtfyPoller | None = None
|
||||
self.ntfy_cc: NtfyPoller | None = None
|
||||
|
||||
@classmethod
|
||||
def warmup_topics(cls) -> None:
|
||||
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
||||
if cls._topics_warmed:
|
||||
return
|
||||
|
||||
import urllib.request
|
||||
|
||||
topics = [
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline",
|
||||
]
|
||||
|
||||
for topic in topics:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
topic,
|
||||
data=b"init",
|
||||
headers={
|
||||
"User-Agent": "mainline/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls._topics_warmed = True
|
||||
|
||||
def initialize_sources(self) -> tuple[bool, bool]:
|
||||
"""Initialize microphone and ntfy sources.
|
||||
@@ -35,7 +114,38 @@ class StreamController:
|
||||
)
|
||||
ntfy_ok = self.ntfy.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok
|
||||
self.ntfy_cc = NtfyPoller(
|
||||
self.config.ntfy_cc_cmd_topic,
|
||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||
display_secs=5,
|
||||
)
|
||||
self.ntfy_cc.subscribe(self._handle_cc_message)
|
||||
ntfy_cc_ok = self.ntfy_cc.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
||||
|
||||
def _handle_cc_message(self, event) -> None:
|
||||
"""Handle incoming C&C message - like a serial port control interface."""
|
||||
import urllib.request
|
||||
|
||||
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
||||
if not cmd.startswith("/"):
|
||||
return
|
||||
|
||||
response = handle_effects_command(cmd)
|
||||
|
||||
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
||||
data = response.encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=data,
|
||||
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self, items: list) -> None:
|
||||
"""Run the stream with initialized sources."""
|
||||
@@ -51,7 +161,10 @@ class StreamController:
|
||||
),
|
||||
)
|
||||
|
||||
stream(items, self.ntfy, self.mic)
|
||||
display = _get_display(self.config)
|
||||
stream(items, self.ntfy, self.mic, display)
|
||||
if display:
|
||||
display.cleanup()
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
|
||||
124
engine/display/__init__.py
Normal file
124
engine/display/__init__.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Display backend system with registry pattern.
|
||||
|
||||
Allows swapping output backends via the Display protocol.
|
||||
Supports auto-discovery of display backends.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from engine.display.backends.kitty import KittyDisplay
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
from engine.display.backends.null import NullDisplay
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.terminal import TerminalDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends.
|
||||
|
||||
All display backends must implement:
|
||||
- width, height: Terminal dimensions
|
||||
- init(width, height, reuse=False): Initialize the display
|
||||
- show(buffer): Render buffer to display
|
||||
- clear(): Clear the display
|
||||
- cleanup(): Shutdown the display
|
||||
|
||||
The reuse flag allows attaching to an existing display instance
|
||||
rather than creating a new window/connection.
|
||||
"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, attach to existing display instead of creating new
|
||||
"""
|
||||
...
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
"""Show buffer on display."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
|
||||
class DisplayRegistry:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
|
||||
_backends: dict[str, type[Display]] = {}
|
||||
_initialized = False
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||
"""Register a display backend."""
|
||||
cls._backends[name.lower()] = backend_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> type[Display] | None:
|
||||
"""Get a display backend class by name."""
|
||||
return cls._backends.get(name.lower())
|
||||
|
||||
@classmethod
|
||||
def list_backends(cls) -> list[str]:
|
||||
"""List all available display backend names."""
|
||||
return list(cls._backends.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str, **kwargs) -> Display | None:
|
||||
"""Create a display instance by name."""
|
||||
cls.initialize()
|
||||
backend_class = cls.get(name)
|
||||
if backend_class:
|
||||
return backend_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def initialize(cls) -> None:
|
||||
"""Initialize and register all built-in backends."""
|
||||
if cls._initialized:
|
||||
return
|
||||
|
||||
cls.register("terminal", TerminalDisplay)
|
||||
cls.register("null", NullDisplay)
|
||||
cls.register("websocket", WebSocketDisplay)
|
||||
cls.register("sixel", SixelDisplay)
|
||||
cls.register("kitty", KittyDisplay)
|
||||
cls.register("pygame", PygameDisplay)
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Display",
|
||||
"DisplayRegistry",
|
||||
"get_monitor",
|
||||
"TerminalDisplay",
|
||||
"NullDisplay",
|
||||
"WebSocketDisplay",
|
||||
"SixelDisplay",
|
||||
"MultiDisplay",
|
||||
]
|
||||
152
engine/display/backends/kitty.py
Normal file
152
engine/display/backends/kitty.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Kitty graphics display backend - renders using kitty's native graphics protocol.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes:
|
||||
"""Encode image data using kitty's graphics protocol."""
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
|
||||
chunks = []
|
||||
for i in range(0, len(encoded), 4096):
|
||||
chunk = encoded[i : i + 4096]
|
||||
if i == 0:
|
||||
chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\")
|
||||
else:
|
||||
chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\")
|
||||
|
||||
return "".join(chunks).encode("utf-8")
|
||||
|
||||
|
||||
class KittyDisplay:
|
||||
"""Kitty graphics display backend using kitty's native protocol."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for KittyDisplay (protocol doesn't support reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_KITTY_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
output = BytesIO()
|
||||
img.save(output, format="PNG")
|
||||
png_data = output.getvalue()
|
||||
|
||||
graphic = _encode_kitty_graphic(png_data, img_width, img_height)
|
||||
|
||||
sys.stdout.buffer.write(graphic)
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.clear()
|
||||
43
engine/display/backends/multi.py
Normal file
43
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Multi display backend - forwards to multiple displays.
|
||||
"""
|
||||
|
||||
|
||||
class MultiDisplay:
|
||||
"""Display that forwards to multiple displays.
|
||||
|
||||
Supports reuse - passes reuse flag to all child displays.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, displays: list):
|
||||
self.displays = displays
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize all child displays with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, use reuse mode for child displays
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
for d in self.displays:
|
||||
d.init(width, height, reuse=reuse)
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
for d in self.displays:
|
||||
d.show(buffer)
|
||||
|
||||
def clear(self) -> None:
|
||||
for d in self.displays:
|
||||
d.clear()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for d in self.displays:
|
||||
d.cleanup()
|
||||
43
engine/display/backends/null.py
Normal file
43
engine/display/backends/null.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Null/headless display backend.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for NullDisplay (no resources to reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
from engine.display import get_monitor
|
||||
|
||||
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
|
||||
212
engine/display/backends/pygame.py
Normal file
212
engine/display/backends/pygame.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Pygame display backend - renders to a native application window.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
|
||||
class PygameDisplay:
|
||||
"""Pygame display backend - renders to native window.
|
||||
|
||||
Supports reuse mode - when reuse=True, skips SDL initialization
|
||||
and reuses the existing pygame window from a previous instance.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
window_width: int = 800
|
||||
window_height: int = 600
|
||||
_pygame_initialized: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
window_width: int = 800,
|
||||
window_height: int = 600,
|
||||
):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
self._initialized = False
|
||||
self._pygame = None
|
||||
self._screen = None
|
||||
self._font = None
|
||||
self._resized = False
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path for rendering."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
return env_font
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.append(os.path.expanduser("~/Library/Fonts/"))
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.append(
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\")
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, attach to existing pygame window instead of creating new
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
import os
|
||||
|
||||
os.environ["SDL_VIDEODRIVER"] = "x11"
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if reuse and PygameDisplay._pygame_initialized:
|
||||
self._pygame = pygame
|
||||
self._initialized = True
|
||||
return
|
||||
|
||||
pygame.init()
|
||||
pygame.display.set_caption("Mainline")
|
||||
|
||||
self._screen = pygame.display.set_mode(
|
||||
(self.window_width, self.window_height),
|
||||
pygame.RESIZABLE,
|
||||
)
|
||||
self._pygame = pygame
|
||||
PygameDisplay._pygame_initialized = True
|
||||
|
||||
font_path = self._get_font_path()
|
||||
if font_path:
|
||||
try:
|
||||
self._font = pygame.font.Font(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
else:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
if not self._initialized or not self._pygame:
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
for event in self._pygame.event.get():
|
||||
if event.type == self._pygame.QUIT:
|
||||
sys.exit(0)
|
||||
elif event.type == self._pygame.VIDEORESIZE:
|
||||
self.window_width = event.w
|
||||
self.window_height = event.h
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
self._resized = True
|
||||
|
||||
self._screen.fill((0, 0, 0))
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bg_surface = self._font.render(text, True, fg, bg)
|
||||
self._screen.blit(bg_surface, (x_pos, row_idx * self.cell_height))
|
||||
else:
|
||||
text_surface = self._font.render(text, True, fg)
|
||||
self._screen.blit(text_surface, (x_pos, row_idx * self.cell_height))
|
||||
|
||||
x_pos += self._font.size(text)[0]
|
||||
|
||||
self._pygame.display.flip()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._screen and self._pygame:
|
||||
self._screen.fill((0, 0, 0))
|
||||
self._pygame.display.flip()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions based on window size.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
if self._resized:
|
||||
self._resized = False
|
||||
return self.width, self.height
|
||||
|
||||
def cleanup(self, quit_pygame: bool = True) -> None:
|
||||
"""Cleanup display resources.
|
||||
|
||||
Args:
|
||||
quit_pygame: If True, quit pygame entirely. Set to False when
|
||||
reusing the display to avoid closing shared window.
|
||||
"""
|
||||
if quit_pygame and self._pygame:
|
||||
self._pygame.quit()
|
||||
PygameDisplay._pygame_initialized = False
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""Reset pygame state - useful for testing."""
|
||||
cls._pygame_initialized = False
|
||||
200
engine/display/backends/sixel.py
Normal file
200
engine/display/backends/sixel.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Sixel graphics display backend - renders to sixel graphics in terminal.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_sixel(image) -> str:
|
||||
"""Encode a PIL Image to sixel format (pure Python)."""
|
||||
img = image.convert("RGBA")
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
palette = []
|
||||
pixel_palette_idx = {}
|
||||
|
||||
def get_color_idx(r, g, b, a):
|
||||
if a < 128:
|
||||
return -1
|
||||
key = (r // 32, g // 32, b // 32)
|
||||
if key not in pixel_palette_idx:
|
||||
idx = len(palette)
|
||||
if idx < 256:
|
||||
palette.append((r, g, b))
|
||||
pixel_palette_idx[key] = idx
|
||||
return pixel_palette_idx.get(key, 0)
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
get_color_idx(r, g, b, a)
|
||||
|
||||
if not palette:
|
||||
return ""
|
||||
|
||||
if len(palette) == 1:
|
||||
palette = [palette[0], (0, 0, 0)]
|
||||
|
||||
sixel_data = []
|
||||
sixel_data.append(
|
||||
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
||||
)
|
||||
|
||||
for x in range(width):
|
||||
col_data = []
|
||||
for y in range(0, height, 6):
|
||||
bits = 0
|
||||
color_idx = -1
|
||||
for dy in range(6):
|
||||
if y + dy < height:
|
||||
r, g, b, a = pixels[x, y + dy]
|
||||
if a >= 128:
|
||||
bits |= 1 << dy
|
||||
idx = get_color_idx(r, g, b, a)
|
||||
if color_idx == -1:
|
||||
color_idx = idx
|
||||
elif color_idx != idx:
|
||||
color_idx = -2
|
||||
|
||||
if color_idx >= 0:
|
||||
col_data.append(
|
||||
chr(63 + color_idx) + chr(63 + bits)
|
||||
if bits
|
||||
else chr(63 + color_idx) + "?"
|
||||
)
|
||||
elif color_idx == -2:
|
||||
pass
|
||||
|
||||
if col_data:
|
||||
sixel_data.append("".join(col_data) + "$")
|
||||
else:
|
||||
sixel_data.append("-" if x < width - 1 else "$")
|
||||
|
||||
sixel_data.append("\x1b\\")
|
||||
|
||||
return "\x1bPq" + "".join(sixel_data)
|
||||
|
||||
|
||||
class SixelDisplay:
|
||||
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_SIXEL_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for SixelDisplay
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
sixel = _encode_sixel(img)
|
||||
|
||||
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
59
engine/display/backends/terminal.py
Normal file
59
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend.
|
||||
|
||||
Renders buffer to stdout using ANSI escape codes.
|
||||
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_initialized: bool = False
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, skip terminal re-initialization
|
||||
"""
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if not reuse or not self._initialized:
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
self._initialized = 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
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
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)
|
||||
274
engine/display/backends/websocket.py
Normal file
274
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
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 WebSocketDisplay:
|
||||
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
http_port: int = 8766,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.http_port = http_port
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self._clients: set = set()
|
||||
self._server_running = False
|
||||
self._http_running = False
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._http_thread: threading.Thread | None = None
|
||||
self._available = True
|
||||
self._max_clients = 10
|
||||
self._client_connected_callback = None
|
||||
self._client_disconnected_callback = None
|
||||
self._frame_delay = 0.0
|
||||
|
||||
try:
|
||||
import websockets as _ws
|
||||
|
||||
self._available = _ws is not None
|
||||
except ImportError:
|
||||
self._available = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if WebSocket support is available."""
|
||||
return self._available
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions and start server.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, skip starting servers (assume already running)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if not reuse or not self._server_running:
|
||||
self.start_server()
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
"""Broadcast buffer to all connected clients."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
if self._clients:
|
||||
frame_data = {
|
||||
"type": "frame",
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"lines": buffer,
|
||||
}
|
||||
message = json.dumps(frame_data)
|
||||
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Broadcast clear command to all clients."""
|
||||
if self._clients:
|
||||
clear_data = {"type": "clear"}
|
||||
message = json.dumps(clear_data)
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the servers."""
|
||||
self.stop_server()
|
||||
self.stop_http_server()
|
||||
|
||||
async def _websocket_handler(self, websocket):
|
||||
"""Handle WebSocket connections."""
|
||||
if len(self._clients) >= self._max_clients:
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
self._clients.add(websocket)
|
||||
if self._client_connected_callback:
|
||||
self._client_connected_callback(websocket)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("type") == "resize":
|
||||
self.width = data.get("width", 80)
|
||||
self.height = data.get("height", 24)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clients.discard(websocket)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(websocket)
|
||||
|
||||
async def _run_websocket_server(self):
|
||||
"""Run the WebSocket server."""
|
||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||
while self._server_running:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _run_http_server(self):
|
||||
"""Run simple HTTP server for the client."""
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
client_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||
)
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=client_dir, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||
while self._http_running:
|
||||
httpd.handle_request()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run coroutine in background."""
|
||||
try:
|
||||
asyncio.run(coro)
|
||||
except Exception as e:
|
||||
print(f"WebSocket async error: {e}")
|
||||
|
||||
def start_server(self):
|
||||
"""Start the WebSocket server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._server_thread is not None:
|
||||
return
|
||||
|
||||
self._server_running = True
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
def stop_server(self):
|
||||
"""Stop the WebSocket server."""
|
||||
self._server_running = False
|
||||
self._server_thread = None
|
||||
|
||||
def start_http_server(self):
|
||||
"""Start the HTTP server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._http_thread is not None:
|
||||
return
|
||||
|
||||
self._http_running = True
|
||||
|
||||
self._http_running = True
|
||||
self._http_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||
)
|
||||
self._http_thread.start()
|
||||
|
||||
def stop_http_server(self):
|
||||
"""Stop the HTTP server."""
|
||||
self._http_running = False
|
||||
self._http_thread = None
|
||||
|
||||
def client_count(self) -> int:
|
||||
"""Return number of connected clients."""
|
||||
return len(self._clients)
|
||||
|
||||
def get_ws_port(self) -> int:
|
||||
"""Return WebSocket port."""
|
||||
return self.port
|
||||
|
||||
def get_http_port(self) -> int:
|
||||
"""Return HTTP port."""
|
||||
return self.http_port
|
||||
|
||||
def set_frame_delay(self, delay: float) -> None:
|
||||
"""Set delay between frames in seconds."""
|
||||
self._frame_delay = delay
|
||||
|
||||
def get_frame_delay(self) -> float:
|
||||
"""Get delay between frames."""
|
||||
return self._frame_delay
|
||||
|
||||
def set_client_connected_callback(self, callback) -> None:
|
||||
"""Set callback for client connections."""
|
||||
self._client_connected_callback = callback
|
||||
|
||||
def set_client_disconnected_callback(self, callback) -> None:
|
||||
"""Set callback for client disconnections."""
|
||||
self._client_disconnected_callback = callback
|
||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Shared display rendering utilities.
|
||||
|
||||
Provides common functionality for displays that render text to images
|
||||
(Pygame, Sixel, Kitty displays).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
ANSI_COLORS = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
|
||||
def parse_ansi(
|
||||
text: str,
|
||||
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||
"""Parse ANSI escape sequences into text tokens with colors.
|
||||
|
||||
Args:
|
||||
text: Text containing ANSI escape sequences
|
||||
|
||||
Returns:
|
||||
List of (text, fg_rgb, bg_rgb, bold) tuples
|
||||
"""
|
||||
tokens = []
|
||||
current_text = ""
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
i = 0
|
||||
|
||||
ANSI_COLORS_4BIT = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
|
||||
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
current_text = ""
|
||||
|
||||
i += 2
|
||||
code = ""
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if c.isalpha():
|
||||
break
|
||||
code += c
|
||||
i += 1
|
||||
|
||||
if code:
|
||||
codes = code.split(";")
|
||||
for c in codes:
|
||||
if c == "0":
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
elif c == "1":
|
||||
bold = True
|
||||
elif c == "22":
|
||||
bold = False
|
||||
elif c == "39":
|
||||
fg = (204, 204, 204)
|
||||
elif c == "49":
|
||||
bg = (0, 0, 0)
|
||||
elif c.isdigit():
|
||||
color_idx = int(c)
|
||||
if color_idx in ANSI_COLORS_4BIT:
|
||||
fg = ANSI_COLORS_4BIT[color_idx]
|
||||
elif 30 <= color_idx <= 37:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg)
|
||||
elif 40 <= color_idx <= 47:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg)
|
||||
elif 90 <= color_idx <= 97:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg)
|
||||
elif 100 <= color_idx <= 107:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg)
|
||||
elif c.startswith("38;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
fg = ANSI_COLORS_4BIT.get(idx, fg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
fg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
fg = (gray, gray, gray)
|
||||
elif c.startswith("48;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
bg = ANSI_COLORS_4BIT.get(idx, bg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
bg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
bg = (gray, gray, gray)
|
||||
i += 1
|
||||
else:
|
||||
current_text += char
|
||||
i += 1
|
||||
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
|
||||
return tokens if tokens else [("", fg, bg, bold)]
|
||||
|
||||
|
||||
def get_default_font_path() -> str | None:
|
||||
"""Get the path to a default monospace font."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
if "mono" in name or "courier" in name or "terminal" in name:
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/Library/Fonts/"),
|
||||
"/System/Library/Fonts/",
|
||||
]
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\"),
|
||||
"C:\\Windows\\Fonts\\",
|
||||
]
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]:
|
||||
result = subprocess.run(
|
||||
["fc-match", "-f", "%{file}", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
font_file = result.stdout.strip()
|
||||
if os.path.exists(font_file):
|
||||
return font_file
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_to_pil(
|
||||
buffer: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
font_path: str | None = None,
|
||||
) -> Any:
|
||||
"""Render buffer to a PIL Image.
|
||||
|
||||
Args:
|
||||
buffer: List of text lines to render
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
cell_width: Width of each character cell in pixels
|
||||
cell_height: Height of each character cell in pixels
|
||||
font_path: Path to TTF/OTF font file (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
img_width = width * cell_width
|
||||
img_height = height * cell_height
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, cell_height - 2)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row_idx, line in enumerate(buffer[:height]):
|
||||
if row_idx >= height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * cell_height
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
return img
|
||||
50
engine/effects/__init__.py
Normal file
50
engine/effects/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
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_offset,
|
||||
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,
|
||||
create_effect_context,
|
||||
)
|
||||
|
||||
|
||||
def get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
"EffectRegistry",
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"PipelineConfig",
|
||||
"create_effect_context",
|
||||
"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",
|
||||
"vis_offset",
|
||||
]
|
||||
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)
|
||||
@@ -1,6 +1,14 @@
|
||||
"""
|
||||
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||
Depends on: config, terminal, sources.
|
||||
|
||||
These are low-level functional implementations of visual effects. They are used
|
||||
internally by the EffectPlugin system (effects_plugins/*.py) and also directly
|
||||
by layers.py and scroll.py for rendering.
|
||||
|
||||
The plugin system provides a higher-level OOP interface with configuration
|
||||
support, while these legacy functions provide direct functional access.
|
||||
Both systems coexist - there are no current plans to deprecate the legacy functions.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -74,6 +82,37 @@ def vis_trunc(s, w):
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def vis_offset(s, offset):
|
||||
"""Offset string by skipping first offset visual characters, skipping ANSI escape codes."""
|
||||
if offset <= 0:
|
||||
return s
|
||||
result = []
|
||||
vw = 0
|
||||
i = 0
|
||||
skipping = True
|
||||
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
|
||||
if skipping:
|
||||
i = j + 1
|
||||
continue
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
else:
|
||||
if skipping:
|
||||
if vw >= offset:
|
||||
skipping = False
|
||||
result.append(s[i])
|
||||
vw += 1
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i])
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def next_headline(pool, items, seen):
|
||||
"""Pull the next unique headline from pool, refilling as needed."""
|
||||
while True:
|
||||
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
|
||||
122
engine/effects/types.py
Normal file
122
engine/effects/types.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Visual effects type definitions and base classes.
|
||||
|
||||
EffectPlugin Architecture:
|
||||
- Uses ABC (Abstract Base Class) for interface enforcement
|
||||
- Runtime discovery via directory scanning (effects_plugins/)
|
||||
- Configuration via EffectConfig dataclass
|
||||
- Context passed through EffectContext dataclass
|
||||
|
||||
Plugin System Research (see AGENTS.md for references):
|
||||
- VST: Standardized audio interfaces, chaining, presets (FXP/FXB)
|
||||
- Python Entry Points: Namespace packages, importlib.metadata discovery
|
||||
- Shadertoy: Shader-based with uniforms as context
|
||||
|
||||
Current gaps vs industry patterns:
|
||||
- No preset save/load system
|
||||
- No external plugin distribution via entry points
|
||||
- No plugin metadata (version, author, description)
|
||||
"""
|
||||
|
||||
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):
|
||||
"""Abstract base class for effect plugins.
|
||||
|
||||
Subclasses must define:
|
||||
- name: str - unique identifier for the effect
|
||||
- config: EffectConfig - current configuration
|
||||
|
||||
And implement:
|
||||
- process(buf, ctx) -> list[str]
|
||||
- configure(config) -> None
|
||||
|
||||
Effect Behavior with ticker_height=0:
|
||||
- NoiseEffect: Returns buffer unchanged (no ticker to apply noise to)
|
||||
- FadeEffect: Returns buffer unchanged (no ticker to fade)
|
||||
- GlitchEffect: Processes normally (doesn't depend on ticker_height)
|
||||
- FirehoseEffect: Returns buffer unchanged if no items in context
|
||||
|
||||
Effects should handle missing or zero context values gracefully by
|
||||
returning the input buffer unchanged rather than raising errors.
|
||||
"""
|
||||
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Process the buffer with this effect applied.
|
||||
|
||||
Args:
|
||||
buf: List of lines to process
|
||||
ctx: Effect context with terminal state
|
||||
|
||||
Returns:
|
||||
Processed buffer (may be same object or new list)
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect with new settings.
|
||||
|
||||
Args:
|
||||
config: New configuration to apply
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def create_effect_context(
|
||||
terminal_width: int = 80,
|
||||
terminal_height: int = 24,
|
||||
scroll_cam: int = 0,
|
||||
ticker_height: 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,
|
||||
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)
|
||||
@@ -10,10 +10,13 @@ from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectContext,
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
noise,
|
||||
vis_offset,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||
@@ -92,16 +95,18 @@ def render_message_overlay(
|
||||
def render_ticker_zone(
|
||||
active: list,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
w: int,
|
||||
noise_cache: dict,
|
||||
grad_offset: float,
|
||||
camera_x: int = 0,
|
||||
ticker_h: int = 0,
|
||||
w: int = 80,
|
||||
noise_cache: dict | None = None,
|
||||
grad_offset: float = 0.0,
|
||||
) -> 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)
|
||||
camera_x: horizontal camera offset
|
||||
ticker_h: height of ticker zone
|
||||
w: terminal width
|
||||
noise_cache: dict of cy -> noise string
|
||||
@@ -110,6 +115,8 @@ def render_ticker_zone(
|
||||
Returns:
|
||||
(list of ANSI strings, updated noise_cache)
|
||||
"""
|
||||
if noise_cache is None:
|
||||
noise_cache = {}
|
||||
buf = []
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
@@ -135,7 +142,7 @@ def render_ticker_zone(
|
||||
colored = lr_gradient([raw], grad_offset)[0]
|
||||
else:
|
||||
colored = raw
|
||||
ln = vis_trunc(colored, w)
|
||||
ln = vis_trunc(vis_offset(colored, camera_x), w)
|
||||
if row_fade < 1.0:
|
||||
ln = fade_line(ln, row_fade)
|
||||
|
||||
@@ -199,3 +206,62 @@ def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||
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,
|
||||
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,
|
||||
) -> 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,
|
||||
camera_x=camera_x,
|
||||
ticker_height=ticker_h,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
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
|
||||
|
||||
611
engine/pipeline.py
Normal file
611
engine/pipeline.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
Pipeline introspection - generates self-documenting diagrams of the render pipeline.
|
||||
|
||||
Pipeline Architecture:
|
||||
- Sources: Data providers (RSS, Poetry, Ntfy, Mic) - static or dynamic
|
||||
- Fetch: Retrieve data from sources
|
||||
- Prepare: Transform raw data (make_block, strip_tags, translate)
|
||||
- Scroll: Camera-based viewport rendering (ticker zone, message overlay)
|
||||
- Effects: Post-processing chain (noise, fade, glitch, firehose, hud)
|
||||
- Render: Final line rendering and layout
|
||||
- Display: Output backends (terminal, pygame, websocket, sixel, kitty)
|
||||
|
||||
Key abstractions:
|
||||
- DataSource: Sources can be static (cached) or dynamic (idempotent fetch)
|
||||
- Camera: Viewport controller (vertical, horizontal, omni, floating, trace)
|
||||
- EffectChain: Ordered effect processing pipeline
|
||||
- Display: Pluggable output backends
|
||||
- SourceRegistry: Source discovery and management
|
||||
- AnimationController: Time-based parameter animation
|
||||
- Preset: Package of initial params + animation for demo modes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineNode:
|
||||
"""Represents a node in the pipeline."""
|
||||
|
||||
name: str
|
||||
module: str
|
||||
class_name: str | None = None
|
||||
func_name: str | None = None
|
||||
description: str = ""
|
||||
inputs: list[str] | None = None
|
||||
outputs: list[str] | None = None
|
||||
metrics: dict | None = None # Performance metrics (avg_ms, min_ms, max_ms)
|
||||
|
||||
|
||||
class PipelineIntrospector:
|
||||
"""Introspects the render pipeline and generates documentation."""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: list[PipelineNode] = []
|
||||
|
||||
def add_node(self, node: PipelineNode) -> None:
|
||||
self.nodes.append(node)
|
||||
|
||||
def generate_mermaid_flowchart(self) -> str:
|
||||
"""Generate a Mermaid flowchart of the pipeline."""
|
||||
lines = ["```mermaid", "flowchart TD"]
|
||||
|
||||
subgraph_groups = {
|
||||
"Sources": [],
|
||||
"Fetch": [],
|
||||
"Prepare": [],
|
||||
"Scroll": [],
|
||||
"Effects": [],
|
||||
"Display": [],
|
||||
"Async": [],
|
||||
"Animation": [],
|
||||
"Viz": [],
|
||||
}
|
||||
|
||||
other_nodes = []
|
||||
|
||||
for node in self.nodes:
|
||||
node_id = node.name.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
label = node.name
|
||||
if node.class_name:
|
||||
label = f"{node.name}\\n({node.class_name})"
|
||||
elif node.func_name:
|
||||
label = f"{node.name}\\n({node.func_name})"
|
||||
|
||||
if node.description:
|
||||
label += f"\\n{node.description}"
|
||||
|
||||
if node.metrics:
|
||||
avg = node.metrics.get("avg_ms", 0)
|
||||
if avg > 0:
|
||||
label += f"\\n⏱ {avg:.1f}ms"
|
||||
impact = node.metrics.get("impact_pct", 0)
|
||||
if impact > 0:
|
||||
label += f" ({impact:.0f}%)"
|
||||
|
||||
node_entry = f' {node_id}["{label}"]'
|
||||
|
||||
if "DataSource" in node.name or "SourceRegistry" in node.name:
|
||||
subgraph_groups["Sources"].append(node_entry)
|
||||
elif "fetch" in node.name.lower():
|
||||
subgraph_groups["Fetch"].append(node_entry)
|
||||
elif (
|
||||
"make_block" in node.name
|
||||
or "strip_tags" in node.name
|
||||
or "translate" in node.name
|
||||
):
|
||||
subgraph_groups["Prepare"].append(node_entry)
|
||||
elif (
|
||||
"StreamController" in node.name
|
||||
or "render_ticker" in node.name
|
||||
or "render_message" in node.name
|
||||
or "Camera" in node.name
|
||||
):
|
||||
subgraph_groups["Scroll"].append(node_entry)
|
||||
elif "Effect" in node.name or "effect" in node.module:
|
||||
subgraph_groups["Effects"].append(node_entry)
|
||||
elif "Display:" in node.name:
|
||||
subgraph_groups["Display"].append(node_entry)
|
||||
elif "Ntfy" in node.name or "Mic" in node.name:
|
||||
subgraph_groups["Async"].append(node_entry)
|
||||
elif "Animation" in node.name or "Preset" in node.name:
|
||||
subgraph_groups["Animation"].append(node_entry)
|
||||
elif "pipeline_viz" in node.module or "CameraLarge" in node.name:
|
||||
subgraph_groups["Viz"].append(node_entry)
|
||||
else:
|
||||
other_nodes.append(node_entry)
|
||||
|
||||
for group_name, nodes in subgraph_groups.items():
|
||||
if nodes:
|
||||
lines.append(f" subgraph {group_name}")
|
||||
for node in nodes:
|
||||
lines.append(node)
|
||||
lines.append(" end")
|
||||
|
||||
for node in other_nodes:
|
||||
lines.append(node)
|
||||
|
||||
lines.append("")
|
||||
|
||||
for node in self.nodes:
|
||||
node_id = node.name.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
if node.inputs:
|
||||
for inp in node.inputs:
|
||||
inp_id = inp.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
lines.append(f" {inp_id} --> {node_id}")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_mermaid_sequence(self) -> str:
|
||||
"""Generate a Mermaid sequence diagram of message flow."""
|
||||
lines = ["```mermaid", "sequenceDiagram"]
|
||||
|
||||
lines.append(" participant Sources")
|
||||
lines.append(" participant Fetch")
|
||||
lines.append(" participant Scroll")
|
||||
lines.append(" participant Effects")
|
||||
lines.append(" participant Display")
|
||||
|
||||
lines.append(" Sources->>Fetch: headlines")
|
||||
lines.append(" Fetch->>Scroll: content blocks")
|
||||
lines.append(" Scroll->>Effects: buffer")
|
||||
lines.append(" Effects->>Effects: process chain")
|
||||
lines.append(" Effects->>Display: rendered buffer")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_mermaid_state(self) -> str:
|
||||
"""Generate a Mermaid state diagram of camera modes."""
|
||||
lines = ["```mermaid", "stateDiagram-v2"]
|
||||
|
||||
lines.append(" [*] --> Vertical")
|
||||
lines.append(" Vertical --> Horizontal: set_mode()")
|
||||
lines.append(" Horizontal --> Omni: set_mode()")
|
||||
lines.append(" Omni --> Floating: set_mode()")
|
||||
lines.append(" Floating --> Trace: set_mode()")
|
||||
lines.append(" Trace --> Vertical: set_mode()")
|
||||
|
||||
lines.append(" state Vertical {")
|
||||
lines.append(" [*] --> ScrollUp")
|
||||
lines.append(" ScrollUp --> ScrollUp: +y each frame")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Horizontal {")
|
||||
lines.append(" [*] --> ScrollLeft")
|
||||
lines.append(" ScrollLeft --> ScrollLeft: +x each frame")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Omni {")
|
||||
lines.append(" [*] --> Diagonal")
|
||||
lines.append(" Diagonal --> Diagonal: +x, +y")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Floating {")
|
||||
lines.append(" [*] --> Bobbing")
|
||||
lines.append(" Bobbing --> Bobbing: sin(time)")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Trace {")
|
||||
lines.append(" [*] --> FollowPath")
|
||||
lines.append(" FollowPath --> FollowPath: node by node")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_full_diagram(self) -> str:
|
||||
"""Generate full pipeline documentation."""
|
||||
lines = [
|
||||
"# Render Pipeline",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
self.generate_mermaid_flowchart(),
|
||||
"",
|
||||
"## Message Sequence",
|
||||
"",
|
||||
self.generate_mermaid_sequence(),
|
||||
"",
|
||||
"## Camera States",
|
||||
"",
|
||||
self.generate_mermaid_state(),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def introspect_sources(self) -> None:
|
||||
"""Introspect data sources."""
|
||||
from engine import sources
|
||||
|
||||
for name in dir(sources):
|
||||
obj = getattr(sources, name)
|
||||
if isinstance(obj, dict):
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Data Source: {name}",
|
||||
module="engine.sources",
|
||||
description=f"{len(obj)} feeds configured",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_sources_v2(self) -> None:
|
||||
"""Introspect data sources v2 (new abstraction)."""
|
||||
from engine.sources_v2 import SourceRegistry, init_default_sources
|
||||
|
||||
init_default_sources()
|
||||
SourceRegistry()
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="SourceRegistry",
|
||||
module="engine.sources_v2",
|
||||
class_name="SourceRegistry",
|
||||
description="Source discovery and management",
|
||||
)
|
||||
)
|
||||
|
||||
for name, desc in [
|
||||
("HeadlinesDataSource", "RSS feed headlines"),
|
||||
("PoetryDataSource", "Poetry DB"),
|
||||
("PipelineDataSource", "Pipeline viz (dynamic)"),
|
||||
]:
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"DataSource: {name}",
|
||||
module="engine.sources_v2",
|
||||
class_name=name,
|
||||
description=f"{desc}",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_prepare(self) -> None:
|
||||
"""Introspect prepare layer (transformation)."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="make_block",
|
||||
module="engine.render",
|
||||
func_name="make_block",
|
||||
description="Transform headline into display block",
|
||||
inputs=["title", "source", "timestamp", "width"],
|
||||
outputs=["block"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="strip_tags",
|
||||
module="engine.filter",
|
||||
func_name="strip_tags",
|
||||
description="Remove HTML tags from content",
|
||||
inputs=["html"],
|
||||
outputs=["plain_text"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="translate_headline",
|
||||
module="engine.translate",
|
||||
func_name="translate_headline",
|
||||
description="Translate headline to target language",
|
||||
inputs=["title", "target_lang"],
|
||||
outputs=["translated_title"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_fetch(self) -> None:
|
||||
"""Introspect fetch layer."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="fetch_all",
|
||||
module="engine.fetch",
|
||||
func_name="fetch_all",
|
||||
description="Fetch RSS feeds",
|
||||
outputs=["items"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="fetch_poetry",
|
||||
module="engine.fetch",
|
||||
func_name="fetch_poetry",
|
||||
description="Fetch Poetry DB",
|
||||
outputs=["items"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_scroll(self) -> None:
|
||||
"""Introspect scroll engine."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="StreamController",
|
||||
module="engine.controller",
|
||||
class_name="StreamController",
|
||||
description="Main render loop orchestrator",
|
||||
inputs=["items", "ntfy_poller", "mic_monitor", "display"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="render_ticker_zone",
|
||||
module="engine.layers",
|
||||
func_name="render_ticker_zone",
|
||||
description="Render scrolling ticker content",
|
||||
inputs=["active", "camera"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="render_message_overlay",
|
||||
module="engine.layers",
|
||||
func_name="render_message_overlay",
|
||||
description="Render ntfy message overlay",
|
||||
inputs=["msg", "width", "height"],
|
||||
outputs=["overlay", "cache"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_render(self) -> None:
|
||||
"""Introspect render layer."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="big_wrap",
|
||||
module="engine.render",
|
||||
func_name="big_wrap",
|
||||
description="Word-wrap text to width",
|
||||
inputs=["text", "width"],
|
||||
outputs=["lines"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="lr_gradient",
|
||||
module="engine.render",
|
||||
func_name="lr_gradient",
|
||||
description="Apply left-right gradient to lines",
|
||||
inputs=["lines", "position"],
|
||||
outputs=["styled_lines"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_async_sources(self) -> None:
|
||||
"""Introspect async data sources (ntfy, mic)."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="NtfyPoller",
|
||||
module="engine.ntfy",
|
||||
class_name="NtfyPoller",
|
||||
description="Poll ntfy for messages (async)",
|
||||
inputs=["topic"],
|
||||
outputs=["message"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="MicMonitor",
|
||||
module="engine.mic",
|
||||
class_name="MicMonitor",
|
||||
description="Monitor microphone input (async)",
|
||||
outputs=["audio_level"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_eventbus(self) -> None:
|
||||
"""Introspect event bus for decoupled communication."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EventBus",
|
||||
module="engine.eventbus",
|
||||
class_name="EventBus",
|
||||
description="Thread-safe event publishing",
|
||||
inputs=["event"],
|
||||
outputs=["subscribers"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_animation(self) -> None:
|
||||
"""Introspect animation system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="AnimationController",
|
||||
module="engine.animation",
|
||||
class_name="AnimationController",
|
||||
description="Time-based parameter animation",
|
||||
inputs=["dt"],
|
||||
outputs=["params"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="Preset",
|
||||
module="engine.animation",
|
||||
class_name="Preset",
|
||||
description="Package of initial params + animation",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_pipeline_viz(self) -> None:
|
||||
"""Introspect pipeline visualization."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="generate_large_network_viewport",
|
||||
module="engine.pipeline_viz",
|
||||
func_name="generate_large_network_viewport",
|
||||
description="Large animated network visualization",
|
||||
inputs=["viewport_w", "viewport_h", "frame"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="CameraLarge",
|
||||
module="engine.pipeline_viz",
|
||||
class_name="CameraLarge",
|
||||
description="Large grid camera (trace mode)",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_camera(self) -> None:
|
||||
"""Introspect camera system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="Camera",
|
||||
module="engine.camera",
|
||||
class_name="Camera",
|
||||
description="Viewport position controller",
|
||||
inputs=["dt"],
|
||||
outputs=["x", "y"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_effects(self) -> None:
|
||||
"""Introspect effect system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EffectChain",
|
||||
module="engine.effects",
|
||||
class_name="EffectChain",
|
||||
description="Process effects in sequence",
|
||||
inputs=["buffer", "context"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EffectRegistry",
|
||||
module="engine.effects",
|
||||
class_name="EffectRegistry",
|
||||
description="Manage effect plugins",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_display(self) -> None:
|
||||
"""Introspect display backends."""
|
||||
from engine.display import DisplayRegistry
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
backends = DisplayRegistry.list_backends()
|
||||
|
||||
for backend in backends:
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Display: {backend}",
|
||||
module="engine.display.backends",
|
||||
class_name=f"{backend.title()}Display",
|
||||
description=f"Render to {backend}",
|
||||
inputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_new_pipeline(self, pipeline=None) -> None:
|
||||
"""Introspect new unified pipeline stages with metrics.
|
||||
|
||||
Args:
|
||||
pipeline: Optional Pipeline instance to collect metrics from
|
||||
"""
|
||||
|
||||
stages_info = [
|
||||
(
|
||||
"ItemsSource",
|
||||
"engine.pipeline.adapters",
|
||||
"ItemsStage",
|
||||
"Provides pre-fetched items",
|
||||
),
|
||||
(
|
||||
"Render",
|
||||
"engine.pipeline.adapters",
|
||||
"RenderStage",
|
||||
"Renders items to buffer",
|
||||
),
|
||||
(
|
||||
"Effect",
|
||||
"engine.pipeline.adapters",
|
||||
"EffectPluginStage",
|
||||
"Applies effect",
|
||||
),
|
||||
(
|
||||
"Display",
|
||||
"engine.pipeline.adapters",
|
||||
"DisplayStage",
|
||||
"Outputs to display",
|
||||
),
|
||||
]
|
||||
|
||||
metrics = None
|
||||
if pipeline and hasattr(pipeline, "get_metrics_summary"):
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
if "error" in metrics:
|
||||
metrics = None
|
||||
|
||||
total_avg = metrics.get("pipeline", {}).get("avg_ms", 0) if metrics else 0
|
||||
|
||||
for stage_name, module, class_name, desc in stages_info:
|
||||
node_metrics = None
|
||||
if metrics and "stages" in metrics:
|
||||
for name, stats in metrics["stages"].items():
|
||||
if stage_name.lower() in name.lower():
|
||||
impact_pct = (
|
||||
(stats.get("avg_ms", 0) / total_avg * 100)
|
||||
if total_avg > 0
|
||||
else 0
|
||||
)
|
||||
node_metrics = {
|
||||
"avg_ms": stats.get("avg_ms", 0),
|
||||
"min_ms": stats.get("min_ms", 0),
|
||||
"max_ms": stats.get("max_ms", 0),
|
||||
"impact_pct": impact_pct,
|
||||
}
|
||||
break
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Pipeline: {stage_name}",
|
||||
module=module,
|
||||
class_name=class_name,
|
||||
description=desc,
|
||||
inputs=["data"],
|
||||
outputs=["data"],
|
||||
metrics=node_metrics,
|
||||
)
|
||||
)
|
||||
|
||||
def run(self) -> str:
|
||||
"""Run full introspection."""
|
||||
self.introspect_sources()
|
||||
self.introspect_sources_v2()
|
||||
self.introspect_fetch()
|
||||
self.introspect_prepare()
|
||||
self.introspect_scroll()
|
||||
self.introspect_render()
|
||||
self.introspect_camera()
|
||||
self.introspect_effects()
|
||||
self.introspect_display()
|
||||
self.introspect_async_sources()
|
||||
self.introspect_eventbus()
|
||||
self.introspect_animation()
|
||||
self.introspect_pipeline_viz()
|
||||
|
||||
return self.generate_full_diagram()
|
||||
|
||||
|
||||
def generate_pipeline_diagram() -> str:
|
||||
"""Generate a self-documenting pipeline diagram."""
|
||||
introspector = PipelineIntrospector()
|
||||
return introspector.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(generate_pipeline_diagram())
|
||||
107
engine/pipeline/__init__.py
Normal file
107
engine/pipeline/__init__.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Unified Pipeline Architecture.
|
||||
|
||||
This module provides a clean, dependency-managed pipeline system:
|
||||
- Stage: Base class for all pipeline components
|
||||
- Pipeline: DAG-based execution orchestrator
|
||||
- PipelineParams: Runtime configuration for animation
|
||||
- PipelinePreset: Pre-configured pipeline configurations
|
||||
- StageRegistry: Unified registration for all stage types
|
||||
|
||||
The pipeline architecture supports:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
|
||||
Example:
|
||||
from engine.pipeline import Pipeline, PipelineConfig, StageRegistry
|
||||
|
||||
pipeline = Pipeline(PipelineConfig(source="headlines", display="terminal"))
|
||||
pipeline.add_stage("source", StageRegistry.create("source", "headlines"))
|
||||
pipeline.add_stage("display", StageRegistry.create("display", "terminal"))
|
||||
pipeline.build().initialize()
|
||||
|
||||
result = pipeline.execute(initial_data)
|
||||
"""
|
||||
|
||||
from engine.pipeline.controller import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
PipelineRunner,
|
||||
create_default_pipeline,
|
||||
create_pipeline_from_params,
|
||||
)
|
||||
from engine.pipeline.core import (
|
||||
PipelineContext,
|
||||
Stage,
|
||||
StageConfig,
|
||||
StageError,
|
||||
StageResult,
|
||||
)
|
||||
from engine.pipeline.params import (
|
||||
DEFAULT_HEADLINE_PARAMS,
|
||||
DEFAULT_PIPELINE_PARAMS,
|
||||
DEFAULT_PYGAME_PARAMS,
|
||||
PipelineParams,
|
||||
)
|
||||
from engine.pipeline.presets import (
|
||||
DEMO_PRESET,
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
PRESETS,
|
||||
SIXEL_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
PipelinePreset,
|
||||
create_preset_from_params,
|
||||
get_preset,
|
||||
list_presets,
|
||||
)
|
||||
from engine.pipeline.registry import (
|
||||
StageRegistry,
|
||||
discover_stages,
|
||||
register_camera,
|
||||
register_display,
|
||||
register_effect,
|
||||
register_source,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core
|
||||
"Stage",
|
||||
"StageConfig",
|
||||
"StageError",
|
||||
"StageResult",
|
||||
"PipelineContext",
|
||||
# Controller
|
||||
"Pipeline",
|
||||
"PipelineConfig",
|
||||
"PipelineRunner",
|
||||
"create_default_pipeline",
|
||||
"create_pipeline_from_params",
|
||||
# Params
|
||||
"PipelineParams",
|
||||
"DEFAULT_HEADLINE_PARAMS",
|
||||
"DEFAULT_PIPELINE_PARAMS",
|
||||
"DEFAULT_PYGAME_PARAMS",
|
||||
# Presets
|
||||
"PipelinePreset",
|
||||
"PRESETS",
|
||||
"DEMO_PRESET",
|
||||
"POETRY_PRESET",
|
||||
"PIPELINE_VIZ_PRESET",
|
||||
"WEBSOCKET_PRESET",
|
||||
"SIXEL_PRESET",
|
||||
"FIREHOSE_PRESET",
|
||||
"get_preset",
|
||||
"list_presets",
|
||||
"create_preset_from_params",
|
||||
# Registry
|
||||
"StageRegistry",
|
||||
"discover_stages",
|
||||
"register_source",
|
||||
"register_effect",
|
||||
"register_display",
|
||||
"register_camera",
|
||||
]
|
||||
299
engine/pipeline/adapters.py
Normal file
299
engine/pipeline/adapters.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Stage adapters - Bridge existing components to the Stage interface.
|
||||
|
||||
This module provides adapters that wrap existing components
|
||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage
|
||||
|
||||
|
||||
class RenderStage(Stage):
|
||||
"""Stage that renders items to a text buffer for display.
|
||||
|
||||
This mimics the old demo's render pipeline:
|
||||
- Selects headlines and renders them to blocks
|
||||
- Applies camera scroll position
|
||||
- Adds firehose layer if enabled
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list,
|
||||
width: int = 80,
|
||||
height: int = 24,
|
||||
camera_speed: float = 1.0,
|
||||
camera_mode: str = "vertical",
|
||||
firehose_enabled: bool = False,
|
||||
name: str = "render",
|
||||
):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = False
|
||||
self._items = items
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._camera_speed = camera_speed
|
||||
self._camera_mode = camera_mode
|
||||
self._firehose_enabled = firehose_enabled
|
||||
|
||||
self._camera_y = 0.0
|
||||
self._camera_x = 0
|
||||
self._scroll_accum = 0.0
|
||||
self._ticker_next_y = 0
|
||||
self._active: list = []
|
||||
self._seen: set = set()
|
||||
self._pool: list = list(items)
|
||||
self._noise_cache: dict = {}
|
||||
self._frame_count = 0
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source.items"}
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
random.shuffle(self._pool)
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Render items to a text buffer."""
|
||||
from engine.effects import next_headline
|
||||
from engine.layers import render_firehose, render_ticker_zone
|
||||
from engine.render import make_block
|
||||
|
||||
items = data or self._items
|
||||
w = ctx.params.viewport_width if ctx.params else self._width
|
||||
h = ctx.params.viewport_height if ctx.params else self._height
|
||||
camera_speed = ctx.params.camera_speed if ctx.params else self._camera_speed
|
||||
firehose = ctx.params.firehose_enabled if ctx.params else self._firehose_enabled
|
||||
|
||||
scroll_step = 0.5 / (camera_speed * 10)
|
||||
self._scroll_accum += scroll_step
|
||||
|
||||
GAP = 3
|
||||
|
||||
while self._scroll_accum >= scroll_step:
|
||||
self._scroll_accum -= scroll_step
|
||||
self._camera_y += 1.0
|
||||
|
||||
while (
|
||||
self._ticker_next_y < int(self._camera_y) + h + 10
|
||||
and len(self._active) < 50
|
||||
):
|
||||
t, src, ts = next_headline(self._pool, items, self._seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
self._active.append((ticker_content, hc, self._ticker_next_y, midx))
|
||||
self._ticker_next_y += len(ticker_content) + GAP
|
||||
|
||||
self._active = [
|
||||
(c, hc, by, mi)
|
||||
for c, hc, by, mi in self._active
|
||||
if by + len(c) > int(self._camera_y)
|
||||
]
|
||||
for k in list(self._noise_cache):
|
||||
if k < int(self._camera_y):
|
||||
del self._noise_cache[k]
|
||||
|
||||
grad_offset = (self._frame_count * 0.01) % 1.0
|
||||
|
||||
buf, self._noise_cache = render_ticker_zone(
|
||||
self._active,
|
||||
scroll_cam=int(self._camera_y),
|
||||
camera_x=self._camera_x,
|
||||
ticker_h=h,
|
||||
w=w,
|
||||
noise_cache=self._noise_cache,
|
||||
grad_offset=grad_offset,
|
||||
)
|
||||
|
||||
if firehose:
|
||||
firehose_buf = render_firehose(items, w, 0, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
self._frame_count += 1
|
||||
return buf
|
||||
|
||||
|
||||
class EffectPluginStage(Stage):
|
||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||
|
||||
def __init__(self, effect_plugin, name: str = "effect"):
|
||||
self._effect = effect_plugin
|
||||
self.name = name
|
||||
self.category = "effect"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"effect.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Process data through the effect."""
|
||||
if data is None:
|
||||
return None
|
||||
from engine.effects import EffectContext
|
||||
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
h = ctx.params.viewport_height if ctx.params else 24
|
||||
frame = ctx.params.frame_number if ctx.params else 0
|
||||
|
||||
effect_ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=0,
|
||||
ticker_height=h,
|
||||
camera_x=0,
|
||||
mic_excess=0.0,
|
||||
grad_offset=(frame * 0.01) % 1.0,
|
||||
frame_number=frame,
|
||||
has_message=False,
|
||||
items=ctx.get("items", []),
|
||||
)
|
||||
return self._effect.process(data, effect_ctx)
|
||||
|
||||
|
||||
class DisplayStage(Stage):
|
||||
"""Adapter wrapping Display as a Stage."""
|
||||
|
||||
def __init__(self, display, name: str = "terminal"):
|
||||
self._display = display
|
||||
self.name = name
|
||||
self.category = "display"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"display.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
h = ctx.params.viewport_height if ctx.params else 24
|
||||
result = self._display.init(w, h, reuse=False)
|
||||
return result is not False
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Output data to display."""
|
||||
if data is not None:
|
||||
self._display.show(data)
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._display.cleanup()
|
||||
|
||||
|
||||
class DataSourceStage(Stage):
|
||||
"""Adapter wrapping DataSource as a Stage."""
|
||||
|
||||
def __init__(self, data_source, name: str = "headlines"):
|
||||
self._source = data_source
|
||||
self.name = name
|
||||
self.category = "source"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"source.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Fetch data from source."""
|
||||
if hasattr(self._source, "get_items"):
|
||||
return self._source.get_items()
|
||||
return data
|
||||
|
||||
|
||||
class ItemsStage(Stage):
|
||||
"""Stage that holds pre-fetched items and provides them to the pipeline."""
|
||||
|
||||
def __init__(self, items, name: str = "headlines"):
|
||||
self._items = items
|
||||
self.name = name
|
||||
self.category = "source"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"source.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Return the pre-fetched items."""
|
||||
return self._items
|
||||
|
||||
|
||||
class CameraStage(Stage):
|
||||
"""Adapter wrapping Camera as a Stage."""
|
||||
|
||||
def __init__(self, camera, name: str = "vertical"):
|
||||
self._camera = camera
|
||||
self.name = name
|
||||
self.category = "camera"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"camera"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source.items"}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Apply camera transformation to data."""
|
||||
if data is None:
|
||||
return None
|
||||
if hasattr(self._camera, "apply"):
|
||||
return self._camera.apply(
|
||||
data, ctx.params.viewport_width if ctx.params else 80
|
||||
)
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if hasattr(self._camera, "reset"):
|
||||
self._camera.reset()
|
||||
|
||||
|
||||
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
||||
"""Create a Stage from a Display instance."""
|
||||
return DisplayStage(display, name)
|
||||
|
||||
|
||||
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
||||
"""Create a Stage from an EffectPlugin."""
|
||||
return EffectPluginStage(effect_plugin, name)
|
||||
|
||||
|
||||
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
||||
"""Create a Stage from a DataSource."""
|
||||
return DataSourceStage(data_source, name)
|
||||
|
||||
|
||||
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||
"""Create a Stage from a Camera."""
|
||||
return CameraStage(camera, name)
|
||||
|
||||
|
||||
def create_items_stage(items, name: str = "headlines") -> ItemsStage:
|
||||
"""Create a Stage that holds pre-fetched items."""
|
||||
return ItemsStage(items, name)
|
||||
320
engine/pipeline/controller.py
Normal file
320
engine/pipeline/controller.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Pipeline controller - DAG-based pipeline execution.
|
||||
|
||||
The Pipeline class orchestrates stages in dependency order, handling
|
||||
the complete render cycle from source to display.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage, StageError, StageResult
|
||||
from engine.pipeline.params import PipelineParams
|
||||
from engine.pipeline.registry import StageRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
"""Configuration for a pipeline instance."""
|
||||
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "vertical"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
enable_metrics: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageMetrics:
|
||||
"""Metrics for a single stage execution."""
|
||||
|
||||
name: str
|
||||
duration_ms: float
|
||||
chars_in: int = 0
|
||||
chars_out: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameMetrics:
|
||||
"""Metrics for a single frame through the pipeline."""
|
||||
|
||||
frame_number: int
|
||||
total_ms: float
|
||||
stages: list[StageMetrics] = field(default_factory=list)
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Main pipeline orchestrator.
|
||||
|
||||
Manages the execution of all stages in dependency order,
|
||||
handling initialization, processing, and cleanup.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PipelineConfig | None = None,
|
||||
context: PipelineContext | None = None,
|
||||
):
|
||||
self.config = config or PipelineConfig()
|
||||
self.context = context or PipelineContext()
|
||||
self._stages: dict[str, Stage] = {}
|
||||
self._execution_order: list[str] = []
|
||||
self._initialized = False
|
||||
|
||||
self._metrics_enabled = self.config.enable_metrics
|
||||
self._frame_metrics: list[FrameMetrics] = []
|
||||
self._max_metrics_frames = 60
|
||||
self._current_frame_number = 0
|
||||
|
||||
def add_stage(self, name: str, stage: Stage) -> "Pipeline":
|
||||
"""Add a stage to the pipeline."""
|
||||
self._stages[name] = stage
|
||||
return self
|
||||
|
||||
def remove_stage(self, name: str) -> None:
|
||||
"""Remove a stage from the pipeline."""
|
||||
if name in self._stages:
|
||||
del self._stages[name]
|
||||
|
||||
def get_stage(self, name: str) -> Stage | None:
|
||||
"""Get a stage by name."""
|
||||
return self._stages.get(name)
|
||||
|
||||
def build(self) -> "Pipeline":
|
||||
"""Build execution order based on dependencies."""
|
||||
self._execution_order = self._resolve_dependencies()
|
||||
self._initialized = True
|
||||
return self
|
||||
|
||||
def _resolve_dependencies(self) -> list[str]:
|
||||
"""Resolve stage execution order using topological sort."""
|
||||
ordered = []
|
||||
visited = set()
|
||||
temp_mark = set()
|
||||
|
||||
def visit(name: str) -> None:
|
||||
if name in temp_mark:
|
||||
raise StageError(name, "Circular dependency detected")
|
||||
if name in visited:
|
||||
return
|
||||
|
||||
temp_mark.add(name)
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
for dep in stage.dependencies:
|
||||
dep_stage = self._stages.get(dep)
|
||||
if dep_stage:
|
||||
visit(dep)
|
||||
|
||||
temp_mark.remove(name)
|
||||
visited.add(name)
|
||||
ordered.append(name)
|
||||
|
||||
for name in self._stages:
|
||||
if name not in visited:
|
||||
visit(name)
|
||||
|
||||
return ordered
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize all stages in execution order."""
|
||||
for name in self._execution_order:
|
||||
stage = self._stages.get(name)
|
||||
if stage and not stage.init(self.context) and not stage.optional:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, data: Any | None = None) -> StageResult:
|
||||
"""Execute the pipeline with the given input data."""
|
||||
if not self._initialized:
|
||||
self.build()
|
||||
|
||||
if not self._initialized:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=None,
|
||||
error="Pipeline not initialized",
|
||||
)
|
||||
|
||||
current_data = data
|
||||
frame_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
stage_timings: list[StageMetrics] = []
|
||||
|
||||
for name in self._execution_order:
|
||||
stage = self._stages.get(name)
|
||||
if not stage or not stage.is_enabled():
|
||||
continue
|
||||
|
||||
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
|
||||
try:
|
||||
current_data = stage.process(current_data, self.context)
|
||||
except Exception as e:
|
||||
if not stage.optional:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=current_data,
|
||||
error=str(e),
|
||||
stage_name=name,
|
||||
)
|
||||
continue
|
||||
|
||||
if self._metrics_enabled:
|
||||
stage_duration = (time.perf_counter() - stage_start) * 1000
|
||||
chars_in = len(str(data)) if data else 0
|
||||
chars_out = len(str(current_data)) if current_data else 0
|
||||
stage_timings.append(
|
||||
StageMetrics(
|
||||
name=name,
|
||||
duration_ms=stage_duration,
|
||||
chars_in=chars_in,
|
||||
chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
if self._metrics_enabled:
|
||||
total_duration = (time.perf_counter() - frame_start) * 1000
|
||||
self._frame_metrics.append(
|
||||
FrameMetrics(
|
||||
frame_number=self._current_frame_number,
|
||||
total_ms=total_duration,
|
||||
stages=stage_timings,
|
||||
)
|
||||
)
|
||||
if len(self._frame_metrics) > self._max_metrics_frames:
|
||||
self._frame_metrics.pop(0)
|
||||
self._current_frame_number += 1
|
||||
|
||||
return StageResult(success=True, data=current_data)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up all stages in reverse order."""
|
||||
for name in reversed(self._execution_order):
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
try:
|
||||
stage.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
self._stages.clear()
|
||||
self._initialized = False
|
||||
|
||||
@property
|
||||
def stages(self) -> dict[str, Stage]:
|
||||
"""Get all stages."""
|
||||
return self._stages.copy()
|
||||
|
||||
@property
|
||||
def execution_order(self) -> list[str]:
|
||||
"""Get execution order."""
|
||||
return self._execution_order.copy()
|
||||
|
||||
def get_stage_names(self) -> list[str]:
|
||||
"""Get list of stage names."""
|
||||
return list(self._stages.keys())
|
||||
|
||||
def get_metrics_summary(self) -> dict:
|
||||
"""Get summary of collected metrics."""
|
||||
if not self._frame_metrics:
|
||||
return {"error": "No metrics collected"}
|
||||
|
||||
total_times = [f.total_ms for f in self._frame_metrics]
|
||||
avg_total = sum(total_times) / len(total_times)
|
||||
min_total = min(total_times)
|
||||
max_total = max(total_times)
|
||||
|
||||
stage_stats: dict[str, dict] = {}
|
||||
for frame in self._frame_metrics:
|
||||
for stage in frame.stages:
|
||||
if stage.name not in stage_stats:
|
||||
stage_stats[stage.name] = {"times": [], "total_chars": 0}
|
||||
stage_stats[stage.name]["times"].append(stage.duration_ms)
|
||||
stage_stats[stage.name]["total_chars"] += stage.chars_out
|
||||
|
||||
for name, stats in stage_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._frame_metrics),
|
||||
"pipeline": {
|
||||
"avg_ms": avg_total,
|
||||
"min_ms": min_total,
|
||||
"max_ms": max_total,
|
||||
},
|
||||
"stages": stage_stats,
|
||||
}
|
||||
|
||||
def reset_metrics(self) -> None:
|
||||
"""Reset collected metrics."""
|
||||
self._frame_metrics.clear()
|
||||
self._current_frame_number = 0
|
||||
|
||||
|
||||
class PipelineRunner:
|
||||
"""High-level pipeline runner with animation support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Pipeline,
|
||||
params: PipelineParams | None = None,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.params = params or PipelineParams()
|
||||
self._running = False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the pipeline."""
|
||||
self._running = True
|
||||
return self.pipeline.initialize()
|
||||
|
||||
def step(self, input_data: Any | None = None) -> Any:
|
||||
"""Execute one pipeline step."""
|
||||
self.params.frame_number += 1
|
||||
self.pipeline.context.params = self.params
|
||||
result = self.pipeline.execute(input_data)
|
||||
return result.data if result.success else None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop and clean up the pipeline."""
|
||||
self._running = False
|
||||
self.pipeline.cleanup()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if runner is active."""
|
||||
return self._running
|
||||
|
||||
|
||||
def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
|
||||
"""Create a pipeline from PipelineParams."""
|
||||
config = PipelineConfig(
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order,
|
||||
)
|
||||
return Pipeline(config=config)
|
||||
|
||||
|
||||
def create_default_pipeline() -> Pipeline:
|
||||
"""Create a default pipeline with all standard components."""
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
from engine.sources_v2 import HeadlinesDataSource
|
||||
|
||||
pipeline = Pipeline()
|
||||
|
||||
# Add source stage (wrapped as Stage)
|
||||
source = HeadlinesDataSource()
|
||||
pipeline.add_stage("source", DataSourceStage(source, name="headlines"))
|
||||
|
||||
# Add display stage
|
||||
display = StageRegistry.create("display", "terminal")
|
||||
if display:
|
||||
pipeline.add_stage("display", display)
|
||||
|
||||
return pipeline.build()
|
||||
221
engine/pipeline/core.py
Normal file
221
engine/pipeline/core.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Pipeline core - Unified Stage abstraction and PipelineContext.
|
||||
|
||||
This module provides the foundation for a clean, dependency-managed pipeline:
|
||||
- Stage: Base class for all pipeline components (sources, effects, displays, cameras)
|
||||
- PipelineContext: Dependency injection context for runtime data exchange
|
||||
- Capability system: Explicit capability declarations with duck-typing support
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageConfig:
|
||||
"""Configuration for a single stage."""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
enabled: bool = True
|
||||
optional: bool = False
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Stage(ABC):
|
||||
"""Abstract base class for all pipeline stages.
|
||||
|
||||
A Stage is a single component in the rendering pipeline. Stages can be:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
|
||||
Stages declare:
|
||||
- capabilities: What they provide to other stages
|
||||
- dependencies: What they need from other stages
|
||||
|
||||
Duck-typing is supported: any class with the required methods can act as a Stage.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str # "source", "effect", "display", "camera"
|
||||
optional: bool = False # If True, pipeline continues even if stage fails
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
"""Return set of capabilities this stage provides.
|
||||
|
||||
Examples:
|
||||
- "source.headlines"
|
||||
- "effect.noise"
|
||||
- "display.output"
|
||||
- "camera"
|
||||
"""
|
||||
return {f"{self.category}.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
"""Return set of capability names this stage needs.
|
||||
|
||||
Examples:
|
||||
- {"display.output"}
|
||||
- {"source.headlines"}
|
||||
- {"camera"}
|
||||
"""
|
||||
return set()
|
||||
|
||||
def init(self, ctx: "PipelineContext") -> bool:
|
||||
"""Initialize stage with pipeline context.
|
||||
|
||||
Args:
|
||||
ctx: PipelineContext for accessing services
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded, False otherwise
|
||||
"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||
"""Process input data and return output.
|
||||
|
||||
Args:
|
||||
data: Input data from previous stage (or initial data for first stage)
|
||||
ctx: PipelineContext for accessing services and state
|
||||
|
||||
Returns:
|
||||
Processed data for next stage
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None: # noqa: B027
|
||||
"""Clean up resources when pipeline shuts down."""
|
||||
pass
|
||||
|
||||
def get_config(self) -> StageConfig:
|
||||
"""Return current configuration of this stage."""
|
||||
return StageConfig(
|
||||
name=self.name,
|
||||
category=self.category,
|
||||
optional=self.optional,
|
||||
)
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable this stage."""
|
||||
self._enabled = enabled # type: ignore[attr-defined]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if stage is enabled."""
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageResult:
|
||||
"""Result of stage processing, including success/failure info."""
|
||||
|
||||
success: bool
|
||||
data: Any
|
||||
error: str | None = None
|
||||
stage_name: str = ""
|
||||
|
||||
|
||||
class PipelineContext:
|
||||
"""Dependency injection context passed through the pipeline.
|
||||
|
||||
Provides:
|
||||
- services: Named services (display, config, event_bus, etc.)
|
||||
- state: Runtime state shared between stages
|
||||
- params: PipelineParams for animation-driven config
|
||||
|
||||
Services can be injected at construction time or lazily resolved.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
services: dict[str, Any] | None = None,
|
||||
initial_state: dict[str, Any] | None = None,
|
||||
):
|
||||
self.services: dict[str, Any] = services or {}
|
||||
self.state: dict[str, Any] = initial_state or {}
|
||||
self._params: PipelineParams | None = None
|
||||
|
||||
# Lazy resolvers for common services
|
||||
self._lazy_resolvers: dict[str, Callable[[], Any]] = {
|
||||
"config": self._resolve_config,
|
||||
"event_bus": self._resolve_event_bus,
|
||||
}
|
||||
|
||||
def _resolve_config(self) -> Any:
|
||||
from engine.config import get_config
|
||||
|
||||
return get_config()
|
||||
|
||||
def _resolve_event_bus(self) -> Any:
|
||||
from engine.eventbus import get_event_bus
|
||||
|
||||
return get_event_bus()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a service or state value by key.
|
||||
|
||||
First checks services, then state, then lazy resolution.
|
||||
"""
|
||||
if key in self.services:
|
||||
return self.services[key]
|
||||
if key in self.state:
|
||||
return self.state[key]
|
||||
if key in self._lazy_resolvers:
|
||||
try:
|
||||
return self._lazy_resolvers[key]()
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a service or state value."""
|
||||
self.services[key] = value
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a runtime state value."""
|
||||
self.state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a runtime state value."""
|
||||
return self.state.get(key, default)
|
||||
|
||||
@property
|
||||
def params(self) -> "PipelineParams | None":
|
||||
"""Get current pipeline params (for animation)."""
|
||||
return self._params
|
||||
|
||||
@params.setter
|
||||
def params(self, value: "PipelineParams") -> None:
|
||||
"""Set pipeline params (from animation controller)."""
|
||||
self._params = value
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Check if a capability is available."""
|
||||
return capability in self.services or capability in self._lazy_resolvers
|
||||
|
||||
|
||||
class StageError(Exception):
|
||||
"""Raised when a stage fails to process."""
|
||||
|
||||
def __init__(self, stage_name: str, message: str, is_optional: bool = False):
|
||||
self.stage_name = stage_name
|
||||
self.message = message
|
||||
self.is_optional = is_optional
|
||||
super().__init__(f"Stage '{stage_name}' failed: {message}")
|
||||
|
||||
|
||||
def create_stage_error(
|
||||
stage_name: str, error: Exception, is_optional: bool = False
|
||||
) -> StageError:
|
||||
"""Helper to create a StageError from an exception."""
|
||||
return StageError(stage_name, str(error), is_optional)
|
||||
144
engine/pipeline/params.py
Normal file
144
engine/pipeline/params.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Pipeline parameters - Runtime configuration layer for animation control.
|
||||
|
||||
PipelineParams is the target for AnimationController - animation events
|
||||
modify these params, which the pipeline then applies to its stages.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
"""Runtime configuration for the pipeline.
|
||||
|
||||
This is the canonical config object that AnimationController modifies.
|
||||
Stages read from these params to adjust their behavior.
|
||||
"""
|
||||
|
||||
# Source config
|
||||
source: str = "headlines"
|
||||
source_refresh_interval: float = 60.0
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0
|
||||
camera_x: int = 0 # For horizontal scrolling
|
||||
|
||||
# Effect config
|
||||
effect_order: list[str] = field(
|
||||
default_factory=lambda: ["noise", "fade", "glitch", "firehose", "hud"]
|
||||
)
|
||||
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
# Animation-driven state (set by AnimationController)
|
||||
pulse: float = 0.0
|
||||
current_effect: str | None = None
|
||||
path_progress: float = 0.0
|
||||
|
||||
# Viewport
|
||||
viewport_width: int = 80
|
||||
viewport_height: int = 24
|
||||
|
||||
# Firehose
|
||||
firehose_enabled: bool = False
|
||||
|
||||
# Runtime state
|
||||
frame_number: int = 0
|
||||
fps: float = 60.0
|
||||
|
||||
def get_effect_config(self, name: str) -> tuple[bool, float]:
|
||||
"""Get (enabled, intensity) for an effect."""
|
||||
enabled = self.effect_enabled.get(name, True)
|
||||
intensity = self.effect_intensity.get(name, 1.0)
|
||||
return enabled, intensity
|
||||
|
||||
def set_effect_config(self, name: str, enabled: bool, intensity: float) -> None:
|
||||
"""Set effect configuration."""
|
||||
self.effect_enabled[name] = enabled
|
||||
self.effect_intensity[name] = intensity
|
||||
|
||||
def is_effect_enabled(self, name: str) -> bool:
|
||||
"""Check if an effect is enabled."""
|
||||
if name not in self.effect_enabled:
|
||||
return True # Default to enabled
|
||||
return self.effect_enabled.get(name, True)
|
||||
|
||||
def get_effect_intensity(self, name: str) -> float:
|
||||
"""Get effect intensity (0.0 to 1.0)."""
|
||||
return self.effect_intensity.get(name, 1.0)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"source": self.source,
|
||||
"display": self.display,
|
||||
"camera_mode": self.camera_mode,
|
||||
"camera_speed": self.camera_speed,
|
||||
"effect_order": self.effect_order,
|
||||
"effect_enabled": self.effect_enabled.copy(),
|
||||
"effect_intensity": self.effect_intensity.copy(),
|
||||
"pulse": self.pulse,
|
||||
"current_effect": self.current_effect,
|
||||
"viewport_width": self.viewport_width,
|
||||
"viewport_height": self.viewport_height,
|
||||
"firehose_enabled": self.firehose_enabled,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PipelineParams":
|
||||
"""Create from dictionary."""
|
||||
params = cls()
|
||||
for key, value in data.items():
|
||||
if hasattr(params, key):
|
||||
setattr(params, key, value)
|
||||
return params
|
||||
|
||||
def copy(self) -> "PipelineParams":
|
||||
"""Create a copy of this params object."""
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.camera_mode = self.camera_mode
|
||||
params.camera_speed = self.camera_speed
|
||||
params.camera_x = self.camera_x
|
||||
params.effect_order = self.effect_order.copy()
|
||||
params.effect_enabled = self.effect_enabled.copy()
|
||||
params.effect_intensity = self.effect_intensity.copy()
|
||||
params.pulse = self.pulse
|
||||
params.current_effect = self.current_effect
|
||||
params.path_progress = self.path_progress
|
||||
params.viewport_width = self.viewport_width
|
||||
params.viewport_height = self.viewport_height
|
||||
params.firehose_enabled = self.firehose_enabled
|
||||
params.frame_number = self.frame_number
|
||||
params.fps = self.fps
|
||||
return params
|
||||
|
||||
|
||||
# Default params for different modes
|
||||
DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
)
|
||||
|
||||
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
)
|
||||
|
||||
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||
source="pipeline",
|
||||
display="pygame",
|
||||
camera_mode="trace",
|
||||
effect_order=["hud"], # Just HUD for pipeline viz
|
||||
)
|
||||
155
engine/pipeline/presets.py
Normal file
155
engine/pipeline/presets.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Pipeline presets - Pre-configured pipeline configurations.
|
||||
|
||||
Provides PipelinePreset as a unified preset system that wraps
|
||||
the existing Preset class from animation.py for backwards compatibility.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from engine.animation import Preset as AnimationPreset
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
"""Pre-configured pipeline with stages and animation.
|
||||
|
||||
A PipelinePreset packages:
|
||||
- Initial params: Starting configuration
|
||||
- Stages: List of stage configurations to create
|
||||
- Animation: Optional animation controller
|
||||
|
||||
This is the new unified preset that works with the Pipeline class.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "vertical"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
initial_params: PipelineParams | None = None
|
||||
animation_preset: AnimationPreset | None = None
|
||||
|
||||
def to_params(self) -> PipelineParams:
|
||||
"""Convert to PipelineParams."""
|
||||
if self.initial_params:
|
||||
return self.initial_params.copy()
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.camera_mode = self.camera
|
||||
params.effect_order = self.effects.copy()
|
||||
return params
|
||||
|
||||
@classmethod
|
||||
def from_animation_preset(cls, preset: AnimationPreset) -> "PipelinePreset":
|
||||
"""Create a PipelinePreset from an existing animation Preset."""
|
||||
params = preset.initial_params
|
||||
return cls(
|
||||
name=preset.name,
|
||||
description=preset.description,
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy(),
|
||||
initial_params=params,
|
||||
animation_preset=preset,
|
||||
)
|
||||
|
||||
def create_animation_controller(self):
|
||||
"""Create an AnimationController from this preset."""
|
||||
if self.animation_preset:
|
||||
return self.animation_preset.create_controller()
|
||||
return None
|
||||
|
||||
|
||||
# Built-in presets
|
||||
DEMO_PRESET = PipelinePreset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
)
|
||||
|
||||
POETRY_PRESET = PipelinePreset(
|
||||
name="poetry",
|
||||
description="Poetry feed with subtle effects",
|
||||
source="poetry",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["fade", "hud"],
|
||||
)
|
||||
|
||||
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
name="pipeline",
|
||||
description="Pipeline visualization mode",
|
||||
source="pipeline",
|
||||
display="terminal",
|
||||
camera="trace",
|
||||
effects=["hud"],
|
||||
)
|
||||
|
||||
WEBSOCKET_PRESET = PipelinePreset(
|
||||
name="websocket",
|
||||
description="WebSocket display mode",
|
||||
source="headlines",
|
||||
display="websocket",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
)
|
||||
|
||||
SIXEL_PRESET = PipelinePreset(
|
||||
name="sixel",
|
||||
description="Sixel graphics display mode",
|
||||
source="headlines",
|
||||
display="sixel",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
)
|
||||
|
||||
FIREHOSE_PRESET = PipelinePreset(
|
||||
name="firehose",
|
||||
description="High-speed firehose mode",
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
)
|
||||
|
||||
|
||||
PRESETS: dict[str, PipelinePreset] = {
|
||||
"demo": DEMO_PRESET,
|
||||
"poetry": POETRY_PRESET,
|
||||
"pipeline": PIPELINE_VIZ_PRESET,
|
||||
"websocket": WEBSOCKET_PRESET,
|
||||
"sixel": SIXEL_PRESET,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
}
|
||||
|
||||
|
||||
def get_preset(name: str) -> PipelinePreset | None:
|
||||
"""Get a preset by name."""
|
||||
return PRESETS.get(name)
|
||||
|
||||
|
||||
def list_presets() -> list[str]:
|
||||
"""List all available preset names."""
|
||||
return list(PRESETS.keys())
|
||||
|
||||
|
||||
def create_preset_from_params(
|
||||
params: PipelineParams, name: str = "custom"
|
||||
) -> PipelinePreset:
|
||||
"""Create a preset from PipelineParams."""
|
||||
return PipelinePreset(
|
||||
name=name,
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy(),
|
||||
initial_params=params,
|
||||
)
|
||||
165
engine/pipeline/registry.py
Normal file
165
engine/pipeline/registry.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Stage registry - Unified registration for all pipeline stages.
|
||||
|
||||
Provides a single registry for sources, effects, displays, and cameras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from engine.pipeline.core import Stage
|
||||
|
||||
|
||||
class StageRegistry:
|
||||
"""Unified registry for all pipeline stage types."""
|
||||
|
||||
_categories: dict[str, dict[str, type[Stage]]] = {}
|
||||
_discovered: bool = False
|
||||
_instances: dict[str, Stage] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, category: str, stage_class: type[Stage]) -> None:
|
||||
"""Register a stage class in a category.
|
||||
|
||||
Args:
|
||||
category: Category name (source, effect, display, camera)
|
||||
stage_class: Stage subclass to register
|
||||
"""
|
||||
if category not in cls._categories:
|
||||
cls._categories[category] = {}
|
||||
|
||||
# Use class name as key
|
||||
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||
cls._categories[category][key] = stage_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, category: str, name: str) -> type[Stage] | None:
|
||||
"""Get a stage class by category and name."""
|
||||
return cls._categories.get(category, {}).get(name)
|
||||
|
||||
@classmethod
|
||||
def list(cls, category: str) -> list[str]:
|
||||
"""List all stage names in a category."""
|
||||
return list(cls._categories.get(category, {}).keys())
|
||||
|
||||
@classmethod
|
||||
def list_categories(cls) -> list[str]:
|
||||
"""List all registered categories."""
|
||||
return list(cls._categories.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, category: str, name: str, **kwargs) -> Stage | None:
|
||||
"""Create a stage instance by category and name."""
|
||||
stage_class = cls.get(category, name)
|
||||
if stage_class:
|
||||
return stage_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, stage: Stage | type[Stage], **kwargs) -> Stage:
|
||||
"""Create an instance from a stage class or return as-is."""
|
||||
if isinstance(stage, Stage):
|
||||
return stage
|
||||
if isinstance(stage, type) and issubclass(stage, Stage):
|
||||
return stage(**kwargs)
|
||||
raise TypeError(f"Expected Stage class or instance, got {type(stage)}")
|
||||
|
||||
@classmethod
|
||||
def register_instance(cls, name: str, stage: Stage) -> None:
|
||||
"""Register a stage instance by name."""
|
||||
cls._instances[name] = stage
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, name: str) -> Stage | None:
|
||||
"""Get a registered stage instance by name."""
|
||||
return cls._instances.get(name)
|
||||
|
||||
|
||||
def discover_stages() -> None:
|
||||
"""Auto-discover and register all stage implementations."""
|
||||
if StageRegistry._discovered:
|
||||
return
|
||||
|
||||
# Import and register all stage implementations
|
||||
try:
|
||||
from engine.sources_v2 import (
|
||||
HeadlinesDataSource,
|
||||
PipelineDataSource,
|
||||
PoetryDataSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", HeadlinesDataSource)
|
||||
StageRegistry.register("source", PoetryDataSource)
|
||||
StageRegistry.register("source", PipelineDataSource)
|
||||
|
||||
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||
StageRegistry._categories["source"]["pipeline"] = PipelineDataSource
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.effects.types import EffectPlugin # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register display stages
|
||||
_register_display_stages()
|
||||
|
||||
StageRegistry._discovered = True
|
||||
|
||||
|
||||
def _register_display_stages() -> None:
|
||||
"""Register display backends as stages."""
|
||||
try:
|
||||
from engine.display import DisplayRegistry
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
for backend_name in DisplayRegistry.list_backends():
|
||||
factory = _DisplayStageFactory(backend_name)
|
||||
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
|
||||
|
||||
|
||||
class _DisplayStageFactory:
|
||||
"""Factory that creates DisplayStage instances for a specific backend."""
|
||||
|
||||
def __init__(self, backend_name: str):
|
||||
self._backend_name = backend_name
|
||||
|
||||
def __call__(self):
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.pipeline.adapters import DisplayStage
|
||||
|
||||
display = DisplayRegistry.create(self._backend_name)
|
||||
if display is None:
|
||||
raise RuntimeError(
|
||||
f"Failed to create display backend: {self._backend_name}"
|
||||
)
|
||||
return DisplayStage(display, name=self._backend_name)
|
||||
|
||||
@property
|
||||
def __name__(self) -> str:
|
||||
return self._backend_name.capitalize() + "Stage"
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def register_source(stage_class: type[Stage]) -> None:
|
||||
"""Register a source stage."""
|
||||
StageRegistry.register("source", stage_class)
|
||||
|
||||
|
||||
def register_effect(stage_class: type[Stage]) -> None:
|
||||
"""Register an effect stage."""
|
||||
StageRegistry.register("effect", stage_class)
|
||||
|
||||
|
||||
def register_display(stage_class: type[Stage]) -> None:
|
||||
"""Register a display stage."""
|
||||
StageRegistry.register("display", stage_class)
|
||||
|
||||
|
||||
def register_camera(stage_class: type[Stage]) -> None:
|
||||
"""Register a camera stage."""
|
||||
StageRegistry.register("camera", stage_class)
|
||||
364
engine/pipeline_viz.py
Normal file
364
engine/pipeline_viz.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Pipeline visualization - Large animated network visualization with camera modes.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
NODE_NETWORK = {
|
||||
"sources": [
|
||||
{"id": "RSS", "label": "RSS FEEDS", "x": 20, "y": 20},
|
||||
{"id": "POETRY", "label": "POETRY DB", "x": 100, "y": 20},
|
||||
{"id": "NTFY", "label": "NTFY MSG", "x": 180, "y": 20},
|
||||
{"id": "MIC", "label": "MICROPHONE", "x": 260, "y": 20},
|
||||
],
|
||||
"fetch": [
|
||||
{"id": "FETCH", "label": "FETCH LAYER", "x": 140, "y": 100},
|
||||
{"id": "CACHE", "label": "CACHE", "x": 220, "y": 100},
|
||||
],
|
||||
"scroll": [
|
||||
{"id": "STREAM", "label": "STREAM CTRL", "x": 60, "y": 180},
|
||||
{"id": "CAMERA", "label": "CAMERA", "x": 140, "y": 180},
|
||||
{"id": "RENDER", "label": "RENDER", "x": 220, "y": 180},
|
||||
],
|
||||
"effects": [
|
||||
{"id": "NOISE", "label": "NOISE", "x": 20, "y": 260},
|
||||
{"id": "FADE", "label": "FADE", "x": 80, "y": 260},
|
||||
{"id": "GLITCH", "label": "GLITCH", "x": 140, "y": 260},
|
||||
{"id": "FIRE", "label": "FIREHOSE", "x": 200, "y": 260},
|
||||
{"id": "HUD", "label": "HUD", "x": 260, "y": 260},
|
||||
],
|
||||
"display": [
|
||||
{"id": "TERM", "label": "TERMINAL", "x": 20, "y": 340},
|
||||
{"id": "WEB", "label": "WEBSOCKET", "x": 80, "y": 340},
|
||||
{"id": "PYGAME", "label": "PYGAME", "x": 140, "y": 340},
|
||||
{"id": "SIXEL", "label": "SIXEL", "x": 200, "y": 340},
|
||||
{"id": "KITTY", "label": "KITTY", "x": 260, "y": 340},
|
||||
],
|
||||
}
|
||||
|
||||
ALL_NODES = []
|
||||
for group_nodes in NODE_NETWORK.values():
|
||||
ALL_NODES.extend(group_nodes)
|
||||
|
||||
NETWORK_PATHS = [
|
||||
["RSS", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "NOISE", "TERM"],
|
||||
["POETRY", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "FADE", "WEB"],
|
||||
["NTFY", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "GLITCH", "PYGAME"],
|
||||
["MIC", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "FIRE", "SIXEL"],
|
||||
["RSS", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "HUD", "KITTY"],
|
||||
]
|
||||
|
||||
GRID_WIDTH = 300
|
||||
GRID_HEIGHT = 400
|
||||
|
||||
|
||||
def get_node_by_id(node_id: str):
|
||||
for node in ALL_NODES:
|
||||
if node["id"] == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def draw_network_to_grid(frame: int = 0) -> list[list[str]]:
|
||||
grid = [[" " for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
|
||||
|
||||
active_path_idx = (frame // 60) % len(NETWORK_PATHS)
|
||||
active_path = NETWORK_PATHS[active_path_idx]
|
||||
|
||||
for node in ALL_NODES:
|
||||
x, y = node["x"], node["y"]
|
||||
label = node["label"]
|
||||
is_active = node["id"] in active_path
|
||||
is_highlight = node["id"] == active_path[(frame // 15) % len(active_path)]
|
||||
|
||||
node_w, node_h = 20, 7
|
||||
|
||||
for dy in range(node_h):
|
||||
for dx in range(node_w):
|
||||
gx, gy = x + dx, y + dy
|
||||
if 0 <= gx < GRID_WIDTH and 0 <= gy < GRID_HEIGHT:
|
||||
if dy == 0:
|
||||
char = "┌" if dx == 0 else ("┐" if dx == node_w - 1 else "─")
|
||||
elif dy == node_h - 1:
|
||||
char = "└" if dx == 0 else ("┘" if dx == node_w - 1 else "─")
|
||||
elif dy == node_h // 2:
|
||||
if dx == 0 or dx == node_w - 1:
|
||||
char = "│"
|
||||
else:
|
||||
pad = (node_w - 2 - len(label)) // 2
|
||||
if dx - 1 == pad and len(label) <= node_w - 2:
|
||||
char = (
|
||||
label[dx - 1 - pad]
|
||||
if dx - 1 - pad < len(label)
|
||||
else " "
|
||||
)
|
||||
else:
|
||||
char = " "
|
||||
else:
|
||||
char = "│" if dx == 0 or dx == node_w - 1 else " "
|
||||
|
||||
if char.strip():
|
||||
if is_highlight:
|
||||
grid[gy][gx] = "\033[1;38;5;46m" + char + "\033[0m"
|
||||
elif is_active:
|
||||
grid[gy][gx] = "\033[1;38;5;220m" + char + "\033[0m"
|
||||
else:
|
||||
grid[gy][gx] = "\033[38;5;240m" + char + "\033[0m"
|
||||
|
||||
for i, node_id in enumerate(active_path[:-1]):
|
||||
curr = get_node_by_id(node_id)
|
||||
next_id = active_path[i + 1]
|
||||
next_node = get_node_by_id(next_id)
|
||||
if curr and next_node:
|
||||
x1, y1 = curr["x"] + 7, curr["y"] + 2
|
||||
x2, y2 = next_node["x"] + 7, next_node["y"] + 2
|
||||
|
||||
step = 1 if x2 >= x1 else -1
|
||||
for x in range(x1, x2 + step, step):
|
||||
if 0 <= x < GRID_WIDTH and 0 <= y1 < GRID_HEIGHT:
|
||||
grid[y1][x] = "\033[38;5;45m─\033[0m"
|
||||
|
||||
step = 1 if y2 >= y1 else -1
|
||||
for y in range(y1, y2 + step, step):
|
||||
if 0 <= x2 < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
|
||||
grid[y][x2] = "\033[38;5;45m│\033[0m"
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
class TraceCamera:
|
||||
def __init__(self):
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self.target_x = 0
|
||||
self.target_y = 0
|
||||
self.current_node_idx = 0
|
||||
self.path = []
|
||||
self.frame = 0
|
||||
|
||||
def update(self, dt: float, frame: int = 0) -> None:
|
||||
self.frame = frame
|
||||
active_path = NETWORK_PATHS[(frame // 60) % len(NETWORK_PATHS)]
|
||||
|
||||
if self.path != active_path:
|
||||
self.path = active_path
|
||||
self.current_node_idx = 0
|
||||
|
||||
if self.current_node_idx < len(self.path):
|
||||
node_id = self.path[self.current_node_idx]
|
||||
node = get_node_by_id(node_id)
|
||||
if node:
|
||||
self.target_x = max(0, node["x"] - 40)
|
||||
self.target_y = max(0, node["y"] - 10)
|
||||
|
||||
self.current_node_idx += 1
|
||||
|
||||
self.x += int((self.target_x - self.x) * 0.1)
|
||||
self.y += int((self.target_y - self.y) * 0.1)
|
||||
|
||||
|
||||
class CameraLarge:
|
||||
def __init__(self, viewport_w: int, viewport_h: int, frame: int):
|
||||
self.viewport_w = viewport_w
|
||||
self.viewport_h = viewport_h
|
||||
self.frame = frame
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self.mode = "trace"
|
||||
self.trace_camera = TraceCamera()
|
||||
|
||||
def set_vertical_mode(self):
|
||||
self.mode = "vertical"
|
||||
|
||||
def set_horizontal_mode(self):
|
||||
self.mode = "horizontal"
|
||||
|
||||
def set_omni_mode(self):
|
||||
self.mode = "omni"
|
||||
|
||||
def set_floating_mode(self):
|
||||
self.mode = "floating"
|
||||
|
||||
def set_trace_mode(self):
|
||||
self.mode = "trace"
|
||||
|
||||
def update(self, dt: float):
|
||||
self.frame += 1
|
||||
|
||||
if self.mode == "vertical":
|
||||
self.y = int((self.frame * 0.5) % (GRID_HEIGHT - self.viewport_h))
|
||||
elif self.mode == "horizontal":
|
||||
self.x = int((self.frame * 0.5) % (GRID_WIDTH - self.viewport_w))
|
||||
elif self.mode == "omni":
|
||||
self.x = int((self.frame * 0.3) % (GRID_WIDTH - self.viewport_w))
|
||||
self.y = int((self.frame * 0.5) % (GRID_HEIGHT - self.viewport_h))
|
||||
elif self.mode == "floating":
|
||||
self.x = int(50 + math.sin(self.frame * 0.02) * 30)
|
||||
self.y = int(50 + math.cos(self.frame * 0.015) * 30)
|
||||
elif self.mode == "trace":
|
||||
self.trace_camera.update(dt, self.frame)
|
||||
self.x = self.trace_camera.x
|
||||
self.y = self.trace_camera.y
|
||||
|
||||
|
||||
def generate_mermaid_graph(frame: int = 0) -> str:
|
||||
effects = ["NOISE", "FADE", "GLITCH", "FIREHOSE"]
|
||||
active_effect = effects[(frame // 30) % 4]
|
||||
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
active_cam = cam_modes[(frame // 100) % 5]
|
||||
|
||||
return f"""graph LR
|
||||
subgraph SOURCES
|
||||
RSS[RSS Feeds]
|
||||
Poetry[Poetry DB]
|
||||
Ntfy[Ntfy Msg]
|
||||
Mic[Microphone]
|
||||
end
|
||||
|
||||
subgraph FETCH
|
||||
Fetch(fetch_all)
|
||||
Cache[(Cache)]
|
||||
end
|
||||
|
||||
subgraph SCROLL
|
||||
Scroll(StreamController)
|
||||
Camera({active_cam})
|
||||
end
|
||||
|
||||
subgraph EFFECTS
|
||||
Noise[NOISE]
|
||||
Fade[FADE]
|
||||
Glitch[GLITCH]
|
||||
Fire[FIREHOSE]
|
||||
Hud[HUD]
|
||||
end
|
||||
|
||||
subgraph DISPLAY
|
||||
Term[Terminal]
|
||||
Web[WebSocket]
|
||||
Pygame[PyGame]
|
||||
Sixel[Sixel]
|
||||
end
|
||||
|
||||
RSS --> Fetch
|
||||
Poetry --> Fetch
|
||||
Ntfy --> Fetch
|
||||
Fetch --> Cache
|
||||
Cache --> Scroll
|
||||
Scroll --> Noise
|
||||
Scroll --> Fade
|
||||
Scroll --> Glitch
|
||||
Scroll --> Fire
|
||||
Scroll --> Hud
|
||||
|
||||
Noise --> Term
|
||||
Fade --> Web
|
||||
Glitch --> Pygame
|
||||
Fire --> Sixel
|
||||
|
||||
style {active_effect} fill:#90EE90
|
||||
style Camera fill:#87CEEB
|
||||
"""
|
||||
|
||||
|
||||
def generate_network_pipeline(
|
||||
width: int = 80, height: int = 24, frame: int = 0
|
||||
) -> list[str]:
|
||||
try:
|
||||
from engine.beautiful_mermaid import render_mermaid_ascii
|
||||
|
||||
mermaid_graph = generate_mermaid_graph(frame)
|
||||
ascii_output = render_mermaid_ascii(mermaid_graph, padding_x=2, padding_y=1)
|
||||
|
||||
lines = ascii_output.split("\n")
|
||||
|
||||
result = []
|
||||
for y in range(height):
|
||||
if y < len(lines):
|
||||
line = lines[y]
|
||||
if len(line) < width:
|
||||
line = line + " " * (width - len(line))
|
||||
elif len(line) > width:
|
||||
line = line[:width]
|
||||
result.append(line)
|
||||
else:
|
||||
result.append(" " * width)
|
||||
|
||||
status_y = height - 2
|
||||
if status_y < height:
|
||||
fps = 60 - (frame % 15)
|
||||
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
cam = cam_modes[(frame // 100) % 5]
|
||||
effects = ["NOISE", "FADE", "GLITCH", "FIREHOSE"]
|
||||
eff = effects[(frame // 30) % 4]
|
||||
|
||||
anim = "▓▒░ "[frame % 4]
|
||||
status = f" FPS:{fps:3.0f} │ {anim} {eff} │ Cam:{cam}"
|
||||
status = status[: width - 4].ljust(width - 4)
|
||||
result[status_y] = "║ " + status + " ║"
|
||||
|
||||
if height > 0:
|
||||
result[0] = "═" * width
|
||||
result[height - 1] = "═" * width
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return [
|
||||
f"Error: {e}" + " " * (width - len(f"Error: {e}")) for _ in range(height)
|
||||
]
|
||||
|
||||
|
||||
def generate_large_network_viewport(
|
||||
viewport_w: int = 80, viewport_h: int = 24, frame: int = 0
|
||||
) -> list[str]:
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
camera_mode = cam_modes[(frame // 100) % 5]
|
||||
|
||||
camera = CameraLarge(viewport_w, viewport_h, frame)
|
||||
|
||||
if camera_mode == "TRACE":
|
||||
camera.set_trace_mode()
|
||||
elif camera_mode == "VERTICAL":
|
||||
camera.set_vertical_mode()
|
||||
elif camera_mode == "HORIZONTAL":
|
||||
camera.set_horizontal_mode()
|
||||
elif camera_mode == "OMNI":
|
||||
camera.set_omni_mode()
|
||||
elif camera_mode == "FLOATING":
|
||||
camera.set_floating_mode()
|
||||
|
||||
camera.update(1 / 60)
|
||||
|
||||
grid = draw_network_to_grid(frame)
|
||||
|
||||
result = []
|
||||
for vy in range(viewport_h):
|
||||
line = ""
|
||||
for vx in range(viewport_w):
|
||||
gx = camera.x + vx
|
||||
gy = camera.y + vy
|
||||
if 0 <= gx < GRID_WIDTH and 0 <= gy < GRID_HEIGHT:
|
||||
line += grid[gy][gx]
|
||||
else:
|
||||
line += " "
|
||||
result.append(line)
|
||||
|
||||
fps = 60 - (frame % 15)
|
||||
|
||||
active_path = NETWORK_PATHS[(frame // 60) % len(NETWORK_PATHS)]
|
||||
active_node = active_path[(frame // 15) % len(active_path)]
|
||||
|
||||
anim = "▓▒░ "[frame % 4]
|
||||
status = f" FPS:{fps:3.0f} │ {anim} {camera_mode:9s} │ Node:{active_node}"
|
||||
status = status[: viewport_w - 4].ljust(viewport_w - 4)
|
||||
if viewport_h > 2:
|
||||
result[viewport_h - 2] = "║ " + status + " ║"
|
||||
|
||||
if viewport_h > 0:
|
||||
result[0] = "═" * viewport_w
|
||||
result[viewport_h - 1] = "═" * viewport_w
|
||||
|
||||
return result
|
||||
@@ -4,44 +4,63 @@ Orchestrates viewport, frame timing, and layers.
|
||||
"""
|
||||
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
from engine import config
|
||||
from engine.camera import Camera
|
||||
from engine.display import (
|
||||
Display,
|
||||
TerminalDisplay,
|
||||
)
|
||||
from engine.display import (
|
||||
get_monitor as _get_display_monitor,
|
||||
)
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
process_effects,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
)
|
||||
from engine.terminal import CLR
|
||||
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,
|
||||
camera: Camera | None = None,
|
||||
):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
if display is None:
|
||||
display = TerminalDisplay()
|
||||
if camera is None:
|
||||
camera = Camera.vertical()
|
||||
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
queued = 0
|
||||
|
||||
time.sleep(0.5)
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
|
||||
w, h = tw(), th()
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
active = []
|
||||
scroll_cam = 0
|
||||
ticker_next_y = ticker_view_h
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
msg_cache = (None, None)
|
||||
frame_number = 0
|
||||
|
||||
while True:
|
||||
if queued >= config.HEADLINE_LIMIT and not active:
|
||||
@@ -62,10 +81,10 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
scroll_cam += 1
|
||||
camera.update(config.FRAME_DT)
|
||||
|
||||
while (
|
||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||
ticker_next_y < camera.y + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
from engine.effects import next_headline
|
||||
@@ -78,34 +97,55 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
queued += 1
|
||||
|
||||
active = [
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > camera.y
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < scroll_cam:
|
||||
if k < camera.y:
|
||||
del noise_cache[k]
|
||||
|
||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
ticker_buf_start = len(buf)
|
||||
|
||||
ticker_buf, noise_cache = render_ticker_zone(
|
||||
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||
active, camera.y, camera.x, ticker_h, w, noise_cache, grad_offset
|
||||
)
|
||||
buf.extend(ticker_buf)
|
||||
|
||||
mic_excess = mic_monitor.excess
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
render_start = time.perf_counter()
|
||||
|
||||
if USE_EFFECT_CHAIN:
|
||||
buf = process_effects(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
camera.y,
|
||||
ticker_h,
|
||||
camera.x,
|
||||
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)
|
||||
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
|
||||
sys.stdout.buffer.write("".join(buf).encode())
|
||||
sys.stdout.flush()
|
||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||
monitor = _get_display_monitor()
|
||||
if monitor:
|
||||
chars = sum(len(line) for line in buf)
|
||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||
|
||||
display.show(buf)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
frame_number += 1
|
||||
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
display.cleanup()
|
||||
|
||||
362
engine/sources_v2.py
Normal file
362
engine/sources_v2.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Data source abstraction - Treat data sources as first-class citizens in the pipeline.
|
||||
|
||||
Each data source implements a common interface:
|
||||
- name: Display name for the source
|
||||
- fetch(): Fetch fresh data
|
||||
- stream(): Stream data continuously (optional)
|
||||
- get_items(): Get current items
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceItem:
|
||||
"""A single item from a data source."""
|
||||
|
||||
content: str
|
||||
source: str
|
||||
timestamp: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources.
|
||||
|
||||
Static sources: Data fetched once and cached. Safe to call fetch() multiple times.
|
||||
Dynamic sources: Data changes over time. fetch() should be idempotent.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Display name for this source."""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
"""Whether this source updates dynamically while the app runs. Default False."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Fetch fresh data from the source. Must be idempotent."""
|
||||
...
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
"""Get current items. Default implementation returns cached fetch results."""
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def refresh(self) -> list[SourceItem]:
|
||||
"""Force refresh - clear cache and fetch fresh data."""
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def stream(self):
|
||||
"""Optional: Yield items continuously. Override for streaming sources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __post_init__(self):
|
||||
self._items: list[SourceItem] | None = None
|
||||
|
||||
|
||||
class HeadlinesDataSource(DataSource):
|
||||
"""Data source for RSS feed headlines."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "headlines"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_all
|
||||
|
||||
items, _, _ = fetch_all()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class PoetryDataSource(DataSource):
|
||||
"""Data source for Poetry DB."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "poetry"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_poetry
|
||||
|
||||
items, _, _ = fetch_poetry()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class PipelineDataSource(DataSource):
|
||||
"""Data source for pipeline visualization (demo mode). Dynamic - updates every frame."""
|
||||
|
||||
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pipeline"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.pipeline_viz import generate_large_network_viewport
|
||||
|
||||
buffer = generate_large_network_viewport(
|
||||
self.viewport_width, self.viewport_height, self.frame
|
||||
)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="pipeline", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class MetricsDataSource(DataSource):
|
||||
"""Data source that renders live pipeline metrics as ASCII art.
|
||||
|
||||
Wraps a Pipeline and displays active stages with their average execution
|
||||
time and approximate FPS impact. Updates lazily when camera is about to
|
||||
focus on a new node (frame % 15 == 12).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._cached_metrics: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "metrics"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
if self.frame % 15 == 12:
|
||||
self._cached_metrics = None
|
||||
|
||||
if self._cached_metrics is None:
|
||||
self._cached_metrics = self._fetch_metrics()
|
||||
|
||||
buffer = self._render_metrics(self._cached_metrics)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def _fetch_metrics(self) -> dict:
|
||||
if hasattr(self.pipeline, "get_metrics_summary"):
|
||||
metrics = self.pipeline.get_metrics_summary()
|
||||
if "error" not in metrics:
|
||||
return metrics
|
||||
return {"stages": {}, "pipeline": {"avg_ms": 0}}
|
||||
|
||||
def _render_metrics(self, metrics: dict) -> list[str]:
|
||||
stages = metrics.get("stages", {})
|
||||
|
||||
if not stages:
|
||||
return self._render_empty()
|
||||
|
||||
active_stages = {
|
||||
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
|
||||
}
|
||||
|
||||
if not active_stages:
|
||||
return self._render_empty()
|
||||
|
||||
total_avg = sum(s["avg_ms"] for s in active_stages.values())
|
||||
if total_avg == 0:
|
||||
total_avg = 1
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("═" * self.viewport_width)
|
||||
lines.append(" PIPELINE METRICS ".center(self.viewport_width, "─"))
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
|
||||
lines.append(header)
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
for name, stats in sorted(active_stages.items()):
|
||||
avg_ms = stats.get("avg_ms", 0)
|
||||
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
|
||||
|
||||
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
|
||||
lines.append(row[: self.viewport_width])
|
||||
|
||||
lines.append("─" * self.viewport_width)
|
||||
total_row = (
|
||||
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
|
||||
)
|
||||
lines.append(total_row[: self.viewport_width])
|
||||
lines.append("─" * self.viewport_width)
|
||||
lines.append(
|
||||
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
|
||||
)
|
||||
|
||||
while len(lines) < self.viewport_height:
|
||||
lines.append(" " * self.viewport_width)
|
||||
|
||||
return lines[: self.viewport_height]
|
||||
|
||||
def _render_empty(self) -> list[str]:
|
||||
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
|
||||
msg = "No metrics available"
|
||||
y = self.viewport_height // 2
|
||||
x = (self.viewport_width - len(msg)) // 2
|
||||
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
|
||||
return lines
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class CachedDataSource(DataSource):
|
||||
"""Data source that wraps another source with caching."""
|
||||
|
||||
def __init__(self, source: DataSource, max_items: int = 100):
|
||||
self.source = source
|
||||
self.max_items = max_items
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"cached:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
return items[: self.max_items]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
|
||||
class TransformDataSource(DataSource):
|
||||
"""Data source that transforms items from another source.
|
||||
|
||||
Applies optional filter and map functions to each item.
|
||||
This enables chaining: source → transform → transformed output.
|
||||
|
||||
Args:
|
||||
source: The source to fetch items from
|
||||
filter_fn: Optional function(item: SourceItem) -> bool
|
||||
map_fn: Optional function(item: SourceItem) -> SourceItem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: DataSource,
|
||||
filter_fn: Callable[[SourceItem], bool] | None = None,
|
||||
map_fn: Callable[[SourceItem], SourceItem] | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.filter_fn = filter_fn
|
||||
self.map_fn = map_fn
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"transform:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
|
||||
if self.filter_fn:
|
||||
items = [item for item in items if self.filter_fn(item)]
|
||||
|
||||
if self.map_fn:
|
||||
items = [self.map_fn(item) for item in items]
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class CompositeDataSource(DataSource):
|
||||
"""Data source that combines multiple sources."""
|
||||
|
||||
def __init__(self, sources: list[DataSource]):
|
||||
self.sources = sources
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "composite"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = []
|
||||
for source in self.sources:
|
||||
items.extend(source.fetch())
|
||||
return items
|
||||
|
||||
|
||||
class SourceRegistry:
|
||||
"""Registry for data sources."""
|
||||
|
||||
def __init__(self):
|
||||
self._sources: dict[str, DataSource] = {}
|
||||
self._default: str | None = None
|
||||
|
||||
def register(self, source: DataSource, default: bool = False) -> None:
|
||||
self._sources[source.name] = source
|
||||
if default or self._default is None:
|
||||
self._default = source.name
|
||||
|
||||
def get(self, name: str) -> DataSource | None:
|
||||
return self._sources.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, DataSource]:
|
||||
return dict(self._sources)
|
||||
|
||||
def default(self) -> DataSource | None:
|
||||
if self._default:
|
||||
return self._sources.get(self._default)
|
||||
return None
|
||||
|
||||
def create_headlines(self) -> HeadlinesDataSource:
|
||||
return HeadlinesDataSource()
|
||||
|
||||
def create_poetry(self) -> PoetryDataSource:
|
||||
return PoetryDataSource()
|
||||
|
||||
def create_pipeline(self, width: int = 80, height: int = 24) -> PipelineDataSource:
|
||||
return PipelineDataSource(width, height)
|
||||
|
||||
|
||||
_global_registry: SourceRegistry | None = None
|
||||
|
||||
|
||||
def get_source_registry() -> SourceRegistry:
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = SourceRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def init_default_sources() -> SourceRegistry:
|
||||
"""Initialize the default source registry with standard sources."""
|
||||
registry = get_source_registry()
|
||||
registry.register(HeadlinesDataSource(), default=True)
|
||||
registry.register(PoetryDataSource())
|
||||
return registry
|
||||
3
hk.pkl
3
hk.pkl
@@ -22,6 +22,9 @@ hooks {
|
||||
prefix = "uv run"
|
||||
check = "ruff check engine/ tests/"
|
||||
}
|
||||
["benchmark"] {
|
||||
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
kitty_test.py
Normal file
31
kitty_test.py
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for Kitty graphics display."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def test_kitty_simple():
|
||||
"""Test simple Kitty graphics output with embedded PNG."""
|
||||
import base64
|
||||
|
||||
# Minimal 1x1 red pixel PNG (pre-encoded)
|
||||
# This is a tiny valid PNG with a red pixel
|
||||
png_red_1x1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00"
|
||||
b"\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00"
|
||||
b"\x03\x00\x01\x00\x05\xfe\xd4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
encoded = base64.b64encode(png_red_1x1).decode("ascii")
|
||||
|
||||
graphic = f"\x1b_Gf=100,t=d,s=1,v=1,c=1,r=1;{encoded}\x1b\\"
|
||||
sys.stdout.buffer.write(graphic.encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
|
||||
print("\n[If you see a red dot above, Kitty graphics is working!]")
|
||||
print("[If you see nothing or garbage, it's not working]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_kitty_simple()
|
||||
80
mise.toml
80
mise.toml
@@ -5,45 +5,101 @@ pkl = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Development
|
||||
# Testing
|
||||
# =====================
|
||||
|
||||
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"
|
||||
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
||||
|
||||
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
||||
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
||||
|
||||
# =====================
|
||||
# Linting & Formatting
|
||||
# =====================
|
||||
|
||||
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
|
||||
# Runtime Modes
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
||||
run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
|
||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Pipeline Architecture (unified Stage-based)
|
||||
# =====================
|
||||
|
||||
run-pipeline = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] }
|
||||
run-pipeline-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Presets (Animation-controlled modes)
|
||||
# =====================
|
||||
|
||||
run-preset-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-preset-pipeline = { run = "uv run mainline.py --preset pipeline --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Command & Control
|
||||
# =====================
|
||||
|
||||
cmd = "uv run cmdline.py"
|
||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Benchmark
|
||||
# =====================
|
||||
|
||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
||||
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
||||
|
||||
# Initialize ntfy topics (warm up before first use)
|
||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||
|
||||
# =====================
|
||||
# Daemon
|
||||
# =====================
|
||||
|
||||
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||
|
||||
# =====================
|
||||
# Environment
|
||||
# =====================
|
||||
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "uv sync"
|
||||
install-dev = "uv sync --group dev"
|
||||
install = "mise run sync"
|
||||
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
||||
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
||||
|
||||
bootstrap = "uv sync && uv run mainline.py --help"
|
||||
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
|
||||
# =====================
|
||||
# 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"
|
||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||
|
||||
# =====================
|
||||
# Git Hooks (via hk)
|
||||
|
||||
@@ -30,6 +30,18 @@ mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
websocket = [
|
||||
"websockets>=12.0",
|
||||
]
|
||||
sixel = [
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
pygame = [
|
||||
"pygame>=2.0.0",
|
||||
]
|
||||
browser = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
@@ -61,6 +73,12 @@ addopts = [
|
||||
"--tb=short",
|
||||
"-v",
|
||||
]
|
||||
markers = [
|
||||
"benchmark: marks tests as performance benchmarks (may be slow)",
|
||||
"e2e: marks tests as end-to-end tests (require network/display)",
|
||||
"integration: marks tests as integration tests (require external services)",
|
||||
"ntfy: marks tests that require ntfy service",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
36
tests/conftest.py
Normal file
36
tests/conftest.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Pytest configuration for mainline.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest to skip integration tests by default."""
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"integration: marks tests as integration tests (require external services)",
|
||||
)
|
||||
config.addinivalue_line("markers", "ntfy: marks tests that require ntfy service")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Skip integration/e2e tests unless explicitly requested with -m."""
|
||||
# Get the current marker expression
|
||||
marker_expr = config.getoption("-m", default="")
|
||||
|
||||
# If explicitly running integration or e2e, don't skip them
|
||||
if marker_expr in ("integration", "e2e", "integration or e2e"):
|
||||
return
|
||||
|
||||
# Skip integration tests
|
||||
skip_integration = pytest.mark.skip(reason="need -m integration to run")
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip_integration)
|
||||
|
||||
# Skip e2e tests by default (they require browser/display)
|
||||
skip_e2e = pytest.mark.skip(reason="need -m e2e to run")
|
||||
for item in items:
|
||||
if "e2e" in item.keywords and "integration" not in item.keywords:
|
||||
item.add_marker(skip_e2e)
|
||||
133
tests/e2e/test_web_client.py
Normal file
133
tests/e2e/test_web_client.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
End-to-end tests for web client with headless browser.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socketserver
|
||||
import threading
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
||||
|
||||
|
||||
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
"""Threaded HTTP server for handling concurrent requests."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def http_server():
|
||||
"""Start a local HTTP server for the client."""
|
||||
os.chdir(CLIENT_DIR)
|
||||
|
||||
handler = SimpleHTTPRequestHandler
|
||||
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
server.shutdown()
|
||||
|
||||
|
||||
class TestWebClient:
|
||||
"""Tests for the web client using Playwright."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_browser(self):
|
||||
"""Set up browser for tests."""
|
||||
pytest.importorskip("playwright")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self.playwright = sync_playwright().start()
|
||||
self.browser = self.playwright.chromium.launch(headless=True)
|
||||
self.context = self.browser.new_context()
|
||||
self.page = self.context.new_page()
|
||||
|
||||
yield
|
||||
|
||||
self.page.close()
|
||||
self.context.close()
|
||||
self.browser.close()
|
||||
self.playwright.stop()
|
||||
|
||||
def test_client_loads(self, http_server):
|
||||
"""Web client loads without errors."""
|
||||
response = self.page.goto(http_server)
|
||||
assert response.status == 200, f"Page load failed with status {response.status}"
|
||||
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
content = self.page.content()
|
||||
assert "<canvas" in content, "Canvas element not found in page"
|
||||
|
||||
canvas = self.page.locator("#terminal")
|
||||
assert canvas.count() > 0, "Canvas not found"
|
||||
|
||||
def test_status_shows_connecting(self, http_server):
|
||||
"""Status shows connecting initially."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
status = self.page.locator("#status")
|
||||
assert status.count() > 0, "Status element not found"
|
||||
|
||||
def test_canvas_has_dimensions(self, http_server):
|
||||
"""Canvas has correct dimensions after load."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
canvas = self.page.locator("#terminal")
|
||||
assert canvas.count() > 0, "Canvas not found"
|
||||
|
||||
def test_no_console_errors_on_load(self, http_server):
|
||||
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
||||
js_errors = []
|
||||
|
||||
def handle_console(msg):
|
||||
if msg.type == "error":
|
||||
text = msg.text
|
||||
if "WebSocket" not in text:
|
||||
js_errors.append(text)
|
||||
|
||||
self.page.on("console", handle_console)
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
||||
|
||||
|
||||
class TestWebClientProtocol:
|
||||
"""Tests for WebSocket protocol handling in client."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_browser(self):
|
||||
"""Set up browser for tests."""
|
||||
pytest.importorskip("playwright")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self.playwright = sync_playwright().start()
|
||||
self.browser = self.playwright.chromium.launch(headless=True)
|
||||
self.context = self.browser.new_context()
|
||||
self.page = self.context.new_page()
|
||||
|
||||
yield
|
||||
|
||||
self.page.close()
|
||||
self.context.close()
|
||||
self.browser.close()
|
||||
self.playwright.stop()
|
||||
|
||||
def test_websocket_reconnection(self, http_server):
|
||||
"""Client attempts reconnection on disconnect."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
status = self.page.locator("#status")
|
||||
assert status.count() > 0, "Status element not found"
|
||||
55
tests/test_app.py
Normal file
55
tests/test_app.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Tests for engine.app module.
|
||||
"""
|
||||
|
||||
from engine.app import _normalize_preview_rows
|
||||
|
||||
|
||||
class TestNormalizePreviewRows:
|
||||
"""Tests for _normalize_preview_rows function."""
|
||||
|
||||
def test_empty_rows(self):
|
||||
"""Empty input returns empty list."""
|
||||
result = _normalize_preview_rows([])
|
||||
assert result == [""]
|
||||
|
||||
def test_strips_left_padding(self):
|
||||
"""Left padding is stripped."""
|
||||
result = _normalize_preview_rows([" content", " more"])
|
||||
assert all(not r.startswith(" ") for r in result)
|
||||
|
||||
def test_preserves_content(self):
|
||||
"""Content is preserved."""
|
||||
result = _normalize_preview_rows([" hello world "])
|
||||
assert "hello world" in result[0]
|
||||
|
||||
def test_handles_all_empty_rows(self):
|
||||
"""All empty rows returns single empty string."""
|
||||
result = _normalize_preview_rows(["", " ", ""])
|
||||
assert result == [""]
|
||||
|
||||
|
||||
class TestAppConstants:
|
||||
"""Tests for app module constants."""
|
||||
|
||||
def test_title_defined(self):
|
||||
"""TITLE is defined."""
|
||||
from engine.app import TITLE
|
||||
|
||||
assert len(TITLE) > 0
|
||||
|
||||
def test_title_lines_are_strings(self):
|
||||
"""TITLE contains string lines."""
|
||||
from engine.app import TITLE
|
||||
|
||||
assert all(isinstance(line, str) for line in TITLE)
|
||||
|
||||
|
||||
class TestAppImports:
|
||||
"""Tests for app module imports."""
|
||||
|
||||
def test_app_imports_without_error(self):
|
||||
"""Module imports without error."""
|
||||
from engine import app
|
||||
|
||||
assert app is not None
|
||||
100
tests/test_benchmark.py
Normal file
100
tests/test_benchmark.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Tests for engine.benchmark module - performance regression tests.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display import NullDisplay
|
||||
|
||||
|
||||
class TestBenchmarkNullDisplay:
|
||||
"""Performance tests for NullDisplay - regression tests."""
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_null_display_minimum_fps(self):
|
||||
"""NullDisplay should meet minimum performance threshold."""
|
||||
import time
|
||||
|
||||
display = NullDisplay()
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 1000
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 20000
|
||||
|
||||
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_effects_minimum_throughput(self):
|
||||
"""Effects should meet minimum processing throughput."""
|
||||
import time
|
||||
|
||||
from effects_plugins import discover_plugins
|
||||
from engine.effects import EffectContext, get_registry
|
||||
|
||||
discover_plugins()
|
||||
registry = get_registry()
|
||||
effect = registry.get("noise")
|
||||
assert effect is not None, "Noise effect should be registered"
|
||||
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
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,
|
||||
)
|
||||
|
||||
iterations = 500
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
effect.process(buffer, ctx)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 10000
|
||||
|
||||
assert fps >= min_fps, (
|
||||
f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
|
||||
)
|
||||
|
||||
|
||||
class TestBenchmarkWebSocketDisplay:
|
||||
"""Performance tests for WebSocketDisplay."""
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_websocket_display_minimum_fps(self):
|
||||
"""WebSocketDisplay should meet minimum performance threshold."""
|
||||
import time
|
||||
|
||||
with patch("engine.display.backends.websocket.websockets", None):
|
||||
from engine.display import WebSocketDisplay
|
||||
|
||||
display = WebSocketDisplay()
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 500
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 10000
|
||||
|
||||
assert fps >= min_fps, (
|
||||
f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||
)
|
||||
69
tests/test_camera.py
Normal file
69
tests/test_camera.py
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
from engine.camera import Camera, CameraMode
|
||||
|
||||
|
||||
def test_camera_vertical_default():
|
||||
"""Test default vertical camera."""
|
||||
cam = Camera()
|
||||
assert cam.mode == CameraMode.VERTICAL
|
||||
assert cam.x == 0
|
||||
assert cam.y == 0
|
||||
|
||||
|
||||
def test_camera_vertical_factory():
|
||||
"""Test vertical factory method."""
|
||||
cam = Camera.vertical(speed=2.0)
|
||||
assert cam.mode == CameraMode.VERTICAL
|
||||
assert cam.speed == 2.0
|
||||
|
||||
|
||||
def test_camera_horizontal():
|
||||
"""Test horizontal camera."""
|
||||
cam = Camera.horizontal(speed=1.5)
|
||||
assert cam.mode == CameraMode.HORIZONTAL
|
||||
cam.update(1.0)
|
||||
assert cam.x > 0
|
||||
|
||||
|
||||
def test_camera_omni():
|
||||
"""Test omnidirectional camera."""
|
||||
cam = Camera.omni(speed=1.0)
|
||||
assert cam.mode == CameraMode.OMNI
|
||||
cam.update(1.0)
|
||||
assert cam.x > 0
|
||||
assert cam.y > 0
|
||||
|
||||
|
||||
def test_camera_floating():
|
||||
"""Test floating camera with sinusoidal motion."""
|
||||
cam = Camera.floating(speed=1.0)
|
||||
assert cam.mode == CameraMode.FLOATING
|
||||
y_before = cam.y
|
||||
cam.update(0.5)
|
||||
y_after = cam.y
|
||||
assert y_before != y_after
|
||||
|
||||
|
||||
def test_camera_reset():
|
||||
"""Test camera reset."""
|
||||
cam = Camera.vertical()
|
||||
cam.update(1.0)
|
||||
assert cam.y > 0
|
||||
cam.reset()
|
||||
assert cam.x == 0
|
||||
assert cam.y == 0
|
||||
|
||||
|
||||
def test_camera_custom_update():
|
||||
"""Test custom update function."""
|
||||
call_count = 0
|
||||
|
||||
def custom_update(camera, dt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
camera.x += int(10 * dt)
|
||||
|
||||
cam = Camera.custom(custom_update)
|
||||
cam.update(1.0)
|
||||
assert call_count == 1
|
||||
assert cam.x == 10
|
||||
@@ -5,7 +5,75 @@ Tests for engine.controller module.
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine import config
|
||||
from engine.controller import StreamController
|
||||
from engine.controller import StreamController, _get_display
|
||||
|
||||
|
||||
class TestGetDisplay:
|
||||
"""Tests for _get_display function."""
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
def test_get_display_terminal(self, mock_terminal, mock_ws):
|
||||
"""returns TerminalDisplay for display=terminal."""
|
||||
mock_terminal.return_value = MagicMock()
|
||||
mock_ws.return_value = MagicMock()
|
||||
|
||||
cfg = config.Config(display="terminal")
|
||||
display = _get_display(cfg)
|
||||
|
||||
mock_terminal.assert_called()
|
||||
assert isinstance(display, MagicMock)
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
def test_get_display_websocket(self, mock_terminal, mock_ws):
|
||||
"""returns WebSocketDisplay for display=websocket."""
|
||||
mock_ws_instance = MagicMock()
|
||||
mock_ws.return_value = mock_ws_instance
|
||||
mock_terminal.return_value = MagicMock()
|
||||
|
||||
cfg = config.Config(display="websocket")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_ws.assert_called()
|
||||
mock_ws_instance.start_server.assert_called()
|
||||
mock_ws_instance.start_http_server.assert_called()
|
||||
|
||||
@patch("engine.controller.SixelDisplay")
|
||||
def test_get_display_sixel(self, mock_sixel):
|
||||
"""returns SixelDisplay for display=sixel."""
|
||||
mock_sixel.return_value = MagicMock()
|
||||
cfg = config.Config(display="sixel")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_sixel.assert_called()
|
||||
|
||||
def test_get_display_unknown_returns_null(self):
|
||||
"""returns NullDisplay for unknown display mode."""
|
||||
cfg = config.Config(display="unknown")
|
||||
display = _get_display(cfg)
|
||||
|
||||
from engine.display import NullDisplay
|
||||
|
||||
assert isinstance(display, NullDisplay)
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
@patch("engine.controller.MultiDisplay")
|
||||
def test_get_display_both(self, mock_multi, mock_terminal, mock_ws):
|
||||
"""returns MultiDisplay for display=both."""
|
||||
mock_terminal_instance = MagicMock()
|
||||
mock_ws_instance = MagicMock()
|
||||
mock_terminal.return_value = mock_terminal_instance
|
||||
mock_ws.return_value = mock_ws_instance
|
||||
|
||||
cfg = config.Config(display="both")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_multi.assert_called()
|
||||
call_args = mock_multi.call_args[0][0]
|
||||
assert mock_terminal_instance in call_args
|
||||
assert mock_ws_instance in call_args
|
||||
|
||||
|
||||
class TestStreamController:
|
||||
@@ -68,6 +136,24 @@ class TestStreamController:
|
||||
assert mic_ok is False
|
||||
assert ntfy_ok is True
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
def test_initialize_sources_cc_subscribed(self, mock_mic):
|
||||
"""initialize_sources subscribes C&C handler."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = False
|
||||
mock_mic_instance.start.return_value = False
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
with patch("engine.controller.NtfyPoller") as mock_ntfy:
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
controller.initialize_sources()
|
||||
|
||||
mock_ntfy_instance.subscribe.assert_called()
|
||||
|
||||
|
||||
class TestStreamControllerCleanup:
|
||||
"""Tests for StreamController cleanup."""
|
||||
|
||||
210
tests/test_display.py
Normal file
210
tests/test_display.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Tests for engine.display module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
|
||||
|
||||
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 TestDisplayRegistry:
|
||||
"""Tests for DisplayRegistry class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset registry before each test."""
|
||||
DisplayRegistry._backends = {}
|
||||
DisplayRegistry._initialized = False
|
||||
|
||||
def test_register_adds_backend(self):
|
||||
"""register adds a backend to the registry."""
|
||||
DisplayRegistry.register("test", TerminalDisplay)
|
||||
assert DisplayRegistry.get("test") == TerminalDisplay
|
||||
|
||||
def test_register_case_insensitive(self):
|
||||
"""register is case insensitive."""
|
||||
DisplayRegistry.register("TEST", TerminalDisplay)
|
||||
assert DisplayRegistry.get("test") == TerminalDisplay
|
||||
|
||||
def test_get_returns_none_for_unknown(self):
|
||||
"""get returns None for unknown backend."""
|
||||
assert DisplayRegistry.get("unknown") is None
|
||||
|
||||
def test_list_backends_returns_all(self):
|
||||
"""list_backends returns all registered backends."""
|
||||
DisplayRegistry.register("a", TerminalDisplay)
|
||||
DisplayRegistry.register("b", NullDisplay)
|
||||
backends = DisplayRegistry.list_backends()
|
||||
assert "a" in backends
|
||||
assert "b" in backends
|
||||
|
||||
def test_create_returns_instance(self):
|
||||
"""create returns a display instance."""
|
||||
DisplayRegistry.register("test", NullDisplay)
|
||||
display = DisplayRegistry.create("test")
|
||||
assert isinstance(display, NullDisplay)
|
||||
|
||||
def test_create_returns_none_for_unknown(self):
|
||||
"""create returns None for unknown backend."""
|
||||
display = DisplayRegistry.create("unknown")
|
||||
assert display is None
|
||||
|
||||
def test_initialize_registers_defaults(self):
|
||||
"""initialize registers default backends."""
|
||||
DisplayRegistry.initialize()
|
||||
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
||||
assert DisplayRegistry.get("null") == NullDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
||||
assert DisplayRegistry.get("sixel") == SixelDisplay
|
||||
|
||||
def test_initialize_idempotent(self):
|
||||
"""initialize can be called multiple times safely."""
|
||||
DisplayRegistry.initialize()
|
||||
DisplayRegistry._backends["custom"] = TerminalDisplay
|
||||
DisplayRegistry.initialize()
|
||||
assert "custom" in DisplayRegistry.list_backends()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class TestMultiDisplay:
|
||||
"""Tests for MultiDisplay class."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores dimensions and forwards to displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.init(120, 40)
|
||||
|
||||
assert multi.width == 120
|
||||
assert multi.height == 40
|
||||
mock_display1.init.assert_called_once_with(120, 40, reuse=False)
|
||||
mock_display2.init.assert_called_once_with(120, 40, reuse=False)
|
||||
|
||||
def test_show_forwards_to_all_displays(self):
|
||||
"""show forwards buffer to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
buffer = ["line1", "line2"]
|
||||
multi.show(buffer)
|
||||
|
||||
mock_display1.show.assert_called_once_with(buffer)
|
||||
mock_display2.show.assert_called_once_with(buffer)
|
||||
|
||||
def test_clear_forwards_to_all_displays(self):
|
||||
"""clear forwards to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.clear()
|
||||
|
||||
mock_display1.clear.assert_called_once()
|
||||
mock_display2.clear.assert_called_once()
|
||||
|
||||
def test_cleanup_forwards_to_all_displays(self):
|
||||
"""cleanup forwards to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.cleanup()
|
||||
|
||||
mock_display1.cleanup.assert_called_once()
|
||||
mock_display2.cleanup.assert_called_once()
|
||||
|
||||
def test_empty_displays_list(self):
|
||||
"""handles empty displays list gracefully."""
|
||||
multi = MultiDisplay([])
|
||||
multi.init(80, 24)
|
||||
multi.show(["test"])
|
||||
multi.clear()
|
||||
multi.cleanup()
|
||||
|
||||
def test_init_with_reuse(self):
|
||||
"""init passes reuse flag to child displays."""
|
||||
mock_display = MagicMock()
|
||||
multi = MultiDisplay([mock_display])
|
||||
|
||||
multi.init(80, 24, reuse=True)
|
||||
|
||||
mock_display.init.assert_called_once_with(80, 24, reuse=True)
|
||||
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
|
||||
241
tests/test_effects_controller.py
Normal file
241
tests/test_effects_controller.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Tests for engine.effects.controller module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.effects.controller import (
|
||||
_format_stats,
|
||||
handle_effects_command,
|
||||
set_effect_chain_ref,
|
||||
show_effects_menu,
|
||||
)
|
||||
|
||||
|
||||
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_reorder_failure(self):
|
||||
"""reorder returns error on failure."""
|
||||
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 = False
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = handle_effects_command("/effects reorder bad")
|
||||
|
||||
assert "Failed to reorder" in result
|
||||
|
||||
def test_unknown_effect(self):
|
||||
"""unknown effect returns error."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_registry.return_value.list_all.return_value = {}
|
||||
|
||||
result = handle_effects_command("/effects unknown on")
|
||||
|
||||
assert "Unknown effect" in result
|
||||
|
||||
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
|
||||
|
||||
def test_invalid_intensity_value(self):
|
||||
"""invalid intensity value 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 bad")
|
||||
|
||||
assert "Invalid intensity" in result
|
||||
|
||||
def test_missing_action(self):
|
||||
"""missing action returns usage."""
|
||||
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")
|
||||
|
||||
assert "Usage" in result
|
||||
|
||||
def test_stats_command(self):
|
||||
"""stats command returns formatted stats."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {
|
||||
"frame_count": 100,
|
||||
"pipeline": {"avg_ms": 1.5, "min_ms": 1.0, "max_ms": 2.0},
|
||||
"effects": {},
|
||||
}
|
||||
|
||||
result = handle_effects_command("/effects stats")
|
||||
|
||||
assert "Performance Stats" in result
|
||||
|
||||
def test_list_only_effects(self):
|
||||
"""list command works with just /effects."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = False
|
||||
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 = None
|
||||
|
||||
result = handle_effects_command("/effects")
|
||||
|
||||
assert "noise: OFF" in result
|
||||
|
||||
|
||||
class TestShowEffectsMenu:
|
||||
"""Tests for show_effects_menu function."""
|
||||
|
||||
def test_returns_formatted_menu(self):
|
||||
"""returns formatted effects menu."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = True
|
||||
mock_plugin.config.intensity = 0.75
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain_instance = MagicMock()
|
||||
mock_chain_instance.get_order.return_value = ["noise"]
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = show_effects_menu()
|
||||
|
||||
assert "EFFECTS MENU" in result
|
||||
assert "noise" in result
|
||||
|
||||
|
||||
class TestFormatStats:
|
||||
"""Tests for _format_stats function."""
|
||||
|
||||
def test_returns_error_when_no_monitor(self):
|
||||
"""returns error when monitor unavailable."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {"error": "No data"}
|
||||
|
||||
result = _format_stats()
|
||||
|
||||
assert "No data" in result
|
||||
|
||||
def test_formats_pipeline_stats(self):
|
||||
"""formats pipeline stats correctly."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {
|
||||
"frame_count": 50,
|
||||
"pipeline": {"avg_ms": 2.5, "min_ms": 2.0, "max_ms": 3.0},
|
||||
"effects": {"noise": {"avg_ms": 0.5, "min_ms": 0.4, "max_ms": 0.6}},
|
||||
}
|
||||
|
||||
result = _format_stats()
|
||||
|
||||
assert "Pipeline" in result
|
||||
assert "noise" 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
|
||||
234
tests/test_fetch.py
Normal file
234
tests/test_fetch.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Tests for engine.fetch module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.fetch import (
|
||||
_fetch_gutenberg,
|
||||
fetch_all,
|
||||
fetch_feed,
|
||||
fetch_poetry,
|
||||
load_cache,
|
||||
save_cache,
|
||||
)
|
||||
|
||||
|
||||
class TestFetchFeed:
|
||||
"""Tests for fetch_feed function."""
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_fetch_success(self, mock_urlopen):
|
||||
"""Successful feed fetch returns parsed feed."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b"<rss>test</rss>"
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = fetch_feed("http://example.com/feed")
|
||||
|
||||
assert result is not None
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_fetch_network_error(self, mock_urlopen):
|
||||
"""Network error returns None."""
|
||||
mock_urlopen.side_effect = Exception("Network error")
|
||||
|
||||
result = fetch_feed("http://example.com/feed")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestFetchAll:
|
||||
"""Tests for fetch_all function."""
|
||||
|
||||
@patch("engine.fetch.fetch_feed")
|
||||
@patch("engine.fetch.strip_tags")
|
||||
@patch("engine.fetch.skip")
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
||||
"""Successful fetch returns items."""
|
||||
mock_feed = MagicMock()
|
||||
mock_feed.bozo = False
|
||||
mock_feed.entries = [
|
||||
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||
]
|
||||
mock_fetch_feed.return_value = mock_feed
|
||||
mock_skip.return_value = False
|
||||
mock_strip.side_effect = lambda x: x
|
||||
|
||||
items, linked, failed = fetch_all()
|
||||
|
||||
assert linked > 0
|
||||
assert failed == 0
|
||||
|
||||
@patch("engine.fetch.fetch_feed")
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||
"""Feed error increments failed count."""
|
||||
mock_fetch_feed.return_value = None
|
||||
|
||||
items, linked, failed = fetch_all()
|
||||
|
||||
assert failed > 0
|
||||
|
||||
@patch("engine.fetch.fetch_feed")
|
||||
@patch("engine.fetch.strip_tags")
|
||||
@patch("engine.fetch.skip")
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_all_skips_filtered(
|
||||
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
||||
):
|
||||
"""Filtered headlines are skipped."""
|
||||
mock_feed = MagicMock()
|
||||
mock_feed.bozo = False
|
||||
mock_feed.entries = [
|
||||
{"title": "Sports scores"},
|
||||
{"title": "Valid headline"},
|
||||
]
|
||||
mock_fetch_feed.return_value = mock_feed
|
||||
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||
mock_strip.side_effect = lambda x: x
|
||||
|
||||
items, linked, failed = fetch_all()
|
||||
|
||||
assert any("Valid headline" in item[0] for item in items)
|
||||
|
||||
|
||||
class TestFetchGutenberg:
|
||||
"""Tests for _fetch_gutenberg function."""
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_gutenberg_success(self, mock_urlopen):
|
||||
"""Successful gutenberg fetch returns items."""
|
||||
text = """Project Gutenberg
|
||||
|
||||
*** START OF THE PROJECT GUTENBERG ***
|
||||
This is a test poem with multiple lines
|
||||
that should be parsed as a block.
|
||||
|
||||
Another stanza with more content here.
|
||||
|
||||
*** END OF THE PROJECT GUTENBERG ***
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = text.encode("utf-8")
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||
|
||||
assert len(result) > 0
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_gutenberg_network_error(self, mock_urlopen):
|
||||
"""Network error returns empty list."""
|
||||
mock_urlopen.side_effect = Exception("Network error")
|
||||
|
||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
||||
"""Blocks shorter than 20 chars are skipped."""
|
||||
text = """*** START OF THE ***
|
||||
Short
|
||||
*** END OF THE ***
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = text.encode("utf-8")
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
||||
"""All-caps lines are skipped as headers."""
|
||||
text = """*** START OF THE ***
|
||||
THIS IS ALL CAPS HEADER
|
||||
more content here
|
||||
*** END OF THE ***
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = text.encode("utf-8")
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
class TestFetchPoetry:
|
||||
"""Tests for fetch_poetry function."""
|
||||
|
||||
@patch("engine.fetch._fetch_gutenberg")
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
||||
"""Successful poetry fetch returns items."""
|
||||
mock_fetch.return_value = [
|
||||
("Stanza 1 content here", "Test", ""),
|
||||
("Stanza 2 content here", "Test", ""),
|
||||
]
|
||||
|
||||
items, linked, failed = fetch_poetry()
|
||||
|
||||
assert linked > 0
|
||||
assert failed == 0
|
||||
|
||||
@patch("engine.fetch._fetch_gutenberg")
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
||||
"""Failed fetch increments failed count."""
|
||||
mock_fetch.return_value = []
|
||||
|
||||
items, linked, failed = fetch_poetry()
|
||||
|
||||
assert failed > 0
|
||||
|
||||
|
||||
class TestCache:
|
||||
"""Tests for cache functions."""
|
||||
|
||||
@patch("engine.fetch._cache_path")
|
||||
def test_load_cache_success(self, mock_path):
|
||||
"""Successful cache load returns items."""
|
||||
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.read_text.return_value = json.dumps(
|
||||
{"items": [("title", "source", "time")]}
|
||||
)
|
||||
|
||||
result = load_cache()
|
||||
|
||||
assert result is not None
|
||||
|
||||
@patch("engine.fetch._cache_path")
|
||||
def test_load_cache_missing_file(self, mock_path):
|
||||
"""Missing cache file returns None."""
|
||||
mock_path.return_value.exists.return_value = False
|
||||
|
||||
result = load_cache()
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("engine.fetch._cache_path")
|
||||
def test_load_cache_invalid_json(self, mock_path):
|
||||
"""Invalid JSON returns None."""
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
||||
|
||||
result = load_cache()
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("engine.fetch._cache_path")
|
||||
def test_save_cache_success(self, mock_path):
|
||||
"""Save cache writes to file."""
|
||||
mock_path.return_value.__truediv__ = MagicMock(
|
||||
return_value=mock_path.return_value
|
||||
)
|
||||
|
||||
save_cache([("title", "source", "time")])
|
||||
107
tests/test_hud.py
Normal file
107
tests/test_hud.py
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, set_monitor
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
def test_hud_effect_adds_hud_lines():
|
||||
"""Test that HUD effect adds HUD lines to the buffer."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
hud = HudEffect()
|
||||
hud.config.params["display_effect"] = "noise"
|
||||
hud.config.params["display_intensity"] = 0.5
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=24,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=[],
|
||||
)
|
||||
|
||||
buf = [
|
||||
"A" * 80,
|
||||
"B" * 80,
|
||||
"C" * 80,
|
||||
]
|
||||
|
||||
result = hud.process(buf, ctx)
|
||||
|
||||
assert len(result) >= 3, f"Expected at least 3 lines, got {len(result)}"
|
||||
|
||||
first_line = result[0]
|
||||
assert "MAINLINE DEMO" in first_line, (
|
||||
f"HUD not found in first line: {first_line[:50]}"
|
||||
)
|
||||
|
||||
second_line = result[1]
|
||||
assert "EFFECT:" in second_line, f"Effect line not found: {second_line[:50]}"
|
||||
|
||||
print("First line:", result[0])
|
||||
print("Second line:", result[1])
|
||||
if len(result) > 2:
|
||||
print("Third line:", result[2])
|
||||
|
||||
|
||||
def test_hud_effect_shows_current_effect():
|
||||
"""Test that HUD displays the correct effect name."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
hud = HudEffect()
|
||||
hud.config.params["display_effect"] = "fade"
|
||||
hud.config.params["display_intensity"] = 0.75
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=24,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=[],
|
||||
)
|
||||
|
||||
buf = ["X" * 80]
|
||||
result = hud.process(buf, ctx)
|
||||
|
||||
second_line = result[1]
|
||||
assert "fade" in second_line, f"Effect name 'fade' not found in: {second_line}"
|
||||
|
||||
|
||||
def test_hud_effect_shows_intensity():
|
||||
"""Test that HUD displays intensity percentage."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
hud = HudEffect()
|
||||
hud.config.params["display_effect"] = "glitch"
|
||||
hud.config.params["display_intensity"] = 0.8
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=24,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=[],
|
||||
)
|
||||
|
||||
buf = ["Y" * 80]
|
||||
result = hud.process(buf, ctx)
|
||||
|
||||
second_line = result[1]
|
||||
assert "80%" in second_line, f"Intensity 80% not found in: {second_line}"
|
||||
@@ -87,10 +87,26 @@ class TestRenderTickerZone:
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Returns a list of strings."""
|
||||
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||
result, cache = layers.render_ticker_zone(
|
||||
[],
|
||||
scroll_cam=0,
|
||||
camera_x=0,
|
||||
ticker_h=10,
|
||||
w=80,
|
||||
noise_cache={},
|
||||
grad_offset=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)
|
||||
result, cache = layers.render_ticker_zone(
|
||||
[],
|
||||
scroll_cam=0,
|
||||
camera_x=0,
|
||||
ticker_h=10,
|
||||
w=80,
|
||||
noise_cache={},
|
||||
grad_offset=0.0,
|
||||
)
|
||||
assert isinstance(cache, dict)
|
||||
|
||||
131
tests/test_ntfy_integration.py
Normal file
131
tests/test_ntfy_integration.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Integration tests for ntfy topics.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.ntfy
|
||||
class TestNtfyTopics:
|
||||
def test_cc_cmd_topic_exists_and_writable(self):
|
||||
"""Verify C&C CMD topic exists and accepts messages."""
|
||||
from engine.config import NTFY_CC_CMD_TOPIC
|
||||
|
||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||
test_message = f"test_{int(time.time())}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=test_message.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": "mainline-test/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
assert resp.status == 200
|
||||
except Exception as e:
|
||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||
|
||||
def test_cc_resp_topic_exists_and_writable(self):
|
||||
"""Verify C&C RESP topic exists and accepts messages."""
|
||||
from engine.config import NTFY_CC_RESP_TOPIC
|
||||
|
||||
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
||||
test_message = f"test_{int(time.time())}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=test_message.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": "mainline-test/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
assert resp.status == 200
|
||||
except Exception as e:
|
||||
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
||||
|
||||
def test_message_topic_exists_and_writable(self):
|
||||
"""Verify message topic exists and accepts messages."""
|
||||
from engine.config import NTFY_TOPIC
|
||||
|
||||
topic_url = NTFY_TOPIC.replace("/json", "")
|
||||
test_message = f"test_{int(time.time())}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=test_message.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": "mainline-test/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
assert resp.status == 200
|
||||
except Exception as e:
|
||||
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
||||
|
||||
def test_cc_cmd_topic_readable(self):
|
||||
"""Verify we can read messages from C&C CMD topic."""
|
||||
from engine.config import NTFY_CC_CMD_TOPIC
|
||||
|
||||
test_message = f"integration_test_{int(time.time())}"
|
||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=test_message.encode("utf-8"),
|
||||
headers={
|
||||
"User-Agent": "mainline-test/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
||||
req = urllib.request.Request(
|
||||
poll_url,
|
||||
headers={"User-Agent": "mainline-test/0.1"},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
if body.strip():
|
||||
data = json.loads(body.split("\n")[0])
|
||||
assert isinstance(data, dict)
|
||||
except Exception as e:
|
||||
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
||||
|
||||
def test_topics_are_different(self):
|
||||
"""Verify C&C CMD/RESP and message topics are different."""
|
||||
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
||||
|
||||
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
||||
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
||||
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
||||
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
||||
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
||||
281
tests/test_pipeline.py
Normal file
281
tests/test_pipeline.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Tests for the new unified pipeline architecture.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from engine.pipeline import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
PipelineContext,
|
||||
Stage,
|
||||
StageRegistry,
|
||||
create_default_pipeline,
|
||||
discover_stages,
|
||||
)
|
||||
|
||||
|
||||
class TestStageRegistry:
|
||||
"""Tests for StageRegistry."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset registry before each test."""
|
||||
StageRegistry._discovered = False
|
||||
StageRegistry._categories.clear()
|
||||
StageRegistry._instances.clear()
|
||||
|
||||
def test_discover_stages_registers_sources(self):
|
||||
"""discover_stages registers source stages."""
|
||||
discover_stages()
|
||||
|
||||
sources = StageRegistry.list("source")
|
||||
assert "HeadlinesDataSource" in sources
|
||||
assert "PoetryDataSource" in sources
|
||||
assert "PipelineDataSource" in sources
|
||||
|
||||
def test_discover_stages_registers_displays(self):
|
||||
"""discover_stages registers display stages."""
|
||||
discover_stages()
|
||||
|
||||
displays = StageRegistry.list("display")
|
||||
assert "terminal" in displays
|
||||
assert "pygame" in displays
|
||||
assert "websocket" in displays
|
||||
assert "null" in displays
|
||||
assert "sixel" in displays
|
||||
|
||||
def test_create_source_stage(self):
|
||||
"""StageRegistry.create creates source stages."""
|
||||
discover_stages()
|
||||
|
||||
source = StageRegistry.create("source", "HeadlinesDataSource")
|
||||
assert source is not None
|
||||
assert source.name == "headlines"
|
||||
|
||||
def test_create_display_stage(self):
|
||||
"""StageRegistry.create creates display stages."""
|
||||
discover_stages()
|
||||
|
||||
display = StageRegistry.create("display", "terminal")
|
||||
assert display is not None
|
||||
assert hasattr(display, "_display")
|
||||
|
||||
def test_create_display_stage_pygame(self):
|
||||
"""StageRegistry.create creates pygame display stage."""
|
||||
discover_stages()
|
||||
|
||||
display = StageRegistry.create("display", "pygame")
|
||||
assert display is not None
|
||||
|
||||
|
||||
class TestPipeline:
|
||||
"""Tests for Pipeline class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset registry before each test."""
|
||||
StageRegistry._discovered = False
|
||||
StageRegistry._categories.clear()
|
||||
StageRegistry._instances.clear()
|
||||
discover_stages()
|
||||
|
||||
def test_create_pipeline(self):
|
||||
"""Pipeline can be created with config."""
|
||||
config = PipelineConfig(source="headlines", display="terminal")
|
||||
pipeline = Pipeline(config=config)
|
||||
|
||||
assert pipeline.config is not None
|
||||
assert pipeline.config.source == "headlines"
|
||||
assert pipeline.config.display == "terminal"
|
||||
|
||||
def test_add_stage(self):
|
||||
"""Pipeline.add_stage adds a stage."""
|
||||
pipeline = Pipeline()
|
||||
mock_stage = MagicMock(spec=Stage)
|
||||
mock_stage.name = "test_stage"
|
||||
mock_stage.category = "test"
|
||||
|
||||
pipeline.add_stage("test", mock_stage)
|
||||
|
||||
assert "test" in pipeline.stages
|
||||
|
||||
def test_build_resolves_dependencies(self):
|
||||
"""Pipeline.build resolves execution order."""
|
||||
pipeline = Pipeline()
|
||||
mock_source = MagicMock(spec=Stage)
|
||||
mock_source.name = "source"
|
||||
mock_source.category = "source"
|
||||
mock_source.dependencies = set()
|
||||
|
||||
mock_display = MagicMock(spec=Stage)
|
||||
mock_display.name = "display"
|
||||
mock_display.category = "display"
|
||||
mock_display.dependencies = {"source"}
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("display", mock_display)
|
||||
pipeline.build()
|
||||
|
||||
assert pipeline._initialized is True
|
||||
assert "source" in pipeline.execution_order
|
||||
assert "display" in pipeline.execution_order
|
||||
|
||||
def test_execute_runs_stages(self):
|
||||
"""Pipeline.execute runs all stages in order."""
|
||||
pipeline = Pipeline()
|
||||
|
||||
call_order = []
|
||||
|
||||
mock_source = MagicMock(spec=Stage)
|
||||
mock_source.name = "source"
|
||||
mock_source.category = "source"
|
||||
mock_source.dependencies = set()
|
||||
mock_source.process = lambda data, ctx: call_order.append("source") or "data"
|
||||
|
||||
mock_effect = MagicMock(spec=Stage)
|
||||
mock_effect.name = "effect"
|
||||
mock_effect.category = "effect"
|
||||
mock_effect.dependencies = {"source"}
|
||||
mock_effect.process = lambda data, ctx: call_order.append("effect") or data
|
||||
|
||||
mock_display = MagicMock(spec=Stage)
|
||||
mock_display.name = "display"
|
||||
mock_display.category = "display"
|
||||
mock_display.dependencies = {"effect"}
|
||||
mock_display.process = lambda data, ctx: call_order.append("display") or data
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("effect", mock_effect)
|
||||
pipeline.add_stage("display", mock_display)
|
||||
pipeline.build()
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
assert result.success is True
|
||||
assert call_order == ["source", "effect", "display"]
|
||||
|
||||
def test_execute_handles_stage_failure(self):
|
||||
"""Pipeline.execute handles stage failures."""
|
||||
pipeline = Pipeline()
|
||||
|
||||
mock_source = MagicMock(spec=Stage)
|
||||
mock_source.name = "source"
|
||||
mock_source.category = "source"
|
||||
mock_source.dependencies = set()
|
||||
mock_source.process = lambda data, ctx: "data"
|
||||
|
||||
mock_failing = MagicMock(spec=Stage)
|
||||
mock_failing.name = "failing"
|
||||
mock_failing.category = "effect"
|
||||
mock_failing.dependencies = {"source"}
|
||||
mock_failing.optional = False
|
||||
mock_failing.process = lambda data, ctx: (_ for _ in ()).throw(
|
||||
Exception("fail")
|
||||
)
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("failing", mock_failing)
|
||||
pipeline.build()
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
|
||||
def test_optional_stage_failure_continues(self):
|
||||
"""Pipeline.execute continues on optional stage failure."""
|
||||
pipeline = Pipeline()
|
||||
|
||||
mock_source = MagicMock(spec=Stage)
|
||||
mock_source.name = "source"
|
||||
mock_source.category = "source"
|
||||
mock_source.dependencies = set()
|
||||
mock_source.process = lambda data, ctx: "data"
|
||||
|
||||
mock_optional = MagicMock(spec=Stage)
|
||||
mock_optional.name = "optional"
|
||||
mock_optional.category = "effect"
|
||||
mock_optional.dependencies = {"source"}
|
||||
mock_optional.optional = True
|
||||
mock_optional.process = lambda data, ctx: (_ for _ in ()).throw(
|
||||
Exception("fail")
|
||||
)
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("optional", mock_optional)
|
||||
pipeline.build()
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestPipelineContext:
|
||||
"""Tests for PipelineContext."""
|
||||
|
||||
def test_init_empty(self):
|
||||
"""PipelineContext initializes with empty services and state."""
|
||||
ctx = PipelineContext()
|
||||
|
||||
assert ctx.services == {}
|
||||
assert ctx.state == {}
|
||||
|
||||
def test_init_with_services(self):
|
||||
"""PipelineContext accepts initial services."""
|
||||
ctx = PipelineContext(services={"display": MagicMock()})
|
||||
|
||||
assert "display" in ctx.services
|
||||
|
||||
def test_init_with_state(self):
|
||||
"""PipelineContext accepts initial state."""
|
||||
ctx = PipelineContext(initial_state={"count": 42})
|
||||
|
||||
assert ctx.get_state("count") == 42
|
||||
|
||||
def test_get_set_services(self):
|
||||
"""PipelineContext can get/set services."""
|
||||
ctx = PipelineContext()
|
||||
mock_service = MagicMock()
|
||||
|
||||
ctx.set("test_service", mock_service)
|
||||
|
||||
assert ctx.get("test_service") == mock_service
|
||||
|
||||
def test_get_set_state(self):
|
||||
"""PipelineContext can get/set state."""
|
||||
ctx = PipelineContext()
|
||||
|
||||
ctx.set_state("counter", 100)
|
||||
|
||||
assert ctx.get_state("counter") == 100
|
||||
|
||||
def test_lazy_resolver(self):
|
||||
"""PipelineContext resolves lazy services."""
|
||||
ctx = PipelineContext()
|
||||
|
||||
config = ctx.get("config")
|
||||
assert config is not None
|
||||
|
||||
def test_has_capability(self):
|
||||
"""PipelineContext.has_capability checks for services."""
|
||||
ctx = PipelineContext(services={"display.output": MagicMock()})
|
||||
|
||||
assert ctx.has_capability("display.output") is True
|
||||
assert ctx.has_capability("missing") is False
|
||||
|
||||
|
||||
class TestCreateDefaultPipeline:
|
||||
"""Tests for create_default_pipeline function."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset registry before each test."""
|
||||
StageRegistry._discovered = False
|
||||
StageRegistry._categories.clear()
|
||||
StageRegistry._instances.clear()
|
||||
discover_stages()
|
||||
|
||||
def test_create_default_pipeline(self):
|
||||
"""create_default_pipeline creates a working pipeline."""
|
||||
pipeline = create_default_pipeline()
|
||||
|
||||
assert pipeline is not None
|
||||
assert "display" in pipeline.stages
|
||||
232
tests/test_render.py
Normal file
232
tests/test_render.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Tests for engine.render module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.render import (
|
||||
GRAD_COLS,
|
||||
MSG_GRAD_COLS,
|
||||
clear_font_cache,
|
||||
font_for_lang,
|
||||
lr_gradient,
|
||||
lr_gradient_opposite,
|
||||
make_block,
|
||||
)
|
||||
|
||||
|
||||
class TestGradientConstants:
|
||||
"""Tests for gradient color constants."""
|
||||
|
||||
def test_grad_cols_defined(self):
|
||||
"""GRAD_COLS is defined with expected length."""
|
||||
assert len(GRAD_COLS) > 0
|
||||
assert all(isinstance(c, str) for c in GRAD_COLS)
|
||||
|
||||
def test_msg_grad_cols_defined(self):
|
||||
"""MSG_GRAD_COLS is defined with expected length."""
|
||||
assert len(MSG_GRAD_COLS) > 0
|
||||
assert all(isinstance(c, str) for c in MSG_GRAD_COLS)
|
||||
|
||||
def test_grad_cols_start_with_white(self):
|
||||
"""GRAD_COLS starts with white."""
|
||||
assert "231" in GRAD_COLS[0]
|
||||
|
||||
def test_msg_grad_cols_different_from_grad_cols(self):
|
||||
"""MSG_GRAD_COLS is different from GRAD_COLS."""
|
||||
assert MSG_GRAD_COLS != GRAD_COLS
|
||||
|
||||
|
||||
class TestLrGradient:
|
||||
"""Tests for lr_gradient function."""
|
||||
|
||||
def test_empty_rows(self):
|
||||
"""Empty input returns empty output."""
|
||||
result = lr_gradient([], 0.0)
|
||||
assert result == []
|
||||
|
||||
def test_preserves_empty_rows(self):
|
||||
"""Empty rows are preserved."""
|
||||
result = lr_gradient([""], 0.0)
|
||||
assert result == [""]
|
||||
|
||||
def test_adds_gradient_to_content(self):
|
||||
"""Non-empty rows get gradient coloring."""
|
||||
result = lr_gradient(["hello"], 0.0)
|
||||
assert len(result) == 1
|
||||
assert "\033[" in result[0]
|
||||
|
||||
def test_preserves_spaces(self):
|
||||
"""Spaces are preserved without coloring."""
|
||||
result = lr_gradient(["hello world"], 0.0)
|
||||
assert " " in result[0]
|
||||
|
||||
def test_offset_wraps_around(self):
|
||||
"""Offset wraps around at 1.0."""
|
||||
result1 = lr_gradient(["hello"], 0.0)
|
||||
result2 = lr_gradient(["hello"], 1.0)
|
||||
assert result1 != result2 or result1 == result2
|
||||
|
||||
|
||||
class TestLrGradientOpposite:
|
||||
"""Tests for lr_gradient_opposite function."""
|
||||
|
||||
def test_uses_msg_grad_cols(self):
|
||||
"""Uses MSG_GRAD_COLS instead of GRAD_COLS."""
|
||||
result = lr_gradient_opposite(["test"])
|
||||
assert "\033[" in result[0]
|
||||
|
||||
|
||||
class TestClearFontCache:
|
||||
"""Tests for clear_font_cache function."""
|
||||
|
||||
def test_clears_without_error(self):
|
||||
"""Function runs without error."""
|
||||
clear_font_cache()
|
||||
|
||||
|
||||
class TestFontForLang:
|
||||
"""Tests for font_for_lang function."""
|
||||
|
||||
@patch("engine.render.font")
|
||||
def test_returns_default_for_none(self, mock_font):
|
||||
"""Returns default font when lang is None."""
|
||||
result = font_for_lang(None)
|
||||
assert result is not None
|
||||
|
||||
@patch("engine.render.font")
|
||||
def test_returns_default_for_unknown_lang(self, mock_font):
|
||||
"""Returns default font for unknown language."""
|
||||
result = font_for_lang("unknown_lang")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestMakeBlock:
|
||||
"""Tests for make_block function."""
|
||||
|
||||
@patch("engine.translate.translate_headline")
|
||||
@patch("engine.translate.detect_location_language")
|
||||
@patch("engine.render.font_for_lang")
|
||||
@patch("engine.render.big_wrap")
|
||||
@patch("engine.render.random")
|
||||
def test_make_block_basic(
|
||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||
):
|
||||
"""Basic make_block returns content, color, meta index."""
|
||||
mock_wrap.return_value = ["Headline content", ""]
|
||||
mock_random.choice.return_value = "\033[38;5;46m"
|
||||
|
||||
content, color, meta_idx = make_block(
|
||||
"Test headline", "TestSource", "12:00", 80
|
||||
)
|
||||
|
||||
assert len(content) > 0
|
||||
assert color is not None
|
||||
assert meta_idx >= 0
|
||||
|
||||
@pytest.mark.skip(reason="Requires full PIL/font environment")
|
||||
@patch("engine.translate.translate_headline")
|
||||
@patch("engine.translate.detect_location_language")
|
||||
@patch("engine.render.font_for_lang")
|
||||
@patch("engine.render.big_wrap")
|
||||
@patch("engine.render.random")
|
||||
def test_make_block_translation(
|
||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||
):
|
||||
"""Translation is applied when mode is news."""
|
||||
mock_wrap.return_value = ["Translated"]
|
||||
mock_random.choice.return_value = "\033[38;5;46m"
|
||||
mock_detect.return_value = "de"
|
||||
|
||||
with patch("engine.config.MODE", "news"):
|
||||
content, _, _ = make_block("Test", "Source", "12:00", 80)
|
||||
mock_translate.assert_called_once()
|
||||
|
||||
@patch("engine.translate.translate_headline")
|
||||
@patch("engine.translate.detect_location_language")
|
||||
@patch("engine.render.font_for_lang")
|
||||
@patch("engine.render.big_wrap")
|
||||
@patch("engine.render.random")
|
||||
def test_make_block_no_translation_poetry(
|
||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||
):
|
||||
"""No translation when mode is poetry."""
|
||||
mock_wrap.return_value = ["Poem content"]
|
||||
mock_random.choice.return_value = "\033[38;5;46m"
|
||||
|
||||
with patch("engine.config.MODE", "poetry"):
|
||||
make_block("Test", "Source", "12:00", 80)
|
||||
mock_translate.assert_not_called()
|
||||
|
||||
@patch("engine.translate.translate_headline")
|
||||
@patch("engine.translate.detect_location_language")
|
||||
@patch("engine.render.font_for_lang")
|
||||
@patch("engine.render.big_wrap")
|
||||
@patch("engine.render.random")
|
||||
def test_make_block_meta_format(
|
||||
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||
):
|
||||
"""Meta line includes source and timestamp."""
|
||||
mock_wrap.return_value = ["Content"]
|
||||
mock_random.choice.return_value = "\033[38;5;46m"
|
||||
|
||||
content, _, meta_idx = make_block("Test", "MySource", "14:30", 80)
|
||||
|
||||
meta_line = content[meta_idx]
|
||||
assert "MySource" in meta_line
|
||||
assert "14:30" in meta_line
|
||||
|
||||
|
||||
class TestRenderLine:
|
||||
"""Tests for render_line function."""
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string returns empty list."""
|
||||
from engine.render import render_line
|
||||
|
||||
result = render_line("")
|
||||
assert result == [""]
|
||||
|
||||
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||
def test_uses_default_font(self):
|
||||
"""Uses default font when none provided."""
|
||||
from engine.render import render_line
|
||||
|
||||
with patch("engine.render.font") as mock_font:
|
||||
mock_font.return_value = MagicMock()
|
||||
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||
render_line("test")
|
||||
|
||||
def test_getbbox_returns_none(self):
|
||||
"""Handles None bbox gracefully."""
|
||||
from engine.render import render_line
|
||||
|
||||
with patch("engine.render.font") as mock_font:
|
||||
mock_font.return_value = MagicMock()
|
||||
mock_font.return_value.getbbox.return_value = None
|
||||
result = render_line("test")
|
||||
assert result == [""]
|
||||
|
||||
|
||||
class TestBigWrap:
|
||||
"""Tests for big_wrap function."""
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string returns empty list."""
|
||||
from engine.render import big_wrap
|
||||
|
||||
result = big_wrap("", 80)
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||
def test_single_word_fits(self):
|
||||
"""Single short word returns rendered."""
|
||||
from engine.render import big_wrap
|
||||
|
||||
with patch("engine.render.font") as mock_font:
|
||||
mock_font.return_value = MagicMock()
|
||||
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||
result = big_wrap("test", 80)
|
||||
assert len(result) > 0
|
||||
128
tests/test_sixel.py
Normal file
128
tests/test_sixel.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Tests for engine.display.backends.sixel module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestSixelDisplay:
|
||||
"""Tests for SixelDisplay class."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores dimensions."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay()
|
||||
display.init(80, 24)
|
||||
assert display.width == 80
|
||||
assert display.height == 24
|
||||
|
||||
def test_init_custom_cell_size(self):
|
||||
"""init accepts custom cell size."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay(cell_width=12, cell_height=18)
|
||||
assert display.cell_width == 12
|
||||
assert display.cell_height == 18
|
||||
|
||||
def test_show_handles_empty_buffer(self):
|
||||
"""show handles empty buffer gracefully."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
with patch("engine.display.backends.sixel._encode_sixel") as mock_encode:
|
||||
mock_encode.return_value = ""
|
||||
display.show([])
|
||||
|
||||
def test_show_handles_pil_import_error(self):
|
||||
"""show gracefully handles missing PIL."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
with patch.dict("sys.modules", {"PIL": None}):
|
||||
display.show(["test line"])
|
||||
|
||||
def test_clear_sends_escape_sequence(self):
|
||||
"""clear sends clear screen escape sequence."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay()
|
||||
|
||||
with patch("sys.stdout") as mock_stdout:
|
||||
display.clear()
|
||||
mock_stdout.buffer.write.assert_called()
|
||||
|
||||
def test_cleanup_does_nothing(self):
|
||||
"""cleanup does nothing."""
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
display = SixelDisplay()
|
||||
display.cleanup()
|
||||
|
||||
|
||||
class TestSixelAnsiParsing:
|
||||
"""Tests for ANSI parsing in SixelDisplay."""
|
||||
|
||||
def test_parse_empty_string(self):
|
||||
"""handles empty string."""
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
result = parse_ansi("")
|
||||
assert len(result) > 0
|
||||
|
||||
def test_parse_plain_text(self):
|
||||
"""parses plain text without ANSI codes."""
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
result = parse_ansi("hello world")
|
||||
assert len(result) == 1
|
||||
text, fg, bg, bold = result[0]
|
||||
assert text == "hello world"
|
||||
|
||||
def test_parse_with_color_codes(self):
|
||||
"""parses ANSI color codes."""
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
result = parse_ansi("\033[31mred\033[0m")
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "red"
|
||||
assert result[0][1] == (205, 49, 49)
|
||||
|
||||
def test_parse_with_bold(self):
|
||||
"""parses bold codes."""
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
result = parse_ansi("\033[1mbold\033[0m")
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "bold"
|
||||
assert result[0][3] is True
|
||||
|
||||
def test_parse_256_color(self):
|
||||
"""parses 256 color codes."""
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
result = parse_ansi("\033[38;5;196mred\033[0m")
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "red"
|
||||
|
||||
|
||||
class TestSixelEncoding:
|
||||
"""Tests for Sixel encoding."""
|
||||
|
||||
def test_encode_empty_image(self):
|
||||
"""handles empty image."""
|
||||
from engine.display.backends.sixel import _encode_sixel
|
||||
|
||||
with patch("PIL.Image.Image") as mock_image:
|
||||
mock_img_instance = MagicMock()
|
||||
mock_img_instance.convert.return_value = mock_img_instance
|
||||
mock_img_instance.size = (0, 0)
|
||||
mock_img_instance.load.return_value = {}
|
||||
mock_image.return_value = mock_img_instance
|
||||
|
||||
result = _encode_sixel(mock_img_instance)
|
||||
assert result == ""
|
||||
115
tests/test_translate.py
Normal file
115
tests/test_translate.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Tests for engine.translate module.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.translate import (
|
||||
_translate_cached,
|
||||
detect_location_language,
|
||||
translate_headline,
|
||||
)
|
||||
|
||||
|
||||
def clear_translate_cache():
|
||||
"""Clear the LRU cache between tests."""
|
||||
_translate_cached.cache_clear()
|
||||
|
||||
|
||||
class TestDetectLocationLanguage:
|
||||
"""Tests for detect_location_language function."""
|
||||
|
||||
def test_returns_none_for_unknown_location(self):
|
||||
"""Returns None when no location pattern matches."""
|
||||
result = detect_location_language("Breaking news about technology")
|
||||
assert result is None
|
||||
|
||||
def test_detects_berlin(self):
|
||||
"""Detects Berlin location."""
|
||||
result = detect_location_language("Berlin police arrest protesters")
|
||||
assert result == "de"
|
||||
|
||||
def test_detects_paris(self):
|
||||
"""Detects Paris location."""
|
||||
result = detect_location_language("Paris fashion week begins")
|
||||
assert result == "fr"
|
||||
|
||||
def test_detects_tokyo(self):
|
||||
"""Detects Tokyo location."""
|
||||
result = detect_location_language("Tokyo stocks rise")
|
||||
assert result == "ja"
|
||||
|
||||
def test_detects_berlin_again(self):
|
||||
"""Detects Berlin location again."""
|
||||
result = detect_location_language("Berlin marathon set to begin")
|
||||
assert result == "de"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Detection is case insensitive."""
|
||||
result = detect_location_language("BERLIN SUMMER FESTIVAL")
|
||||
assert result == "de"
|
||||
|
||||
def test_returns_first_match(self):
|
||||
"""Returns first matching pattern."""
|
||||
result = detect_location_language("Berlin in Paris for the event")
|
||||
assert result == "de"
|
||||
|
||||
|
||||
class TestTranslateHeadline:
|
||||
"""Tests for translate_headline function."""
|
||||
|
||||
def test_returns_translated_text(self):
|
||||
"""Returns translated text from cache."""
|
||||
clear_translate_cache()
|
||||
with patch("engine.translate.translate_headline") as mock_fn:
|
||||
mock_fn.return_value = "Translated title"
|
||||
from engine.translate import translate_headline as th
|
||||
|
||||
result = th("Original title", "de")
|
||||
assert result == "Translated title"
|
||||
|
||||
def test_uses_cached_result(self):
|
||||
"""Translation uses LRU cache."""
|
||||
clear_translate_cache()
|
||||
result1 = translate_headline("Test unique", "es")
|
||||
result2 = translate_headline("Test unique", "es")
|
||||
assert result1 == result2
|
||||
|
||||
|
||||
class TestTranslateCached:
|
||||
"""Tests for _translate_cached function."""
|
||||
|
||||
def test_translation_network_error(self):
|
||||
"""Network error returns original text."""
|
||||
clear_translate_cache()
|
||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.side_effect = Exception("Network error")
|
||||
|
||||
result = _translate_cached("Hello world", "de")
|
||||
|
||||
assert result == "Hello world"
|
||||
|
||||
def test_translation_invalid_json(self):
|
||||
"""Invalid JSON returns original text."""
|
||||
clear_translate_cache()
|
||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = b"invalid json"
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = _translate_cached("Hello", "de")
|
||||
|
||||
assert result == "Hello"
|
||||
|
||||
def test_translation_empty_response(self):
|
||||
"""Empty translation response returns original text."""
|
||||
clear_translate_cache()
|
||||
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps([[[""], None, "de"], None])
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = _translate_cached("Hello", "de")
|
||||
|
||||
assert result == "Hello"
|
||||
32
tests/test_vis_offset.py
Normal file
32
tests/test_vis_offset.py
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
from engine.effects.legacy import vis_offset, vis_trunc
|
||||
|
||||
|
||||
def test_vis_offset_no_change():
|
||||
"""vis_offset with offset 0 returns original."""
|
||||
result = vis_offset("hello", 0)
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
def test_vis_offset_trims_start():
|
||||
"""vis_offset skips first N characters."""
|
||||
result = vis_offset("hello world", 6)
|
||||
assert result == "world"
|
||||
|
||||
|
||||
def test_vis_offset_handles_ansi():
|
||||
"""vis_offset handles ANSI codes correctly."""
|
||||
result = vis_offset("\033[31mhello\033[0m", 3)
|
||||
assert result == "lo\x1b[0m" or "lo" in result
|
||||
|
||||
|
||||
def test_vis_offset_greater_than_length():
|
||||
"""vis_offset with offset > length returns empty-ish."""
|
||||
result = vis_offset("hi", 10)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_vis_trunc_still_works():
|
||||
"""Ensure vis_trunc still works after changes."""
|
||||
result = vis_trunc("hello world", 5)
|
||||
assert result == "hello"
|
||||
161
tests/test_websocket.py
Normal file
161
tests/test_websocket.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Tests for engine.display.backends.websocket module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
|
||||
class TestWebSocketDisplayImport:
|
||||
"""Test that websocket module can be imported."""
|
||||
|
||||
def test_import_does_not_error(self):
|
||||
"""Module imports without error."""
|
||||
from engine.display import backends
|
||||
|
||||
assert backends is not None
|
||||
|
||||
|
||||
class TestWebSocketDisplayInit:
|
||||
"""Tests for WebSocketDisplay initialization."""
|
||||
|
||||
def test_default_init(self):
|
||||
"""Default initialization sets correct defaults."""
|
||||
with patch("engine.display.backends.websocket.websockets", None):
|
||||
display = WebSocketDisplay()
|
||||
assert display.host == "0.0.0.0"
|
||||
assert display.port == 8765
|
||||
assert display.http_port == 8766
|
||||
assert display.width == 80
|
||||
assert display.height == 24
|
||||
|
||||
def test_custom_init(self):
|
||||
"""Custom initialization uses provided values."""
|
||||
with patch("engine.display.backends.websocket.websockets", None):
|
||||
display = WebSocketDisplay(host="localhost", port=9000, http_port=9001)
|
||||
assert display.host == "localhost"
|
||||
assert display.port == 9000
|
||||
assert display.http_port == 9001
|
||||
|
||||
def test_is_available_when_websockets_present(self):
|
||||
"""is_available returns True when websockets is available."""
|
||||
pytest.importorskip("websockets")
|
||||
display = WebSocketDisplay()
|
||||
assert display.is_available() is True
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||
)
|
||||
def test_is_available_when_websockets_missing(self):
|
||||
"""is_available returns False when websockets is not available."""
|
||||
display = WebSocketDisplay()
|
||||
assert display.is_available() is False
|
||||
|
||||
|
||||
class TestWebSocketDisplayProtocol:
|
||||
"""Test that WebSocketDisplay satisfies Display protocol."""
|
||||
|
||||
def test_websocket_display_is_display(self):
|
||||
"""WebSocketDisplay satisfies Display protocol."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
assert hasattr(display, "init")
|
||||
assert hasattr(display, "show")
|
||||
assert hasattr(display, "clear")
|
||||
assert hasattr(display, "cleanup")
|
||||
|
||||
|
||||
class TestWebSocketDisplayMethods:
|
||||
"""Tests for WebSocketDisplay methods."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores terminal dimensions."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
display.init(100, 40)
|
||||
assert display.width == 100
|
||||
assert display.height == 40
|
||||
|
||||
def test_client_count_initially_zero(self):
|
||||
"""client_count returns 0 when no clients connected."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
assert display.client_count() == 0
|
||||
|
||||
def test_get_ws_port(self):
|
||||
"""get_ws_port returns configured port."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay(port=9000)
|
||||
assert display.get_ws_port() == 9000
|
||||
|
||||
def test_get_http_port(self):
|
||||
"""get_http_port returns configured port."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay(http_port=9001)
|
||||
assert display.get_http_port() == 9001
|
||||
|
||||
def test_frame_delay_defaults_to_zero(self):
|
||||
"""get_frame_delay returns 0 by default."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
assert display.get_frame_delay() == 0.0
|
||||
|
||||
def test_set_frame_delay(self):
|
||||
"""set_frame_delay stores the value."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
display.set_frame_delay(0.05)
|
||||
assert display.get_frame_delay() == 0.05
|
||||
|
||||
|
||||
class TestWebSocketDisplayCallbacks:
|
||||
"""Tests for WebSocketDisplay callback methods."""
|
||||
|
||||
def test_set_client_connected_callback(self):
|
||||
"""set_client_connected_callback stores callback."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
callback = MagicMock()
|
||||
display.set_client_connected_callback(callback)
|
||||
assert display._client_connected_callback is callback
|
||||
|
||||
def test_set_client_disconnected_callback(self):
|
||||
"""set_client_disconnected_callback stores callback."""
|
||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||
display = WebSocketDisplay()
|
||||
callback = MagicMock()
|
||||
display.set_client_disconnected_callback(callback)
|
||||
assert display._client_disconnected_callback is callback
|
||||
|
||||
|
||||
class TestWebSocketDisplayUnavailable:
|
||||
"""Tests when WebSocket support is unavailable."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||
)
|
||||
def test_start_server_noop_when_unavailable(self):
|
||||
"""start_server does nothing when websockets unavailable."""
|
||||
display = WebSocketDisplay()
|
||||
display.start_server()
|
||||
assert display._server_thread is None
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||
)
|
||||
def test_start_http_server_noop_when_unavailable(self):
|
||||
"""start_http_server does nothing when websockets unavailable."""
|
||||
display = WebSocketDisplay()
|
||||
display.start_http_server()
|
||||
assert display._http_thread is None
|
||||
|
||||
@pytest.mark.skipif(
|
||||
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||
)
|
||||
def test_show_noops_when_unavailable(self):
|
||||
"""show does nothing when websockets unavailable."""
|
||||
display = WebSocketDisplay()
|
||||
display.show(["line1", "line2"])
|
||||
78
tests/test_websocket_e2e.py
Normal file
78
tests/test_websocket_e2e.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
End-to-end tests for WebSocket display using Playwright.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestWebSocketE2E:
|
||||
"""End-to-end tests for WebSocket display with browser."""
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_websocket_server_starts(self):
|
||||
"""Test that WebSocket server starts and serves HTTP."""
|
||||
import threading
|
||||
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
display = WebSocketDisplay(host="127.0.0.1", port=18765)
|
||||
|
||||
server_thread = threading.Thread(target=display.start_http_server)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
response = urllib.request.urlopen("http://127.0.0.1:18765", timeout=5)
|
||||
assert response.status == 200
|
||||
content = response.read().decode("utf-8")
|
||||
assert len(content) > 0
|
||||
finally:
|
||||
display.cleanup()
|
||||
time.sleep(0.5)
|
||||
|
||||
@pytest.mark.e2e
|
||||
@pytest.mark.skipif(
|
||||
not pytest.importorskip("playwright", reason="playwright not installed"),
|
||||
reason="playwright not installed",
|
||||
)
|
||||
def test_websocket_browser_connection(self):
|
||||
"""Test WebSocket connection with actual browser."""
|
||||
import threading
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
display = WebSocketDisplay(host="127.0.0.1", port=18767)
|
||||
|
||||
server_thread = threading.Thread(target=display.start_server)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
http_thread = threading.Thread(target=display.start_http_server)
|
||||
http_thread.daemon = True
|
||||
http_thread.start()
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
page.goto("http://127.0.0.1:18767")
|
||||
time.sleep(0.5)
|
||||
|
||||
title = page.title()
|
||||
assert len(title) >= 0
|
||||
|
||||
browser.close()
|
||||
finally:
|
||||
display.cleanup()
|
||||
time.sleep(0.5)
|
||||
Reference in New Issue
Block a user