forked from genewildish/Mainline
Compare commits
8 Commits
feature/ve
...
effects_pl
| Author | SHA1 | Date | |
|---|---|---|---|
| 45fb06e0be | |||
| 020eb663ea | |||
| dd282653b5 | |||
| 35e5a71930 | |||
| 3551cc249f | |||
| fba183526a | |||
| 7193e7487b | |||
| b5d6eeedc0 |
280
AGENTS.md
280
AGENTS.md
@@ -16,33 +16,43 @@ This project uses:
|
|||||||
mise run install
|
mise run install
|
||||||
|
|
||||||
# Or equivalently:
|
# Or equivalently:
|
||||||
uv sync --all-extras # includes mic, websocket, sixel support
|
uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run test # Run tests
|
# Development
|
||||||
mise run test-v # Run tests verbose
|
mise run test # Run tests
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-v # Run tests verbose
|
||||||
mise run test-browser # Run e2e browser tests (requires playwright)
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run lint # Run ruff linter
|
mise run lint # Run ruff linter
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run format # Run ruff formatter
|
mise run format # Run ruff formatter
|
||||||
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
mise run ci # Full CI pipeline
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime Commands
|
# Runtime
|
||||||
|
mise run run # Interactive terminal mode (news)
|
||||||
|
mise run run-poetry # Interactive terminal mode (poetry)
|
||||||
|
mise run run-firehose # Dense headline mode
|
||||||
|
|
||||||
```bash
|
# Daemon mode (recommended for long-running)
|
||||||
mise run run # Run mainline (terminal)
|
mise run daemon # Start mainline in background
|
||||||
mise run run-poetry # Run with poetry feed
|
mise run daemon-stop # Stop daemon
|
||||||
mise run run-firehose # Run in firehose mode
|
mise run daemon-restart # Restart daemon
|
||||||
mise run run-websocket # Run with WebSocket display only
|
|
||||||
mise run run-sixel # Run with Sixel graphics display
|
# Command & Control
|
||||||
mise run run-both # Run with both terminal and WebSocket
|
mise run cmd # Interactive CLI
|
||||||
mise run run-client # Run both + open browser
|
mise run cmd "/cmd" # Send single command
|
||||||
mise run cmd # Run C&C command interface
|
mise run cmd-stats # Watch performance stats
|
||||||
|
mise run topics-init # Initialize ntfy topics
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
mise run install # Install dependencies
|
||||||
|
mise run sync # Sync dependencies
|
||||||
|
mise run sync-all # Sync with all extras
|
||||||
|
mise run clean # Clean cache files
|
||||||
|
mise run clobber # Aggressive cleanup (git clean -fdx + caches)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
## Git Hooks
|
||||||
@@ -60,52 +70,9 @@ hk init --mise
|
|||||||
mise run pre-commit
|
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`:
|
The project uses hk configured in `hk.pkl`:
|
||||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||||
- **pre-push**: runs ruff check + benchmark hook
|
- **pre-push**: runs ruff check
|
||||||
|
|
||||||
## 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
|
## Workflow Rules
|
||||||
|
|
||||||
@@ -159,81 +126,142 @@ mise run test-cov
|
|||||||
|
|
||||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
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
|
## Architecture Notes
|
||||||
|
|
||||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
- **effects/** - plugin architecture with performance monitoring
|
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay)
|
||||||
|
|
||||||
### Display System
|
## Operating Modes
|
||||||
|
|
||||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
Mainline can run in two modes:
|
||||||
- `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
|
### 1. Standalone Mode (Original)
|
||||||
- WebSocket server on port 8765
|
Run directly as a terminal application with interactive pickers:
|
||||||
- HTTP server on port 8766 (serves HTML client)
|
```bash
|
||||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
mise run run # news stream
|
||||||
|
mise run run-poetry # poetry mode
|
||||||
|
mise run run-firehose # dense headline mode
|
||||||
|
```
|
||||||
|
This runs the full interactive experience with font picker and effects picker at startup.
|
||||||
|
|
||||||
- **Display modes** (`--display` flag):
|
### 2. Daemon + Command Mode (Recommended for Long-Running)
|
||||||
- `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
|
The recommended approach for persistent displays:
|
||||||
|
|
||||||
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
```bash
|
||||||
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
# Start the daemon (headless rendering)
|
||||||
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
mise run daemon
|
||||||
|
|
||||||
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
|
||||||
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
|
||||||
|
|
||||||
### Command & Control
|
# Send commands via ntfy
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise off"
|
||||||
|
mise run cmd "/effects stats"
|
||||||
|
|
||||||
- C&C uses separate ntfy topics for commands and responses
|
# Watch mode (continuous stats polling)
|
||||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
mise run cmd-stats
|
||||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
|
||||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
|
||||||
|
|
||||||
### Pipeline Documentation
|
# Stop the daemon
|
||||||
|
mise run daemon-stop
|
||||||
|
```
|
||||||
|
|
||||||
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
#### How It Works
|
||||||
|
|
||||||
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
- **Daemon**: Runs `mainline.py` in the background, renders to terminal
|
||||||
1. Edit `docs/PIPELINE.md` with the new architecture
|
- **C&C Topics**: Uses separate ntfy topics (like UART serial):
|
||||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
|
||||||
3. Commit both the markdown and any new diagram files
|
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
|
||||||
|
- **Topics are auto-warmed** on first daemon start
|
||||||
|
|
||||||
|
#### Available Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
/effects list - List all effects and status
|
||||||
|
/effects <name> on - Enable an effect
|
||||||
|
/effects <name> off - Disable an effect
|
||||||
|
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
|
||||||
|
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Effects Plugin System
|
||||||
|
|
||||||
|
The effects system is implemented as a plugin architecture in `engine/effects/`.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `effects/types.py` | `EffectConfig`, `EffectContext` dataclasses and `EffectPlugin` protocol |
|
||||||
|
| `effects/registry.py` | Plugin discovery and management (`EffectRegistry`) |
|
||||||
|
| `effects/chain.py` | Ordered pipeline execution (`EffectChain`) |
|
||||||
|
| `effects_plugins/*.py` | Externalized effect plugins |
|
||||||
|
|
||||||
|
### Creating a New Effect
|
||||||
|
|
||||||
|
Create a file in `effects_plugins/` with a class ending in `Effect`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext
|
||||||
|
|
||||||
|
class MyEffect:
|
||||||
|
name = "myeffect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
# Process buffer and return modified buffer
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
```
|
||||||
|
|
||||||
|
### NTFY Commands
|
||||||
|
|
||||||
|
Send commands via `cmdline.py` or directly to the C&C topic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using cmdline tool (recommended)
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise on"
|
||||||
|
mise run cmd "/effects noise intensity 0.5"
|
||||||
|
mise run cmd "/effects reorder noise,glitch,fade,firehose"
|
||||||
|
|
||||||
|
# Or directly via curl
|
||||||
|
curl -d "/effects list" https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The cmdline tool polls the response topic for the daemon's reply.
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: A new feature
|
||||||
|
- `fix`: A bug fix
|
||||||
|
- `docs`: Documentation only changes
|
||||||
|
- `style`: Changes that don't affect code meaning (formatting)
|
||||||
|
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Changes to build process, dependencies, etc.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(effects): add plugin architecture for visual effects
|
||||||
|
fix(layers): resolve glitch effect not applying on empty buffer
|
||||||
|
docs(AGENTS.md): add effects plugin system documentation
|
||||||
|
test(effects): add tests for EffectChain pipeline ordering
|
||||||
|
```
|
||||||
|
|||||||
324
README.md
324
README.md
@@ -6,45 +6,65 @@ A full-screen terminal news ticker that renders live global headlines in large O
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Using
|
## Run
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 mainline.py # news stream
|
python3 mainline.py # news stream
|
||||||
python3 mainline.py --poetry # literary consciousness mode
|
python3 mainline.py --poetry # literary consciousness mode
|
||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --display websocket # web browser display only
|
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||||
python3 mainline.py --display both # terminal + web browser
|
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
python3 mainline.py --font-file path.otf # use a specific font file
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
python3 mainline.py --font-index 1 # select face index within a collection
|
python3 mainline.py --font-index 1 # select face index within a collection
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with uv:
|
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Daemon Mode (Recommended for Long-Running)
|
||||||
|
|
||||||
|
For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run mainline.py
|
# Start the daemon (runs in background, auto-warms ntfy topics)
|
||||||
|
mise run daemon
|
||||||
|
|
||||||
|
# Send commands via cmdline
|
||||||
|
mise run cmd "/effects list"
|
||||||
|
mise run cmd "/effects noise off"
|
||||||
|
mise run cmd "/effects noise intensity 0.5"
|
||||||
|
|
||||||
|
# Watch performance stats continuously
|
||||||
|
mise run cmd-stats
|
||||||
|
|
||||||
|
# Stop the daemon
|
||||||
|
mise run daemon-stop
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
### How It Works
|
||||||
|
|
||||||
### Command & Control (C&C)
|
- **Topics**: Uses separate ntfy topics for serial-like communication:
|
||||||
|
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
|
||||||
|
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
|
||||||
|
- Topics are automatically created on first daemon start
|
||||||
|
|
||||||
Control mainline remotely using `cmdline.py`:
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```
|
||||||
uv run cmdline.py # Interactive TUI
|
/effects list - List all effects and status
|
||||||
uv run cmdline.py /effects list # List all effects
|
/effects <name> on - Enable an effect
|
||||||
uv run cmdline.py /effects stats # Show performance stats
|
/effects <name> off - Disable an effect
|
||||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
|
||||||
|
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
```
|
```
|
||||||
|
|
||||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
---
|
||||||
|
|
||||||
### Config
|
## Config
|
||||||
|
|
||||||
All constants live in `engine/config.py`:
|
All constants live in `engine/config.py`:
|
||||||
|
|
||||||
@@ -53,50 +73,98 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
|
||||||
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
|
||||||
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||||
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor |
|
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
|
||||||
|
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
||||||
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
|
|
||||||
### Display Modes
|
---
|
||||||
|
|
||||||
Mainline supports multiple display backends:
|
## Fonts
|
||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
- **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.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
### Feeds
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
|
- 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 poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
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/ plugin-based effects system
|
||||||
|
types.py EffectConfig, EffectContext, EffectPlugin protocol
|
||||||
|
registry.py Plugin discovery and management
|
||||||
|
chain.py Ordered pipeline execution
|
||||||
|
performance.py Performance monitoring
|
||||||
|
controller.py NTFY command handler
|
||||||
|
legacy.py Original effects (noise, glitch, fade, firehose)
|
||||||
|
effects_plugins/ External effect plugins (noise, glitch, fade, firehose)
|
||||||
|
display.py Swappable display backends (TerminalDisplay, NullDisplay)
|
||||||
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
|
scroll.py stream() frame loop + message rendering
|
||||||
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
|
frame.py scroll step calculation, timing
|
||||||
|
layers.py ticker zone, firehose, message overlay rendering
|
||||||
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
|
events.py event types and definitions
|
||||||
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
|
emitters.py background emitters for ntfy and mic
|
||||||
|
types.py type definitions and dataclasses
|
||||||
|
```
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feeds
|
||||||
|
|
||||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
||||||
|
|
||||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
||||||
|
|
||||||
### Fonts
|
---
|
||||||
|
|
||||||
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
## ntfy.sh Integration
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
|
||||||
|
|
||||||
### ntfy.sh
|
|
||||||
|
|
||||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -104,160 +172,44 @@ To push a message:
|
|||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
|
||||||
|
|
||||||
## Internals
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
### How it works
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
|
poller.start()
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
# in render loop:
|
||||||
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
||||||
- 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
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/
|
|
||||||
__init__.py package marker
|
|
||||||
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/ 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
|
|
||||||
frame.py scroll step calculation, timing
|
|
||||||
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
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Ideas / Future
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync # minimal (no mic)
|
|
||||||
uv sync --all-extras # with mic support
|
|
||||||
uv sync --all-extras --group dev # full dev environment
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
With [mise](https://mise.jdx.dev/):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run test # run test suite
|
|
||||||
mise run test-cov # run with coverage report
|
|
||||||
|
|
||||||
mise run lint # ruff check
|
|
||||||
mise run lint-fix # ruff check --fix
|
|
||||||
mise run format # ruff format
|
|
||||||
|
|
||||||
mise run run # terminal display
|
|
||||||
mise run run-websocket # web display only
|
|
||||||
mise run run-sixel # sixel graphics
|
|
||||||
mise run run-both # terminal + web
|
|
||||||
mise run run-client # both + open browser
|
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
|
||||||
mise run cmd-stats # watch effects stats
|
|
||||||
|
|
||||||
mise run benchmark # run performance benchmarks
|
|
||||||
mise run benchmark-json # save as JSON
|
|
||||||
|
|
||||||
mise run topics-init # initialize ntfy topics
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest
|
|
||||||
uv run pytest --cov=engine --cov-report=term-missing
|
|
||||||
|
|
||||||
# Run with mise
|
|
||||||
mise run test
|
|
||||||
mise run test-cov
|
|
||||||
|
|
||||||
# Run performance benchmarks
|
|
||||||
mise run benchmark
|
|
||||||
mise run benchmark-json
|
|
||||||
|
|
||||||
# Run benchmark hook mode (for CI)
|
|
||||||
uv run python -m engine.benchmark --hook
|
|
||||||
```
|
|
||||||
|
|
||||||
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
|
|
||||||
|
|
||||||
### Linting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run ruff check engine/ mainline.py
|
|
||||||
uv run ruff format engine/ mainline.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Pre-commit hooks run lint automatically via `hk`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- Concurrent feed fetching with ThreadPoolExecutor
|
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||||
- Background feed refresh daemon
|
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||||
- Translation pre-fetch during boot
|
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- Matrix rain katakana underlay
|
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||||
- CRT scanline simulation
|
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||||
- Sixel/iTerm2 inline images
|
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||||
- Parallax secondary column
|
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
- Keyword watch list with strobe effects
|
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||||
- Breaking interrupt with synthesized audio
|
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||||
- Live data overlay (BTC, ISS position)
|
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||||
- Theme switcher (amber, ice, red)
|
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
||||||
- Persona modes (surveillance, oracle, underground)
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
||||||
|
# test
|
||||||
|
|||||||
@@ -1,366 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Command-line utility for interacting with mainline via ntfy.
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
@@ -21,11 +20,6 @@ C&C works like a serial port:
|
|||||||
3. Cmdline polls for response
|
3. Cmdline polls for response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ["FORCE_COLOR"] = "1"
|
|
||||||
os.environ["TERM"] = "xterm-256color"
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
199
docs/PIPELINE.md
199
docs/PIPELINE.md
@@ -1,199 +0,0 @@
|
|||||||
# 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# README Update Design — 2026-03-15
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Restructure and expand `README.md` to:
|
|
||||||
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
|
||||||
2. Add extensibility-focused content (`Extending` section)
|
|
||||||
3. Add developer workflow coverage (`Development` section)
|
|
||||||
4. Improve navigability via top-level grouping (Approach C)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
# MAINLINE
|
|
||||||
> tagline + description
|
|
||||||
|
|
||||||
## Using
|
|
||||||
### Run
|
|
||||||
### Config
|
|
||||||
### Feeds
|
|
||||||
### Fonts
|
|
||||||
### ntfy.sh
|
|
||||||
|
|
||||||
## Internals
|
|
||||||
### How it works
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
## Extending
|
|
||||||
### NtfyPoller
|
|
||||||
### MicMonitor
|
|
||||||
### Render pipeline
|
|
||||||
|
|
||||||
## Development
|
|
||||||
### Setup
|
|
||||||
### Tasks
|
|
||||||
### Testing
|
|
||||||
### Linting
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
---
|
|
||||||
*footer*
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section-by-section design
|
|
||||||
|
|
||||||
### Using
|
|
||||||
|
|
||||||
All existing content preserved verbatim. Two changes:
|
|
||||||
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
|
||||||
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
|
||||||
|
|
||||||
Subsections moved into Using (currently standalone):
|
|
||||||
- `Feeds` — it's configuration, not a concept
|
|
||||||
- `ntfy.sh` (usage half)
|
|
||||||
|
|
||||||
### Internals
|
|
||||||
|
|
||||||
All existing content preserved verbatim. One change:
|
|
||||||
- **Architecture**: append `tests/` directory listing to the module tree
|
|
||||||
|
|
||||||
### Extending
|
|
||||||
|
|
||||||
Entirely new section. Three subsections:
|
|
||||||
|
|
||||||
**NtfyPoller**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: stdlib only dependencies
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
|
||||||
poller.start()
|
|
||||||
|
|
||||||
# in your render loop:
|
|
||||||
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
|
||||||
if msg:
|
|
||||||
title, body, ts = msg
|
|
||||||
render_my_message(title, body) # visualizer-specific
|
|
||||||
```
|
|
||||||
|
|
||||||
**MicMonitor**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: sounddevice/numpy optional, degrades gracefully
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.mic import MicMonitor
|
|
||||||
|
|
||||||
mic = MicMonitor(threshold_db=50)
|
|
||||||
if mic.start(): # returns False if sounddevice unavailable
|
|
||||||
excess = mic.excess # dB above threshold, clamped to 0
|
|
||||||
db = mic.db # raw RMS dB level
|
|
||||||
```
|
|
||||||
|
|
||||||
**Render pipeline**
|
|
||||||
- Brief prose about `engine.render` as importable pipeline
|
|
||||||
- Minimal sketch of serve.py / ESP32 usage pattern
|
|
||||||
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Entirely new section. Four subsections:
|
|
||||||
|
|
||||||
**Setup**
|
|
||||||
- Hard requirements: Python 3.10+, uv
|
|
||||||
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
|
||||||
|
|
||||||
**Tasks** (via mise)
|
|
||||||
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
|
||||||
|
|
||||||
**Testing**
|
|
||||||
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
|
||||||
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
|
||||||
|
|
||||||
**Linting**
|
|
||||||
- `uv run ruff check` and `uv run ruff format`
|
|
||||||
- Note: pre-commit hooks run lint via `hk`
|
|
||||||
|
|
||||||
### Roadmap
|
|
||||||
|
|
||||||
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
|
||||||
|
|
||||||
### Footer
|
|
||||||
|
|
||||||
Update `Python 3.9+` → `Python 3.10+`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `README.md` — restructured and expanded as above
|
|
||||||
- No other files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What is not changing
|
|
||||||
|
|
||||||
- All existing prose, examples, and config table values — preserved verbatim where retained
|
|
||||||
- The Ideas/Future content — kept intact under the new Roadmap heading
|
|
||||||
- The cyberpunk voice and terse style of the existing README
|
|
||||||
@@ -5,7 +5,6 @@ PLUGIN_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_plugins():
|
def discover_plugins():
|
||||||
from engine.effects.registry import get_registry
|
from engine.effects.registry import get_registry
|
||||||
from engine.effects.types import EffectPlugin
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
imported = {}
|
imported = {}
|
||||||
@@ -23,13 +22,11 @@ def discover_plugins():
|
|||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
isinstance(attr, type)
|
isinstance(attr, type)
|
||||||
and issubclass(attr, EffectPlugin)
|
and hasattr(attr, "name")
|
||||||
and attr is not EffectPlugin
|
and hasattr(attr, "process")
|
||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
if not isinstance(plugin, EffectPlugin):
|
|
||||||
continue
|
|
||||||
registry.register(plugin)
|
registry.register(plugin)
|
||||||
imported[plugin.name] = plugin
|
imported[plugin.name] = plugin
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import random
|
|||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
class FadeEffect(EffectPlugin):
|
class FadeEffect:
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
@@ -54,5 +54,5 @@ class FadeEffect(EffectPlugin):
|
|||||||
i += 1
|
i += 1
|
||||||
return "".join(result)
|
return "".join(result)
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
self.config = config
|
self.config = cfg
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect:
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
@@ -68,5 +68,5 @@ class FirehoseEffect(EffectPlugin):
|
|||||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
return f"{color}{text}{RST}"
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
self.config = config
|
self.config = cfg
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
class GlitchEffect(EffectPlugin):
|
class GlitchEffect:
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
@@ -33,5 +33,5 @@ class GlitchEffect(EffectPlugin):
|
|||||||
o = random.randint(0, w - n)
|
o = random.randint(0, w - n)
|
||||||
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
self.config = config
|
self.config = cfg
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect:
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
@@ -32,5 +32,5 @@ class NoiseEffect(EffectPlugin):
|
|||||||
for _ in range(w)
|
for _ in range(w)
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure(self, config: EffectConfig) -> None:
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
self.config = config
|
self.config = cfg
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
"""
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
635
engine/app.py
635
engine/app.py
@@ -351,517 +351,7 @@ def pick_effects_config():
|
|||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
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():
|
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))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
def handle_sigint(*_):
|
def handle_sigint(*_):
|
||||||
@@ -960,128 +450,3 @@ def main():
|
|||||||
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||||
print(f" {W_GHOST}> end of stream{RST}")
|
print(f" {W_GHOST}> end of stream{RST}")
|
||||||
print()
|
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")
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,730 +0,0 @@
|
|||||||
#!/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
109
engine/camera.py
@@ -1,109 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -129,10 +129,6 @@ class Config:
|
|||||||
|
|
||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
display: str = "pygame"
|
|
||||||
websocket: bool = False
|
|
||||||
websocket_port: int = 8765
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
@@ -172,9 +168,6 @@ class Config:
|
|||||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,26 +229,6 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
|||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
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):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
|
|||||||
@@ -3,16 +3,6 @@ Stream controller - manages input sources and orchestrates the render stream.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from engine.config import Config, get_config
|
from engine.config import Config, get_config
|
||||||
from engine.display import (
|
|
||||||
DisplayRegistry,
|
|
||||||
KittyDisplay,
|
|
||||||
MultiDisplay,
|
|
||||||
NullDisplay,
|
|
||||||
PygameDisplay,
|
|
||||||
SixelDisplay,
|
|
||||||
TerminalDisplay,
|
|
||||||
WebSocketDisplay,
|
|
||||||
)
|
|
||||||
from engine.effects.controller import handle_effects_command
|
from engine.effects.controller import handle_effects_command
|
||||||
from engine.eventbus import EventBus
|
from engine.eventbus import EventBus
|
||||||
from engine.events import EventType, StreamEvent
|
from engine.events import EventType, StreamEvent
|
||||||
@@ -21,40 +11,6 @@ from engine.ntfy import NtfyPoller
|
|||||||
from engine.scroll import stream
|
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:
|
class StreamController:
|
||||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||||
|
|
||||||
@@ -161,10 +117,7 @@ class StreamController:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
display = _get_display(self.config)
|
stream(items, self.ntfy, self.mic)
|
||||||
stream(items, self.ntfy, self.mic, display)
|
|
||||||
if display:
|
|
||||||
display.cleanup()
|
|
||||||
|
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
self.event_bus.publish(
|
self.event_bus.publish(
|
||||||
|
|||||||
102
engine/display.py
Normal file
102
engine/display.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Display output abstraction - allows swapping output backends.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
- init(width, height): Initialize display with terminal dimensions
|
||||||
|
- show(buffer): Render buffer (list of strings) to display
|
||||||
|
- clear(): Clear the display
|
||||||
|
- cleanup(): Shutdown display
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sys.stdout.buffer.write("".join(buffer).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -6,17 +6,11 @@ from engine.effects.legacy import (
|
|||||||
glitch_bar,
|
glitch_bar,
|
||||||
next_headline,
|
next_headline,
|
||||||
noise,
|
noise,
|
||||||
vis_offset,
|
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
from engine.effects.types import (
|
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||||
EffectConfig,
|
|
||||||
EffectContext,
|
|
||||||
PipelineConfig,
|
|
||||||
create_effect_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_effect_chain():
|
def get_effect_chain():
|
||||||
@@ -31,7 +25,6 @@ __all__ = [
|
|||||||
"EffectConfig",
|
"EffectConfig",
|
||||||
"EffectContext",
|
"EffectContext",
|
||||||
"PipelineConfig",
|
"PipelineConfig",
|
||||||
"create_effect_context",
|
|
||||||
"get_registry",
|
"get_registry",
|
||||||
"set_registry",
|
"set_registry",
|
||||||
"get_effect_chain",
|
"get_effect_chain",
|
||||||
@@ -46,5 +39,4 @@ __all__ = [
|
|||||||
"noise",
|
"noise",
|
||||||
"next_headline",
|
"next_headline",
|
||||||
"vis_trunc",
|
"vis_trunc",
|
||||||
"vis_offset",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||||
Depends on: config, terminal, sources.
|
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
|
import random
|
||||||
@@ -82,37 +74,6 @@ def vis_trunc(s, w):
|
|||||||
return "".join(result)
|
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):
|
def next_headline(pool, items, seen):
|
||||||
"""Pull the next unique headline from pool, refilling as needed."""
|
"""Pull the next unique headline from pool, refilling as needed."""
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
"""
|
|
||||||
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 dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -29,11 +8,10 @@ class EffectContext:
|
|||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
ticker_height: int
|
ticker_height: int
|
||||||
camera_x: int = 0
|
mic_excess: float
|
||||||
mic_excess: float = 0.0
|
grad_offset: float
|
||||||
grad_offset: float = 0.0
|
frame_number: int
|
||||||
frame_number: int = 0
|
has_message: bool
|
||||||
has_message: bool = False
|
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,76 +22,15 @@ class EffectConfig:
|
|||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin(ABC):
|
class EffectPlugin:
|
||||||
"""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
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
"""Process the buffer with this effect applied.
|
raise NotImplementedError
|
||||||
|
|
||||||
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:
|
def configure(self, config: EffectConfig) -> None:
|
||||||
"""Configure the effect with new settings.
|
raise NotImplementedError
|
||||||
|
|
||||||
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
|
@dataclass
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from engine.effects import (
|
|||||||
firehose_line,
|
firehose_line,
|
||||||
glitch_bar,
|
glitch_bar,
|
||||||
noise,
|
noise,
|
||||||
vis_offset,
|
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||||
@@ -95,18 +94,16 @@ def render_message_overlay(
|
|||||||
def render_ticker_zone(
|
def render_ticker_zone(
|
||||||
active: list,
|
active: list,
|
||||||
scroll_cam: int,
|
scroll_cam: int,
|
||||||
camera_x: int = 0,
|
ticker_h: int,
|
||||||
ticker_h: int = 0,
|
w: int,
|
||||||
w: int = 80,
|
noise_cache: dict,
|
||||||
noise_cache: dict | None = None,
|
grad_offset: float,
|
||||||
grad_offset: float = 0.0,
|
|
||||||
) -> tuple[list[str], dict]:
|
) -> tuple[list[str], dict]:
|
||||||
"""Render the ticker scroll zone.
|
"""Render the ticker scroll zone.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
active: list of (content_rows, color, canvas_y, meta_idx)
|
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||||
scroll_cam: camera position (viewport top)
|
scroll_cam: camera position (viewport top)
|
||||||
camera_x: horizontal camera offset
|
|
||||||
ticker_h: height of ticker zone
|
ticker_h: height of ticker zone
|
||||||
w: terminal width
|
w: terminal width
|
||||||
noise_cache: dict of cy -> noise string
|
noise_cache: dict of cy -> noise string
|
||||||
@@ -115,8 +112,6 @@ def render_ticker_zone(
|
|||||||
Returns:
|
Returns:
|
||||||
(list of ANSI strings, updated noise_cache)
|
(list of ANSI strings, updated noise_cache)
|
||||||
"""
|
"""
|
||||||
if noise_cache is None:
|
|
||||||
noise_cache = {}
|
|
||||||
buf = []
|
buf = []
|
||||||
top_zone = max(1, int(ticker_h * 0.25))
|
top_zone = max(1, int(ticker_h * 0.25))
|
||||||
bot_zone = max(1, int(ticker_h * 0.10))
|
bot_zone = max(1, int(ticker_h * 0.10))
|
||||||
@@ -142,7 +137,7 @@ def render_ticker_zone(
|
|||||||
colored = lr_gradient([raw], grad_offset)[0]
|
colored = lr_gradient([raw], grad_offset)[0]
|
||||||
else:
|
else:
|
||||||
colored = raw
|
colored = raw
|
||||||
ln = vis_trunc(vis_offset(colored, camera_x), w)
|
ln = vis_trunc(colored, w)
|
||||||
if row_fade < 1.0:
|
if row_fade < 1.0:
|
||||||
ln = fade_line(ln, row_fade)
|
ln = fade_line(ln, row_fade)
|
||||||
|
|
||||||
@@ -233,12 +228,11 @@ def process_effects(
|
|||||||
h: int,
|
h: int,
|
||||||
scroll_cam: int,
|
scroll_cam: int,
|
||||||
ticker_h: int,
|
ticker_h: int,
|
||||||
camera_x: int = 0,
|
mic_excess: float,
|
||||||
mic_excess: float = 0.0,
|
grad_offset: float,
|
||||||
grad_offset: float = 0.0,
|
frame_number: int,
|
||||||
frame_number: int = 0,
|
has_message: bool,
|
||||||
has_message: bool = False,
|
items: list,
|
||||||
items: list | None = None,
|
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Process buffer through effect chain."""
|
"""Process buffer through effect chain."""
|
||||||
if _effect_chain is None:
|
if _effect_chain is None:
|
||||||
@@ -248,13 +242,12 @@ def process_effects(
|
|||||||
terminal_width=w,
|
terminal_width=w,
|
||||||
terminal_height=h,
|
terminal_height=h,
|
||||||
scroll_cam=scroll_cam,
|
scroll_cam=scroll_cam,
|
||||||
camera_x=camera_x,
|
|
||||||
ticker_height=ticker_h,
|
ticker_height=ticker_h,
|
||||||
mic_excess=mic_excess,
|
mic_excess=mic_excess,
|
||||||
grad_offset=grad_offset,
|
grad_offset=grad_offset,
|
||||||
frame_number=frame_number,
|
frame_number=frame_number,
|
||||||
has_message=has_message,
|
has_message=has_message,
|
||||||
items=items or [],
|
items=items,
|
||||||
)
|
)
|
||||||
return _effect_chain.process(buf, ctx)
|
return _effect_chain.process(buf, ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -1,611 +0,0 @@
|
|||||||
"""
|
|
||||||
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())
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -7,7 +7,6 @@ import random
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.display import (
|
from engine.display import (
|
||||||
Display,
|
Display,
|
||||||
TerminalDisplay,
|
TerminalDisplay,
|
||||||
@@ -28,19 +27,10 @@ from engine.viewport import th, tw
|
|||||||
USE_EFFECT_CHAIN = True
|
USE_EFFECT_CHAIN = True
|
||||||
|
|
||||||
|
|
||||||
def stream(
|
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||||
items,
|
|
||||||
ntfy_poller,
|
|
||||||
mic_monitor,
|
|
||||||
display: Display | None = None,
|
|
||||||
camera: Camera | None = None,
|
|
||||||
):
|
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
if display is None:
|
if display is None:
|
||||||
display = TerminalDisplay()
|
display = TerminalDisplay()
|
||||||
if camera is None:
|
|
||||||
camera = Camera.vertical()
|
|
||||||
|
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -56,6 +46,7 @@ def stream(
|
|||||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||||
|
|
||||||
active = []
|
active = []
|
||||||
|
scroll_cam = 0
|
||||||
ticker_next_y = ticker_view_h
|
ticker_next_y = ticker_view_h
|
||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
@@ -81,10 +72,10 @@ def stream(
|
|||||||
scroll_motion_accum += config.FRAME_DT
|
scroll_motion_accum += config.FRAME_DT
|
||||||
while scroll_motion_accum >= scroll_step_interval:
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
scroll_motion_accum -= scroll_step_interval
|
scroll_motion_accum -= scroll_step_interval
|
||||||
camera.update(config.FRAME_DT)
|
scroll_cam += 1
|
||||||
|
|
||||||
while (
|
while (
|
||||||
ticker_next_y < camera.y + ticker_view_h + 10
|
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||||
and queued < config.HEADLINE_LIMIT
|
and queued < config.HEADLINE_LIMIT
|
||||||
):
|
):
|
||||||
from engine.effects import next_headline
|
from engine.effects import next_headline
|
||||||
@@ -97,17 +88,17 @@ def stream(
|
|||||||
queued += 1
|
queued += 1
|
||||||
|
|
||||||
active = [
|
active = [
|
||||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > camera.y
|
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||||
]
|
]
|
||||||
for k in list(noise_cache):
|
for k in list(noise_cache):
|
||||||
if k < camera.y:
|
if k < scroll_cam:
|
||||||
del noise_cache[k]
|
del noise_cache[k]
|
||||||
|
|
||||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
ticker_buf_start = len(buf)
|
ticker_buf_start = len(buf)
|
||||||
|
|
||||||
ticker_buf, noise_cache = render_ticker_zone(
|
ticker_buf, noise_cache = render_ticker_zone(
|
||||||
active, camera.y, camera.x, ticker_h, w, noise_cache, grad_offset
|
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||||
)
|
)
|
||||||
buf.extend(ticker_buf)
|
buf.extend(ticker_buf)
|
||||||
|
|
||||||
@@ -119,9 +110,8 @@ def stream(
|
|||||||
buf,
|
buf,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
camera.y,
|
scroll_cam,
|
||||||
ticker_h,
|
ticker_h,
|
||||||
camera.x,
|
|
||||||
mic_excess,
|
mic_excess,
|
||||||
grad_offset,
|
grad_offset,
|
||||||
frame_number,
|
frame_number,
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
"""
|
|
||||||
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,9 +22,6 @@ hooks {
|
|||||||
prefix = "uv run"
|
prefix = "uv run"
|
||||||
check = "ruff check engine/ tests/"
|
check = "ruff check engine/ tests/"
|
||||||
}
|
}
|
||||||
["benchmark"] {
|
|
||||||
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
75
mise.toml
75
mise.toml
@@ -5,83 +5,40 @@ pkl = "latest"
|
|||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# =====================
|
# =====================
|
||||||
# Testing
|
# Development
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
test-v = "uv run pytest -v"
|
||||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||||
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||||
|
|
||||||
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 = "uv run ruff check engine/ mainline.py"
|
||||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Runtime Modes
|
# Runtime
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
run = "uv run mainline.py"
|
run = "uv run mainline.py"
|
||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
daemon = "nohup uv run mainline.py > /dev/null 2>&1 &"
|
||||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||||
run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
|
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||||
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
|
# Command & Control
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
cmd = "uv run cmdline.py"
|
cmd = "uv run cmdline.py"
|
||||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:"
|
||||||
|
|
||||||
# =====================
|
# Initialize ntfy topics (warm up before first use - also done automatically by mainline)
|
||||||
# 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"
|
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
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
@@ -89,20 +46,24 @@ daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
|||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "mise run sync"
|
install = "mise run sync"
|
||||||
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
install-dev = "mise run sync && uv sync --group dev"
|
||||||
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 .mainline_cache_*.json nohup.out"
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
|
||||||
|
# Aggressive cleanup - removes all generated files, caches, and venv
|
||||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||||
|
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# Git Hooks (via hk)
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
pre-commit = "hk run pre-commit"
|
pre-commit = "hk run pre-commit"
|
||||||
|
|||||||
@@ -30,18 +30,6 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.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 = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
@@ -73,12 +61,6 @@ addopts = [
|
|||||||
"--tb=short",
|
"--tb=short",
|
||||||
"-v",
|
"-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 = [
|
filterwarnings = [
|
||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
|
|
||||||
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,75 +5,7 @@ Tests for engine.controller module.
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.controller import StreamController, _get_display
|
from engine.controller import StreamController
|
||||||
|
|
||||||
|
|
||||||
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:
|
class TestStreamController:
|
||||||
@@ -136,24 +68,6 @@ class TestStreamController:
|
|||||||
assert mic_ok is False
|
assert mic_ok is False
|
||||||
assert ntfy_ok is True
|
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:
|
class TestStreamControllerCleanup:
|
||||||
"""Tests for StreamController cleanup."""
|
"""Tests for StreamController cleanup."""
|
||||||
@@ -169,3 +83,35 @@ class TestStreamControllerCleanup:
|
|||||||
controller.cleanup()
|
controller.cleanup()
|
||||||
|
|
||||||
mock_mic_instance.stop.assert_called_once()
|
mock_mic_instance.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerWarmup:
|
||||||
|
"""Tests for StreamController topic warmup."""
|
||||||
|
|
||||||
|
def test_warmup_topics_idempotent(self):
|
||||||
|
"""warmup_topics can be called multiple times."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert mock_urlopen.call_count >= 3
|
||||||
|
|
||||||
|
def test_warmup_topics_sets_flag(self):
|
||||||
|
"""warmup_topics sets the warmed flag."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen"):
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert StreamController._topics_warmed is True
|
||||||
|
|
||||||
|
def test_warmup_topics_skips_after_first(self):
|
||||||
|
"""warmup_topics skips after first call."""
|
||||||
|
StreamController._topics_warmed = True
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
mock_urlopen.assert_not_called()
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
Tests for engine.display module.
|
Tests for engine.display module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
from engine.display import NullDisplay, TerminalDisplay
|
||||||
|
|
||||||
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay
|
|
||||||
from engine.display.backends.multi import MultiDisplay
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayProtocol:
|
class TestDisplayProtocol:
|
||||||
@@ -28,66 +25,6 @@ class TestDisplayProtocol:
|
|||||||
assert hasattr(display, "cleanup")
|
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:
|
class TestTerminalDisplay:
|
||||||
"""Tests for TerminalDisplay class."""
|
"""Tests for TerminalDisplay class."""
|
||||||
|
|
||||||
@@ -140,71 +77,3 @@ class TestNullDisplay:
|
|||||||
"""cleanup does nothing."""
|
"""cleanup does nothing."""
|
||||||
display = NullDisplay()
|
display = NullDisplay()
|
||||||
display.cleanup()
|
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)
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ Tests for engine.effects.controller module.
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from engine.effects.controller import (
|
from engine.effects.controller import (
|
||||||
_format_stats,
|
|
||||||
handle_effects_command,
|
handle_effects_command,
|
||||||
set_effect_chain_ref,
|
set_effect_chain_ref,
|
||||||
show_effects_menu,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,29 +92,6 @@ class TestHandleEffectsCommand:
|
|||||||
assert "Reordered pipeline" in result
|
assert "Reordered pipeline" in result
|
||||||
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
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):
|
def test_unknown_command(self):
|
||||||
"""unknown command returns error."""
|
"""unknown command returns error."""
|
||||||
result = handle_effects_command("/unknown")
|
result = handle_effects_command("/unknown")
|
||||||
@@ -127,105 +102,6 @@ class TestHandleEffectsCommand:
|
|||||||
result = handle_effects_command("not a command")
|
result = handle_effects_command("not a command")
|
||||||
assert "Unknown command" in result
|
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:
|
class TestSetEffectChainRef:
|
||||||
"""Tests for set_effect_chain_ref function."""
|
"""Tests for set_effect_chain_ref function."""
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
"""
|
|
||||||
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")])
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
|
|
||||||
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,26 +87,10 @@ class TestRenderTickerZone:
|
|||||||
|
|
||||||
def test_returns_list(self):
|
def test_returns_list(self):
|
||||||
"""Returns a list of strings."""
|
"""Returns a list of strings."""
|
||||||
result, cache = layers.render_ticker_zone(
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
[],
|
|
||||||
scroll_cam=0,
|
|
||||||
camera_x=0,
|
|
||||||
ticker_h=10,
|
|
||||||
w=80,
|
|
||||||
noise_cache={},
|
|
||||||
grad_offset=0.0,
|
|
||||||
)
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
|
|
||||||
def test_returns_dict_for_cache(self):
|
def test_returns_dict_for_cache(self):
|
||||||
"""Returns a dict for the noise cache."""
|
"""Returns a dict for the noise cache."""
|
||||||
result, cache = layers.render_ticker_zone(
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
[],
|
|
||||||
scroll_cam=0,
|
|
||||||
camera_x=0,
|
|
||||||
ticker_h=10,
|
|
||||||
w=80,
|
|
||||||
noise_cache={},
|
|
||||||
grad_offset=0.0,
|
|
||||||
)
|
|
||||||
assert isinstance(cache, dict)
|
assert isinstance(cache, dict)
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.ntfy
|
|
||||||
class TestNtfyTopics:
|
class TestNtfyTopics:
|
||||||
def test_cc_cmd_topic_exists_and_writable(self):
|
def test_cc_cmd_topic_exists_and_writable(self):
|
||||||
"""Verify C&C CMD topic exists and accepts messages."""
|
"""Verify C&C CMD topic exists and accepts messages."""
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""
|
|
||||||
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 == ""
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
|
|
||||||
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"
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
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"])
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""
|
|
||||||
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