forked from genewildish/Mainline
Compare commits
3 Commits
a1dcceac47
...
effects_pl
| Author | SHA1 | Date | |
|---|---|---|---|
| 4228400c43 | |||
| 05cc475858 | |||
| cfd7e8931e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,4 +9,3 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
coverage.xml
|
|
||||||
|
|||||||
149
AGENTS.md
149
AGENTS.md
@@ -16,33 +16,19 @@ 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
|
mise run test # Run tests
|
||||||
mise run test-v # Run tests verbose
|
mise run test-v # Run tests verbose
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run test-browser # Run e2e browser tests (requires playwright)
|
mise run lint # Run ruff linter
|
||||||
mise run lint # Run ruff linter
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run format # Run ruff formatter
|
||||||
mise run format # Run ruff formatter
|
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||||
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run run # Run mainline (terminal)
|
|
||||||
mise run run-poetry # Run with poetry feed
|
|
||||||
mise run run-firehose # Run in firehose mode
|
|
||||||
mise run run-websocket # Run with WebSocket display only
|
|
||||||
mise run run-sixel # Run with Sixel graphics display
|
|
||||||
mise run run-both # Run with both terminal and WebSocket
|
|
||||||
mise run run-client # Run both + open browser
|
|
||||||
mise run cmd # Run C&C command interface
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
## Git Hooks
|
||||||
@@ -60,52 +46,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 +102,9 @@ 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 System
|
|
||||||
|
|
||||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
|
||||||
- `display/backends/terminal.py` - ANSI terminal output
|
|
||||||
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
|
||||||
- `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency)
|
|
||||||
- `display/backends/null.py` - headless display for testing
|
|
||||||
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
|
||||||
- `display/__init__.py` - DisplayRegistry for backend discovery
|
|
||||||
|
|
||||||
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
|
||||||
- WebSocket server on port 8765
|
|
||||||
- HTTP server on port 8766 (serves HTML client)
|
|
||||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
|
||||||
|
|
||||||
- **Display modes** (`--display` flag):
|
|
||||||
- `terminal` - Default ANSI terminal output
|
|
||||||
- `websocket` - Web browser display (requires websockets package)
|
|
||||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
|
||||||
- `both` - Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
### Effect Plugin System
|
|
||||||
|
|
||||||
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
|
||||||
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
|
||||||
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
|
||||||
|
|
||||||
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
|
||||||
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
|
||||||
|
|
||||||
### Command & Control
|
|
||||||
|
|
||||||
- C&C uses separate ntfy topics for commands and responses
|
|
||||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
|
||||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
|
||||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
|
||||||
|
|
||||||
### Pipeline Documentation
|
|
||||||
|
|
||||||
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
|
||||||
|
|
||||||
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
|
||||||
1. Edit `docs/PIPELINE.md` with the new architecture
|
|
||||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
|
||||||
3. Commit both the markdown and any new diagram files
|
|
||||||
266
README.md
266
README.md
@@ -15,8 +15,7 @@ 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
|
||||||
@@ -29,20 +28,7 @@ Or with uv:
|
|||||||
uv run mainline.py
|
uv run mainline.py
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
|
||||||
|
|
||||||
### Command & Control (C&C)
|
|
||||||
|
|
||||||
Control mainline remotely using `cmdline.py`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run cmdline.py # Interactive TUI
|
|
||||||
uv run cmdline.py /effects list # List all effects
|
|
||||||
uv run cmdline.py /effects stats # Show performance stats
|
|
||||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
|
|
||||||
@@ -53,32 +39,20 @@ 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 stream endpoint |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||||
### Display Modes
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
|
|
||||||
Mainline supports multiple display backends:
|
|
||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
|
||||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
|
||||||
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
|
||||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
|
||||||
|
|
||||||
### Feeds
|
### Feeds
|
||||||
|
|
||||||
@@ -88,15 +62,15 @@ WebSocket mode serves a web client at http://localhost:8766 with ANSI color supp
|
|||||||
|
|
||||||
### Fonts
|
### Fonts
|
||||||
|
|
||||||
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
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.
|
||||||
|
|
||||||
### ntfy.sh
|
### ntfy.sh
|
||||||
|
|
||||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -104,68 +78,108 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Internals
|
## Internals
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
- 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; results are cached for fast restarts
|
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
- 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
|
- 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 triggers Google Translate and font swap for non-Latin scripts
|
- 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 glitch probability
|
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||||
- The viewport scrolls through pre-rendered blocks with fade zones
|
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
|
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||||
|
|
||||||
```
|
```
|
||||||
engine/
|
engine/
|
||||||
__init__.py package marker
|
__init__.py package marker
|
||||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
config.py constants, CLI flags, glyph tables
|
config.py constants, CLI flags, glyph tables
|
||||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
filter.py HTML stripping, content filter
|
filter.py HTML stripping, content filter
|
||||||
translate.py Google Translate wrapper + region detection
|
translate.py Google Translate wrapper + region detection
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
effects/ plugin architecture for visual effects
|
effects.py noise, glitch_bar, fade, firehose
|
||||||
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
registry.py effect registration and lookup
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
chain.py effect pipeline chaining
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
controller.py handles /effects commands
|
scroll.py stream() frame loop + message rendering
|
||||||
performance.py performance monitoring
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
legacy.py legacy functional effects
|
frame.py scroll step calculation, timing
|
||||||
effects_plugins/ effect plugin implementations
|
layers.py ticker zone, firehose, message overlay rendering
|
||||||
noise.py noise effect
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
fade.py fade effect
|
events.py event types and definitions
|
||||||
glitch.py glitch effect
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
firehose.py firehose effect
|
emitters.py background emitters for ntfy and mic
|
||||||
fetch.py RSS/Gutenberg fetching + cache
|
types.py type definitions and dataclasses
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
|
||||||
|
|
||||||
|
### NtfyPoller
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||||
|
poller.start()
|
||||||
|
|
||||||
|
# in your render loop:
|
||||||
|
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
|
||||||
|
|
||||||
|
### MicMonitor
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
mic = MicMonitor(threshold_db=50)
|
||||||
|
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
|
||||||
|
if result:
|
||||||
|
excess = mic.excess # dB above threshold, clamped to 0
|
||||||
|
db = mic.db # raw RMS dB level
|
||||||
|
```
|
||||||
|
|
||||||
|
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
|
||||||
|
|
||||||
|
### Render pipeline
|
||||||
|
|
||||||
|
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# planned — serve.py does not yet exist
|
||||||
|
from engine.render import render_line, big_wrap
|
||||||
|
from engine.fetch import fetch_all
|
||||||
|
|
||||||
|
headlines = fetch_all()
|
||||||
|
for h in headlines:
|
||||||
|
rows = big_wrap(h.text, font, width=800) # list of half-block rows
|
||||||
|
# threshold to 1-bit, pack bytes, serve over HTTP
|
||||||
|
```
|
||||||
|
|
||||||
|
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -176,7 +190,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # minimal (no mic)
|
uv sync # minimal (no mic)
|
||||||
uv sync --all-extras # with mic support
|
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||||
uv sync --all-extras --group dev # full dev environment
|
uv sync --all-extras --group dev # full dev environment
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -186,47 +200,24 @@ With [mise](https://mise.jdx.dev/):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run test # run test suite
|
mise run test # run test suite
|
||||||
mise run test-cov # run with coverage report
|
mise run test-cov # run with coverage report
|
||||||
|
mise run lint # ruff check
|
||||||
mise run lint # ruff check
|
mise run lint-fix # ruff check --fix
|
||||||
mise run lint-fix # ruff check --fix
|
mise run format # ruff format
|
||||||
mise run format # ruff format
|
mise run run # uv run mainline.py
|
||||||
|
mise run run-poetry # uv run mainline.py --poetry
|
||||||
mise run run # terminal display
|
mise run run-firehose # uv run mainline.py --firehose
|
||||||
mise run run-websocket # web display only
|
|
||||||
mise run run-sixel # sixel graphics
|
|
||||||
mise run run-both # terminal + web
|
|
||||||
mise run run-client # both + open browser
|
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
|
||||||
mise run cmd-stats # watch effects stats
|
|
||||||
|
|
||||||
mise run benchmark # run performance benchmarks
|
|
||||||
mise run benchmark-json # save as JSON
|
|
||||||
|
|
||||||
mise run topics-init # initialize ntfy topics
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run pytest --cov=engine --cov-report=term-missing
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
|
|
||||||
# Run with mise
|
|
||||||
mise run test
|
|
||||||
mise run test-cov
|
|
||||||
|
|
||||||
# Run performance benchmarks
|
|
||||||
mise run benchmark
|
|
||||||
mise run benchmark-json
|
|
||||||
|
|
||||||
# Run benchmark hook mode (for CI)
|
|
||||||
uv run python -m engine.benchmark --hook
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
|
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -241,23 +232,28 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
## Roadmap
|
## 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.10+.*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
118
docs/PIPELINE.md
118
docs/PIPELINE.md
@@ -1,118 +0,0 @@
|
|||||||
# Mainline Pipeline
|
|
||||||
|
|
||||||
## Content to Display Rendering Pipeline
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph Sources["Data Sources"]
|
|
||||||
RSS[("RSS Feeds")]
|
|
||||||
Poetry[("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"]
|
|
||||||
CAM[Camera]
|
|
||||||
NH[next_headline]
|
|
||||||
RTZ[render_ticker_zone]
|
|
||||||
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"]
|
|
||||||
RL[render_line]
|
|
||||||
TL[apply_ticker_layout]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Display["Display Backends"]
|
|
||||||
TD[TerminalDisplay]
|
|
||||||
PD[PygameDisplay]
|
|
||||||
SD[SixelDisplay]
|
|
||||||
KD[KittyDisplay]
|
|
||||||
WSD[WebSocketDisplay]
|
|
||||||
ND[NullDisplay]
|
|
||||||
end
|
|
||||||
|
|
||||||
Sources --> Fetch
|
|
||||||
RSS --> FC
|
|
||||||
Poetry --> FP
|
|
||||||
FC --> Cache
|
|
||||||
FP --> Cache
|
|
||||||
Cache --> MB
|
|
||||||
Strip --> MB
|
|
||||||
Trans --> MB
|
|
||||||
MB --> NH
|
|
||||||
NH --> RTZ
|
|
||||||
CAM --> RTZ
|
|
||||||
Grad --> RTZ
|
|
||||||
VT --> RTZ
|
|
||||||
RTZ --> EC
|
|
||||||
EC --> ER
|
|
||||||
ER --> EffectsPlugins
|
|
||||||
EffectsPlugins --> RL
|
|
||||||
RL --> Display
|
|
||||||
Ntfy --> RL
|
|
||||||
Mic --> RL
|
|
||||||
|
|
||||||
style Sources fill:#f9f,stroke:#333
|
|
||||||
style Fetch fill:#bbf,stroke:#333
|
|
||||||
style Scroll fill:#bfb,stroke:#333
|
|
||||||
style Effects fill:#fbf,stroke:#333
|
|
||||||
style Render fill:#ffb,stroke:#333
|
|
||||||
style Display fill:#bff,stroke:#333
|
|
||||||
```
|
|
||||||
|
|
||||||
## Camera Modes
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Vertical
|
|
||||||
Vertical --> Horizontal: mode change
|
|
||||||
Horizontal --> Omni: mode change
|
|
||||||
Omni --> Floating: mode change
|
|
||||||
Floating --> 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -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
|
||||||
|
|||||||
489
engine/app.py
489
engine/app.py
@@ -11,8 +11,10 @@ import time
|
|||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render
|
||||||
from engine.controller import StreamController
|
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.scroll import stream
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
CLR,
|
CLR,
|
||||||
CURSOR_OFF,
|
CURSOR_OFF,
|
||||||
@@ -247,474 +249,7 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
def pick_effects_config():
|
|
||||||
"""Interactive picker for configuring effects pipeline."""
|
|
||||||
import effects_plugins
|
|
||||||
from engine.effects import get_effect_chain, get_registry
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
|
|
||||||
registry = get_registry()
|
|
||||||
chain = get_effect_chain()
|
|
||||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
|
||||||
|
|
||||||
effects = list(registry.list_all().values())
|
|
||||||
if not effects:
|
|
||||||
return
|
|
||||||
|
|
||||||
selected = 0
|
|
||||||
editing_intensity = False
|
|
||||||
intensity_value = 1.0
|
|
||||||
|
|
||||||
def _draw_effects_picker():
|
|
||||||
w = tw()
|
|
||||||
print(CLR, end="")
|
|
||||||
print("\033[1;1H", end="")
|
|
||||||
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
|
||||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for i, effect in enumerate(effects):
|
|
||||||
prefix = " > " if i == selected else " "
|
|
||||||
marker = "[*]" if effect.config.enabled else "[ ]"
|
|
||||||
if editing_intensity and i == selected:
|
|
||||||
print(
|
|
||||||
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
|
||||||
print(
|
|
||||||
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _read_effects_key():
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
if ch == "\x03":
|
|
||||||
return "interrupt"
|
|
||||||
if ch in ("\r", "\n"):
|
|
||||||
return "enter"
|
|
||||||
if ch == " ":
|
|
||||||
return "toggle"
|
|
||||||
if ch == "q":
|
|
||||||
return "quit"
|
|
||||||
if ch == "+" or ch == "=":
|
|
||||||
return "up"
|
|
||||||
if ch == "-" or ch == "_":
|
|
||||||
return "down"
|
|
||||||
if ch == "\x1b":
|
|
||||||
c1 = sys.stdin.read(1)
|
|
||||||
if c1 != "[":
|
|
||||||
return None
|
|
||||||
c2 = sys.stdin.read(1)
|
|
||||||
if c2 == "A":
|
|
||||||
return "up"
|
|
||||||
if c2 == "B":
|
|
||||||
return "down"
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
return
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_effects_picker()
|
|
||||||
key = _read_effects_key()
|
|
||||||
|
|
||||||
if key == "quit" or key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "up" and editing_intensity:
|
|
||||||
intensity_value = min(1.0, intensity_value + 0.1)
|
|
||||||
effects[selected].config.intensity = intensity_value
|
|
||||||
elif key == "down" and editing_intensity:
|
|
||||||
intensity_value = max(0.0, intensity_value - 0.1)
|
|
||||||
effects[selected].config.intensity = intensity_value
|
|
||||||
elif key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
intensity_value = effects[selected].config.intensity
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(effects) - 1, selected + 1)
|
|
||||||
intensity_value = effects[selected].config.intensity
|
|
||||||
elif key == "toggle":
|
|
||||||
effects[selected].config.enabled = not effects[selected].config.enabled
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
|
|
||||||
def 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_animated_pipeline
|
|
||||||
|
|
||||||
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_animated_pipeline(w, 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 main():
|
def main():
|
||||||
from engine import config
|
|
||||||
from engine.pipeline import generate_pipeline_diagram
|
|
||||||
|
|
||||||
if config.PIPELINE_DIAGRAM:
|
|
||||||
print(generate_pipeline_diagram())
|
|
||||||
return
|
|
||||||
|
|
||||||
if config.PIPELINE_DEMO:
|
|
||||||
run_pipeline_demo()
|
|
||||||
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(*_):
|
||||||
@@ -724,13 +259,10 @@ def main():
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
pick_effects_config()
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
@@ -782,10 +314,9 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
controller = StreamController()
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
mic_ok = mic.start()
|
||||||
|
if mic.available:
|
||||||
if controller.mic and controller.mic.available:
|
|
||||||
boot_ln(
|
boot_ln(
|
||||||
"Microphone",
|
"Microphone",
|
||||||
"ACTIVE"
|
"ACTIVE"
|
||||||
@@ -794,6 +325,12 @@ def main():
|
|||||||
bool(mic_ok),
|
bool(mic_ok),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ntfy = NtfyPoller(
|
||||||
|
config.NTFY_TOPIC,
|
||||||
|
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||||
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||||
|
)
|
||||||
|
ntfy_ok = ntfy.start()
|
||||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
if config.FIREHOSE:
|
if config.FIREHOSE:
|
||||||
@@ -806,7 +343,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
controller.run(items)
|
stream(items, ntfy, mic)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -105,8 +105,6 @@ class Config:
|
|||||||
firehose: bool = False
|
firehose: bool = False
|
||||||
|
|
||||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
|
||||||
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
|
||||||
ntfy_reconnect_delay: int = 5
|
ntfy_reconnect_delay: int = 5
|
||||||
message_display_secs: int = 30
|
message_display_secs: int = 30
|
||||||
|
|
||||||
@@ -129,10 +127,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 = "terminal"
|
|
||||||
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)."""
|
||||||
@@ -154,8 +148,6 @@ class Config:
|
|||||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
firehose="--firehose" in argv,
|
firehose="--firehose" in argv,
|
||||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
|
||||||
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
|
||||||
ntfy_reconnect_delay=5,
|
ntfy_reconnect_delay=5,
|
||||||
message_display_secs=30,
|
message_display_secs=30,
|
||||||
font_dir=font_dir,
|
font_dir=font_dir,
|
||||||
@@ -172,9 +164,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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -204,8 +193,6 @@ FIREHOSE = "--firehose" in sys.argv
|
|||||||
|
|
||||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
|
||||||
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
|
||||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
@@ -236,19 +223,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 "terminal"
|
|
||||||
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 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,17 +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.eventbus import EventBus
|
from engine.eventbus import EventBus
|
||||||
from engine.events import EventType, StreamEvent
|
from engine.events import EventType, StreamEvent
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
@@ -21,82 +10,14 @@ 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."""
|
||||||
|
|
||||||
_topics_warmed = False
|
|
||||||
|
|
||||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||||
self.config = config or get_config()
|
self.config = config or get_config()
|
||||||
self.event_bus = event_bus
|
self.event_bus = event_bus
|
||||||
self.mic: MicMonitor | None = None
|
self.mic: MicMonitor | None = None
|
||||||
self.ntfy: NtfyPoller | None = None
|
self.ntfy: NtfyPoller | None = None
|
||||||
self.ntfy_cc: NtfyPoller | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def warmup_topics(cls) -> None:
|
|
||||||
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
|
||||||
if cls._topics_warmed:
|
|
||||||
return
|
|
||||||
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
topics = [
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
|
||||||
"https://ntfy.sh/klubhaus_terminal_mainline",
|
|
||||||
]
|
|
||||||
|
|
||||||
for topic in topics:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic,
|
|
||||||
data=b"init",
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cls._topics_warmed = True
|
|
||||||
|
|
||||||
def initialize_sources(self) -> tuple[bool, bool]:
|
def initialize_sources(self) -> tuple[bool, bool]:
|
||||||
"""Initialize microphone and ntfy sources.
|
"""Initialize microphone and ntfy sources.
|
||||||
@@ -114,38 +35,7 @@ class StreamController:
|
|||||||
)
|
)
|
||||||
ntfy_ok = self.ntfy.start()
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
self.ntfy_cc = NtfyPoller(
|
return bool(mic_ok), ntfy_ok
|
||||||
self.config.ntfy_cc_cmd_topic,
|
|
||||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
|
||||||
display_secs=5,
|
|
||||||
)
|
|
||||||
self.ntfy_cc.subscribe(self._handle_cc_message)
|
|
||||||
ntfy_cc_ok = self.ntfy_cc.start()
|
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
|
||||||
|
|
||||||
def _handle_cc_message(self, event) -> None:
|
|
||||||
"""Handle incoming C&C message - like a serial port control interface."""
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
|
||||||
if not cmd.startswith("/"):
|
|
||||||
return
|
|
||||||
|
|
||||||
response = handle_effects_command(cmd)
|
|
||||||
|
|
||||||
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
|
||||||
data = response.encode("utf-8")
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=data,
|
|
||||||
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def run(self, items: list) -> None:
|
def run(self, items: list) -> None:
|
||||||
"""Run the stream with initialized sources."""
|
"""Run the stream with initialized sources."""
|
||||||
@@ -161,10 +51,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,265 +0,0 @@
|
|||||||
"""
|
|
||||||
Pipeline introspection - generates self-documenting diagrams of the render pipeline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
||||||
for node in self.nodes:
|
|
||||||
node_id = node.name.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}"
|
|
||||||
|
|
||||||
lines.append(f' {node_id}["{label}"]')
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
for node in self.nodes:
|
|
||||||
node_id = node.name.replace("-", "_").replace(" ", "_")
|
|
||||||
if node.inputs:
|
|
||||||
for inp in node.inputs:
|
|
||||||
inp_id = inp.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 --> 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("```")
|
|
||||||
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_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"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
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 run(self) -> str:
|
|
||||||
"""Run full introspection."""
|
|
||||||
self.introspect_sources()
|
|
||||||
self.introspect_fetch()
|
|
||||||
self.introspect_scroll()
|
|
||||||
self.introspect_camera()
|
|
||||||
self.introspect_effects()
|
|
||||||
self.introspect_display()
|
|
||||||
|
|
||||||
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,123 +0,0 @@
|
|||||||
"""
|
|
||||||
Pipeline visualization - ASCII text graphics showing the render pipeline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def generate_pipeline_visualization(width: int = 80, height: int = 24) -> list[str]:
|
|
||||||
"""Generate ASCII visualization of the pipeline.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Width of the visualization in characters
|
|
||||||
height: Height in lines
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of formatted strings representing the pipeline
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
for y in range(height):
|
|
||||||
line = ""
|
|
||||||
|
|
||||||
if y == 1:
|
|
||||||
line = "╔" + "═" * (width - 2) + "╗"
|
|
||||||
elif y == 2:
|
|
||||||
line = "║" + " RENDER PIPELINE ".center(width - 2) + "║"
|
|
||||||
elif y == 3:
|
|
||||||
line = "╠" + "═" * (width - 2) + "╣"
|
|
||||||
|
|
||||||
elif y == 5:
|
|
||||||
line = "║ SOURCES ══════════════> FETCH ═════════> SCROLL ═══> EFFECTS ═> DISPLAY"
|
|
||||||
elif y == 6:
|
|
||||||
line = "║ │ │ │ │"
|
|
||||||
elif y == 7:
|
|
||||||
line = "║ RSS Poetry Camera Terminal"
|
|
||||||
elif y == 8:
|
|
||||||
line = "║ Ntfy Cache Noise WebSocket"
|
|
||||||
elif y == 9:
|
|
||||||
line = "║ Mic Fade Pygame"
|
|
||||||
elif y == 10:
|
|
||||||
line = "║ Glitch Sixel"
|
|
||||||
elif y == 11:
|
|
||||||
line = "║ Firehose Kitty"
|
|
||||||
elif y == 12:
|
|
||||||
line = "║ Hud"
|
|
||||||
|
|
||||||
elif y == 14:
|
|
||||||
line = "╠" + "═" * (width - 2) + "╣"
|
|
||||||
elif y == 15:
|
|
||||||
line = "║ CAMERA MODES "
|
|
||||||
remaining = width - len(line) - 1
|
|
||||||
line += (
|
|
||||||
"─" * (remaining // 2 - 7)
|
|
||||||
+ " VERTICAL "
|
|
||||||
+ "─" * (remaining // 2 - 6)
|
|
||||||
+ "║"
|
|
||||||
)
|
|
||||||
elif y == 16:
|
|
||||||
line = (
|
|
||||||
"║ "
|
|
||||||
+ "●".center(8)
|
|
||||||
+ " "
|
|
||||||
+ "○".center(8)
|
|
||||||
+ " "
|
|
||||||
+ "○".center(8)
|
|
||||||
+ " "
|
|
||||||
+ "○".center(8)
|
|
||||||
+ " " * 20
|
|
||||||
+ "║"
|
|
||||||
)
|
|
||||||
elif y == 17:
|
|
||||||
line = (
|
|
||||||
"║ scroll up scroll left diagonal bobbing "
|
|
||||||
+ " " * 16
|
|
||||||
+ "║"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif y == 19:
|
|
||||||
line = "╠" + "═" * (width - 2) + "╣"
|
|
||||||
elif y == 20:
|
|
||||||
fps = "60"
|
|
||||||
line = (
|
|
||||||
f"║ FPS: {fps} │ Frame: 16.7ms │ Effects: 5 active │ Camera: VERTICAL "
|
|
||||||
+ " " * (width - len(line) - 2)
|
|
||||||
+ "║"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif y == 21:
|
|
||||||
line = "╚" + "═" * (width - 2) + "╝"
|
|
||||||
|
|
||||||
else:
|
|
||||||
line = " " * width
|
|
||||||
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def generate_animated_pipeline(width: int = 80, frame: int = 0) -> list[str]:
|
|
||||||
"""Generate animated ASCII visualization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Width of the visualization
|
|
||||||
frame: Animation frame number
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of formatted strings
|
|
||||||
"""
|
|
||||||
lines = generate_pipeline_visualization(width, 20)
|
|
||||||
|
|
||||||
anim_chars = ["▓", "▒", "░", " ", "▓", "▒", "░"]
|
|
||||||
char = anim_chars[frame % len(anim_chars)]
|
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
if "Effects" in line:
|
|
||||||
lines[i] = line.replace("═" * 5, char * 5)
|
|
||||||
|
|
||||||
if "FPS:" in line:
|
|
||||||
lines[i] = (
|
|
||||||
f"║ FPS: {60 - frame % 10} │ Frame: {16 + frame % 5:.1f}ms │ Effects: {5 - (frame % 3)} active │ Camera: {['VERTICAL', 'HORIZONTAL', 'OMNI', 'FLOATING'][frame % 4]} "
|
|
||||||
+ " " * (80 - len(lines[i]) - 2)
|
|
||||||
+ "║"
|
|
||||||
)
|
|
||||||
|
|
||||||
return lines
|
|
||||||
@@ -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,
|
||||||
|
|||||||
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()
|
|
||||||
68
mise.toml
68
mise.toml
@@ -5,90 +5,48 @@ 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"] }
|
|
||||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
|
||||||
run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
|
|
||||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
|
||||||
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
|
||||||
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
|
||||||
run-demo = { run = "uv run mainline.py --demo --display pygame", depends = ["sync-all"] }
|
|
||||||
run-pipeline = "uv run mainline.py --pipeline-diagram"
|
|
||||||
run-pipeline-demo = { run = "uv run mainline.py --pipeline-demo --display pygame", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Command & Control
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
cmd = "uv run cmdline.py"
|
|
||||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Benchmark
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
|
||||||
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
|
||||||
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
# Initialize ntfy topics (warm up before first use)
|
|
||||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
|
||||||
|
|
||||||
# =====================
|
|
||||||
# Daemon
|
|
||||||
# =====================
|
|
||||||
|
|
||||||
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
|
||||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
|
||||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "mise run sync"
|
install = "uv sync"
|
||||||
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
install-dev = "uv sync --group dev"
|
||||||
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
|
||||||
|
|
||||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
bootstrap = "uv sync && uv run mainline.py --help"
|
||||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
|
||||||
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||||
|
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# 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,10 +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)",
|
|
||||||
]
|
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"ignore::DeprecationWarning",
|
"ignore::DeprecationWarning",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for ntfy topics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
|
|
||||||
class TestNtfyTopics:
|
|
||||||
def test_cc_cmd_topic_exists_and_writable(self):
|
|
||||||
"""Verify C&C CMD topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
def test_cc_resp_topic_exists_and_writable(self):
|
|
||||||
"""Verify C&C RESP topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_CC_RESP_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
|
||||||
|
|
||||||
def test_message_topic_exists_and_writable(self):
|
|
||||||
"""Verify message topic exists and accepts messages."""
|
|
||||||
from engine.config import NTFY_TOPIC
|
|
||||||
|
|
||||||
topic_url = NTFY_TOPIC.replace("/json", "")
|
|
||||||
test_message = f"test_{int(time.time())}"
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
assert resp.status == 200
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
|
||||||
|
|
||||||
def test_cc_cmd_topic_readable(self):
|
|
||||||
"""Verify we can read messages from C&C CMD topic."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC
|
|
||||||
|
|
||||||
test_message = f"integration_test_{int(time.time())}"
|
|
||||||
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
topic_url,
|
|
||||||
data=test_message.encode("utf-8"),
|
|
||||||
headers={
|
|
||||||
"User-Agent": "mainline-test/0.1",
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
|
||||||
req = urllib.request.Request(
|
|
||||||
poll_url,
|
|
||||||
headers={"User-Agent": "mainline-test/0.1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
body = resp.read().decode("utf-8")
|
|
||||||
if body.strip():
|
|
||||||
data = json.loads(body.split("\n")[0])
|
|
||||||
assert isinstance(data, dict)
|
|
||||||
except Exception as e:
|
|
||||||
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
|
||||||
|
|
||||||
def test_topics_are_different(self):
|
|
||||||
"""Verify C&C CMD/RESP and message topics are different."""
|
|
||||||
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
|
||||||
|
|
||||||
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
|
||||||
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
|
||||||
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
|
||||||
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
|
||||||
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
|
||||||
@@ -1,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