Compare commits
12 Commits
chore/plug
...
ab3e1766b1
| Author | SHA1 | Date | |
|---|---|---|---|
| ab3e1766b1 | |||
| 829c4ab63d | |||
| 22dd063baa | |||
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
coverage.xml
|
||||||
|
|||||||
104
AGENTS.md
104
AGENTS.md
@@ -16,19 +16,33 @@ This project uses:
|
|||||||
mise run install
|
mise run install
|
||||||
|
|
||||||
# Or equivalently:
|
# Or equivalently:
|
||||||
uv sync
|
uv sync --all-extras # includes mic support
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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 lint # Run ruff linter
|
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint # Run ruff linter
|
||||||
mise run format # Run ruff formatter
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
mise run format # Run ruff formatter
|
||||||
|
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
|
||||||
@@ -46,9 +60,52 @@ 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
|
- **pre-push**: runs ruff check + benchmark hook
|
||||||
|
|
||||||
|
## Benchmark Runner
|
||||||
|
|
||||||
|
Run performance benchmarks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run benchmark # Run all benchmarks (text output)
|
||||||
|
mise run benchmark-json # Run benchmarks (JSON output)
|
||||||
|
mise run benchmark-report # Run benchmarks (Markdown report)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benchmark Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run benchmarks
|
||||||
|
uv run python -m engine.benchmark
|
||||||
|
|
||||||
|
# Run with specific displays/effects
|
||||||
|
uv run python -m engine.benchmark --displays null,terminal --effects fade,glitch
|
||||||
|
|
||||||
|
# Save baseline for hook comparisons
|
||||||
|
uv run python -m engine.benchmark --baseline
|
||||||
|
|
||||||
|
# Run in hook mode (compares against baseline)
|
||||||
|
uv run python -m engine.benchmark --hook
|
||||||
|
|
||||||
|
# Hook mode with custom threshold (default: 20% degradation)
|
||||||
|
uv run python -m engine.benchmark --hook --threshold 0.3
|
||||||
|
|
||||||
|
# Custom baseline location
|
||||||
|
uv run python -m engine.benchmark --hook --cache /path/to/cache.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook Mode
|
||||||
|
|
||||||
|
The `--hook` mode compares current benchmarks against a saved baseline. If performance degrades beyond the threshold (default 20%), it exits with code 1. This is useful for preventing performance regressions in feature branches.
|
||||||
|
|
||||||
|
The pre-push hook runs benchmark in hook mode to catch performance regressions before pushing.
|
||||||
|
|
||||||
## Workflow Rules
|
## Workflow Rules
|
||||||
|
|
||||||
@@ -106,5 +163,32 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
|
|
||||||
- **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
|
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
||||||
|
- **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.py`): swap display backends via the Display protocol
|
||||||
|
- `TerminalDisplay` - ANSI terminal output
|
||||||
|
- `WebSocketDisplay` - broadcasts to web clients via WebSocket
|
||||||
|
- `SixelDisplay` - renders to Sixel graphics (pure Python, no C dependency)
|
||||||
|
- `MultiDisplay` - forwards to multiple displays simultaneously
|
||||||
|
|
||||||
|
- **WebSocket display** (`engine/websocket_display.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
|
||||||
|
|
||||||
|
### 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)
|
||||||
241
README.md
241
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ 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 --refresh # force re-fetch (bypass cache)
|
python3 mainline.py --display websocket # web browser display only
|
||||||
|
python3 mainline.py --display both # terminal + web browser
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --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
|
||||||
@@ -28,7 +29,20 @@ Or with uv:
|
|||||||
uv run mainline.py
|
uv run mainline.py
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
|
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||||
|
|
||||||
|
### Command & Control (C&C)
|
||||||
|
|
||||||
|
Control mainline remotely using `cmdline.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cmdline.py # Interactive TUI
|
||||||
|
uv run cmdline.py /effects list # List all effects
|
||||||
|
uv run cmdline.py /effects stats # Show performance stats
|
||||||
|
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
|
|
||||||
@@ -39,20 +53,31 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
||||||
|
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
||||||
|
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
||||||
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
| `SSAA` | `4` | Super-sampling factor |
|
||||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
### Display Modes
|
||||||
|
|
||||||
|
Mainline supports multiple display backends:
|
||||||
|
|
||||||
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
|
- **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
|
||||||
|
|
||||||
@@ -62,30 +87,15 @@ All constants live in `engine/config.py`:
|
|||||||
|
|
||||||
### Fonts
|
### Fonts
|
||||||
|
|
||||||
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
||||||
|
|
||||||
### Color Schemes
|
|
||||||
|
|
||||||
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
|
|
||||||
|
|
||||||
On startup, an interactive picker presents all available color schemes:
|
|
||||||
```
|
|
||||||
[1] Verdant Green (white-hot → deep green)
|
|
||||||
[2] Molten Orange (white-hot → deep orange)
|
|
||||||
[3] Violet Purple (white-hot → deep purple)
|
|
||||||
```
|
|
||||||
|
|
||||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
|
|
||||||
|
|
||||||
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
|
|
||||||
|
|
||||||
### 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 for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
||||||
|
|
||||||
To push a message:
|
To push a message:
|
||||||
|
|
||||||
@@ -93,108 +103,54 @@ 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; `--no-font-picker` skips directly to stream
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
- The ticker uses a sweeping white-hot → deep green gradient
|
||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||||
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||||
|
|
||||||
### 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, signal handler
|
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||||
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.py noise, glitch_bar, fade, firehose
|
effects/ plugin architecture for visual effects
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
controller.py handles /effects commands
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
chain.py effect pipeline chaining
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
registry.py effect registration and lookup
|
||||||
scroll.py stream() frame loop + message rendering
|
performance.py performance monitoring
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
fetch.py RSS/Gutenberg fetching + cache
|
||||||
frame.py scroll step calculation, timing
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
layers.py ticker zone, firehose, message overlay rendering
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
eventbus.py thread-safe event publishing for decoupled communication
|
scroll.py stream() frame loop + message rendering
|
||||||
events.py event types and definitions
|
viewport.py terminal dimension tracking
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
frame.py scroll step calculation, timing
|
||||||
emitters.py background emitters for ntfy and mic
|
layers.py ticker zone, firehose, message overlay
|
||||||
types.py type definitions and dataclasses
|
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.py Display protocol (Terminal, WebSocket, Multi)
|
||||||
|
websocket_display.py WebSocket server for browser clients
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
||||||
@@ -205,7 +161,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 (sounddevice + numpy)
|
uv sync --all-extras # with mic support
|
||||||
uv sync --all-extras --group dev # full dev environment
|
uv sync --all-extras --group dev # full dev environment
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -219,15 +175,19 @@ 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-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 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
|
||||||
@@ -247,28 +207,23 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
- Concurrent feed fetching with ThreadPoolExecutor
|
||||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
- Background feed refresh daemon
|
||||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
- Translation pre-fetch during boot
|
||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
- Matrix rain katakana underlay
|
||||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
- CRT scanline simulation
|
||||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
- Sixel/iTerm2 inline images
|
||||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
- Parallax secondary column
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
- Keyword watch list with strobe effects
|
||||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
- Breaking interrupt with synthesized audio
|
||||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
- Live data overlay (BTC, ISS position)
|
||||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
- Theme switcher (amber, ice, red)
|
||||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
- Persona modes (surveillance, oracle, underground)
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||||
366
client/index.html
Normal file
366
client/index.html
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Terminal</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
body.fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body.fullscreen #controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid #333;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
body.fullscreen canvas {
|
||||||
|
border: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
#controls input {
|
||||||
|
width: 60px;
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected {
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
#status.disconnected {
|
||||||
|
color: #f44;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<canvas id="terminal"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||||
|
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||||
|
<button id="apply">Apply</button>
|
||||||
|
<button id="fullscreen">Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="disconnected">Connecting...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('terminal');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const colsInput = document.getElementById('cols');
|
||||||
|
const rowsInput = document.getElementById('rows');
|
||||||
|
const applyBtn = document.getElementById('apply');
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreen');
|
||||||
|
|
||||||
|
const CHAR_WIDTH = 9;
|
||||||
|
const CHAR_HEIGHT = 16;
|
||||||
|
|
||||||
|
const ANSI_COLORS = {
|
||||||
|
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||||
|
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||||
|
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||||
|
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = cols * CHAR_WIDTH;
|
||||||
|
canvas.height = rows * CHAR_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsi(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
let currentText = '';
|
||||||
|
let fg = '#cccccc';
|
||||||
|
let bg = '#000000';
|
||||||
|
let bold = false;
|
||||||
|
let i = 0;
|
||||||
|
let inEscape = false;
|
||||||
|
let escapeCode = '';
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
if (inEscape) {
|
||||||
|
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||||
|
escapeCode += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === 'm') {
|
||||||
|
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const num = parseInt(code) || 0;
|
||||||
|
|
||||||
|
if (num === 0) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
bg = '#000000';
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 1) {
|
||||||
|
bold = true;
|
||||||
|
} else if (num === 22) {
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 39) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
} else if (num === 49) {
|
||||||
|
bg = '#000000';
|
||||||
|
} else if (num >= 30 && num <= 37) {
|
||||||
|
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||||
|
} else if (num >= 40 && num <= 47) {
|
||||||
|
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||||
|
} else if (num >= 90 && num <= 97) {
|
||||||
|
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||||
|
} else if (num >= 100 && num <= 107) {
|
||||||
|
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||||
|
} else if (num >= 1 && num <= 256) {
|
||||||
|
// 256 colors
|
||||||
|
if (num < 16) {
|
||||||
|
fg = ANSI_COLORS[num] || '#cccccc';
|
||||||
|
} else if (num < 232) {
|
||||||
|
const c = num - 16;
|
||||||
|
const r = Math.floor(c / 36) * 51;
|
||||||
|
const g = Math.floor((c % 36) / 6) * 51;
|
||||||
|
const b = (c % 6) * 51;
|
||||||
|
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||||
|
} else {
|
||||||
|
const gray = (num - 232) * 10 + 8;
|
||||||
|
fg = `#${gray.toString(16).repeat(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = false;
|
||||||
|
escapeCode = '';
|
||||||
|
}
|
||||||
|
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = true;
|
||||||
|
escapeCode = '';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
currentText += char;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(text, x, y, lineHeight) {
|
||||||
|
const tokens = parseAnsi(text);
|
||||||
|
let xOffset = x;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.text) {
|
||||||
|
if (token.bold) {
|
||||||
|
ctx.font = 'bold 16px monospace';
|
||||||
|
} else {
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(token.text);
|
||||||
|
|
||||||
|
if (token.bg !== '#000000') {
|
||||||
|
ctx.fillStyle = token.bg;
|
||||||
|
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = token.fg;
|
||||||
|
ctx.fillText(token.text, xOffset, y);
|
||||||
|
xOffset += metrics.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'connected';
|
||||||
|
sendSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
status.textContent = 'Disconnected - Reconnecting...';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
status.textContent = 'Connection error';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'frame') {
|
||||||
|
cols = data.width || 80;
|
||||||
|
rows = data.height || 24;
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
render(data.lines || []);
|
||||||
|
} else if (data.type === 'clear') {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSize() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
width: parseInt(colsInput.value),
|
||||||
|
height: parseInt(rowsInput.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(lines) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const lineHeight = CHAR_HEIGHT;
|
||||||
|
const maxLines = Math.min(lines.length, rows);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const line = lines[i] || '';
|
||||||
|
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateViewportSize() {
|
||||||
|
const isFullscreen = document.fullscreenElement !== null;
|
||||||
|
const padding = isFullscreen ? 0 : 40;
|
||||||
|
const controlsHeight = isFullscreen ? 0 : 60;
|
||||||
|
const availableWidth = window.innerWidth - padding;
|
||||||
|
const availableHeight = window.innerHeight - controlsHeight;
|
||||||
|
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||||
|
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||||
|
sendSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', () => {
|
||||||
|
cols = parseInt(colsInput.value);
|
||||||
|
rows = parseInt(rowsInput.value);
|
||||||
|
resizeCanvas();
|
||||||
|
sendSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
document.documentElement.requestFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
resizeCanvas();
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/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.
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ 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
|
||||||
|
|||||||
@@ -1,894 +0,0 @@
|
|||||||
# Color Scheme Switcher Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
|
||||||
|
|
||||||
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Purpose | Change Type |
|
|
||||||
|------|---------|------------|
|
|
||||||
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
|
||||||
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
|
||||||
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
|
||||||
| `engine/scroll.py` | Update message gradient call | Modify |
|
|
||||||
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
|
||||||
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: Theme Data Module
|
|
||||||
|
|
||||||
### Task 1: Create themes.py with Theme class and registry
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `engine/themes.py`
|
|
||||||
- Test: `tests/test_themes.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for Theme class**
|
|
||||||
|
|
||||||
Create `tests/test_themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Test color themes and registry."""
|
|
||||||
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_construction():
|
|
||||||
"""Theme stores name and gradient lists."""
|
|
||||||
main = ["\033[1;38;5;231m"] * 12
|
|
||||||
msg = ["\033[1;38;5;225m"] * 12
|
|
||||||
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
|
||||||
|
|
||||||
assert theme.name == "Test Green"
|
|
||||||
assert theme.main_gradient == main
|
|
||||||
assert theme.message_gradient == msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_gradient_length():
|
|
||||||
"""Each gradient must have exactly 12 ANSI codes."""
|
|
||||||
for theme_id, theme in THEME_REGISTRY.items():
|
|
||||||
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
|
||||||
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_registry_has_three_themes():
|
|
||||||
"""Registry contains green, orange, purple."""
|
|
||||||
assert len(THEME_REGISTRY) == 3
|
|
||||||
assert "green" in THEME_REGISTRY
|
|
||||||
assert "orange" in THEME_REGISTRY
|
|
||||||
assert "purple" in THEME_REGISTRY
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_valid():
|
|
||||||
"""get_theme returns Theme object for valid ID."""
|
|
||||||
theme = get_theme("green")
|
|
||||||
assert isinstance(theme, Theme)
|
|
||||||
assert theme.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_invalid():
|
|
||||||
"""get_theme raises KeyError for invalid ID."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
get_theme("invalid_theme")
|
|
||||||
|
|
||||||
|
|
||||||
def test_green_theme_unchanged():
|
|
||||||
"""Green theme uses original green → magenta colors."""
|
|
||||||
green_theme = get_theme("green")
|
|
||||||
# First color should be white (bold)
|
|
||||||
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
|
||||||
# Last deep green
|
|
||||||
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
|
||||||
# Message gradient is magenta
|
|
||||||
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: FAIL (module doesn't exist)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
|
||||||
|
|
||||||
Create `engine/themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Color theme definitions and registry."""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
"""Initialize theme with display name and gradient lists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Display name (e.g., "Verdant Green")
|
|
||||||
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
|
||||||
message_gradient: List of 12 ANSI codes (white → complementary color)
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient
|
|
||||||
self.message_gradient = message_gradient
|
|
||||||
|
|
||||||
|
|
||||||
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
|
||||||
# Each gradient: white → primary/complementary, 12 steps total
|
|
||||||
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
|
||||||
|
|
||||||
_GREEN_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_GREEN_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;215m", # pale orange-white
|
|
||||||
"\033[38;5;209m", # bright orange
|
|
||||||
"\033[38;5;208m", # vibrant orange
|
|
||||||
"\033[38;5;202m", # orange
|
|
||||||
"\033[38;5;166m", # dark orange
|
|
||||||
"\033[38;5;130m", # burnt orange
|
|
||||||
"\033[38;5;94m", # rust
|
|
||||||
"\033[38;5;58m", # dark rust
|
|
||||||
"\033[38;5;94m", # rust (hold)
|
|
||||||
"\033[2;38;5;94m", # dim rust
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;33m", # bright blue
|
|
||||||
"\033[38;5;27m", # blue
|
|
||||||
"\033[38;5;21m", # deep blue
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;18m", # navy
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[2;38;5;18m", # dim navy
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale purple-white
|
|
||||||
"\033[38;5;177m", # bright purple
|
|
||||||
"\033[38;5;171m", # vibrant purple
|
|
||||||
"\033[38;5;165m", # purple
|
|
||||||
"\033[38;5;135m", # medium purple
|
|
||||||
"\033[38;5;129m", # purple
|
|
||||||
"\033[38;5;93m", # dark purple
|
|
||||||
"\033[38;5;57m", # deep purple
|
|
||||||
"\033[38;5;57m", # deep purple (hold)
|
|
||||||
"\033[2;38;5;57m", # dim deep purple
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;226m", # pale yellow-white
|
|
||||||
"\033[38;5;226m", # bright yellow
|
|
||||||
"\033[38;5;220m", # yellow
|
|
||||||
"\033[38;5;220m", # yellow (hold)
|
|
||||||
"\033[38;5;184m", # dark yellow
|
|
||||||
"\033[38;5;184m", # dark yellow (hold)
|
|
||||||
"\033[38;5;178m", # olive-yellow
|
|
||||||
"\033[38;5;178m", # olive-yellow (hold)
|
|
||||||
"\033[38;5;172m", # golden
|
|
||||||
"\033[2;38;5;172m", # dim golden
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
THEME_REGISTRY = {
|
|
||||||
"green": Theme(
|
|
||||||
name="Verdant Green",
|
|
||||||
main_gradient=_GREEN_MAIN,
|
|
||||||
message_gradient=_GREEN_MESSAGE,
|
|
||||||
),
|
|
||||||
"orange": Theme(
|
|
||||||
name="Molten Orange",
|
|
||||||
main_gradient=_ORANGE_MAIN,
|
|
||||||
message_gradient=_ORANGE_MESSAGE,
|
|
||||||
),
|
|
||||||
"purple": Theme(
|
|
||||||
name="Violet Purple",
|
|
||||||
main_gradient=_PURPLE_MAIN,
|
|
||||||
message_gradient=_PURPLE_MESSAGE,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme(theme_id: str) -> Theme:
|
|
||||||
"""Retrieve a theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Theme object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id not found in registry
|
|
||||||
"""
|
|
||||||
if theme_id not in THEME_REGISTRY:
|
|
||||||
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
|
||||||
return THEME_REGISTRY[theme_id]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: PASS (all 6 tests)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/themes.py tests/test_themes.py
|
|
||||||
git commit -m "feat: create Theme class and registry with finalized color gradients
|
|
||||||
|
|
||||||
- Define Theme class to encapsulate name and main/message gradients
|
|
||||||
- Create THEME_REGISTRY with green, orange, purple themes
|
|
||||||
- Each gradient has 12 ANSI 256-color codes finalized
|
|
||||||
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
|
||||||
- Add get_theme() lookup with error handling
|
|
||||||
- Add comprehensive unit tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 2: Config Integration
|
|
||||||
|
|
||||||
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/config.py:1-30`
|
|
||||||
- Test: `tests/test_config.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for config changes**
|
|
||||||
|
|
||||||
Add to `tests/test_config.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_active_theme_initially_none():
|
|
||||||
"""ACTIVE_THEME is None before initialization."""
|
|
||||||
# This test may fail if config is already initialized
|
|
||||||
# We'll set it to None first for testing
|
|
||||||
import engine.config
|
|
||||||
engine.config.ACTIVE_THEME = None
|
|
||||||
assert engine.config.ACTIVE_THEME is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_green():
|
|
||||||
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
set_active_theme("green")
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME is not None
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
assert config.ACTIVE_THEME == get_theme("green")
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_default():
|
|
||||||
"""set_active_theme() with no args defaults to green."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
set_active_theme()
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_invalid():
|
|
||||||
"""set_active_theme() with invalid ID raises KeyError."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
set_active_theme("invalid")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
Expected: FAIL (functions don't exist yet)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
|
||||||
|
|
||||||
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active color theme. Defaults to 'green' if not specified.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is invalid
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# DELETED:
|
|
||||||
# GRAD_COLS = [...]
|
|
||||||
# MSG_GRAD_COLS = [...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 4 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify existing config tests still pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (all existing + new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/config.py tests/test_config.py
|
|
||||||
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
|
||||||
|
|
||||||
- Add ACTIVE_THEME global (initialized to None)
|
|
||||||
- Add set_active_theme(theme_id) function with green default
|
|
||||||
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
|
||||||
- Add comprehensive tests for theme setting"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 3: Render Pipeline Integration
|
|
||||||
|
|
||||||
### Task 3: Update render.py to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/render.py:15-220`
|
|
||||||
- Test: `tests/test_render.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
|
||||||
|
|
||||||
Add to `tests/test_render.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_lr_gradient_uses_active_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
|
||||||
from engine import config, render
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
# Set orange theme
|
|
||||||
config.set_active_theme("orange")
|
|
||||||
|
|
||||||
# Create simple rows
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Result should start with first color from orange main gradient
|
|
||||||
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
|
||||||
from engine import config, render
|
|
||||||
|
|
||||||
# Clear active theme
|
|
||||||
config.ACTIVE_THEME = None
|
|
||||||
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Should not crash and should return something
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_green_gradient_length():
|
|
||||||
"""_default_green_gradient returns 12 colors."""
|
|
||||||
from engine import render
|
|
||||||
|
|
||||||
colors = render._default_green_gradient()
|
|
||||||
assert len(colors) == 12
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Expected: FAIL (function signature doesn't match)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
"""
|
|
||||||
Render rows through a left-to-right color sweep.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text rows to colorize
|
|
||||||
offset: Gradient position offset (for animation)
|
|
||||||
cols: Optional list of color codes. If None, uses active theme.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of colorized rows
|
|
||||||
"""
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rest of function unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
|
||||||
|
|
||||||
Add to `engine/render.py` before `lr_gradient()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (original colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_magenta_gradient():
|
|
||||||
"""Fallback magenta gradient (original message colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 3 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run full render test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing tests may need adjustment for mocking)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/render.py tests/test_render.py
|
|
||||||
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
|
||||||
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
|
||||||
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
|
||||||
- Add tests for theme-aware and fallback gradient rendering"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4: Message Gradient Integration
|
|
||||||
|
|
||||||
### Task 4: Update scroll.py to use message gradient from config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/scroll.py:85-95`
|
|
||||||
- Test: existing `tests/test_scroll.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
|
||||||
|
|
||||||
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
|
||||||
|
|
||||||
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
|
||||||
|
|
||||||
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
|
||||||
|
|
||||||
Old code:
|
|
||||||
```python
|
|
||||||
# Some variation of:
|
|
||||||
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
```
|
|
||||||
|
|
||||||
New code:
|
|
||||||
```python
|
|
||||||
from engine import config
|
|
||||||
msg_cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else render._default_magenta_gradient()
|
|
||||||
)
|
|
||||||
rows = lr_gradient(rows, offset, msg_cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the helper approach (create `msg_gradient()` in render.py):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in scroll.py:
|
|
||||||
```python
|
|
||||||
rows = render.msg_gradient(rows, offset)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run existing scroll tests**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_scroll.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing functionality unchanged)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/scroll.py engine/render.py
|
|
||||||
git commit -m "feat: update scroll.py to use theme message gradient
|
|
||||||
|
|
||||||
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
|
||||||
- Use fallback magenta gradient when theme not initialized
|
|
||||||
- Ensure ntfy messages render in complementary color from selected theme"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 5: Color Picker UI
|
|
||||||
|
|
||||||
### Task 5: Create pick_color_theme() function in app.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/app.py:1-300`
|
|
||||||
- Test: manual/integration (interactive)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write helper functions for color picker UI**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add before `pick_font_face()` function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu."""
|
|
||||||
import sys
|
|
||||||
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
|
||||||
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create pick_color_theme() function**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add after helper function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use green
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... existing signal handler setup ...
|
|
||||||
|
|
||||||
pick_color_theme() # NEW LINE - before font picker
|
|
||||||
pick_font_face()
|
|
||||||
|
|
||||||
# ... rest of main unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual test - run in interactive terminal**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- See color theme picker menu before font picker
|
|
||||||
- Can navigate with ↑/↓ or j/k
|
|
||||||
- Can select with Enter or q
|
|
||||||
- Selected theme applies to scrolling headlines
|
|
||||||
- Can select different themes and see colors change
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
|
||||||
|
|
||||||
Run: `echo "" | python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- No color picker menu shown
|
|
||||||
- Defaults to green theme
|
|
||||||
- App runs without error
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/app.py
|
|
||||||
git commit -m "feat: add pick_color_theme() UI and integration
|
|
||||||
|
|
||||||
- Create _draw_color_picker() to render menu
|
|
||||||
- Create pick_color_theme() function mirroring font picker pattern
|
|
||||||
- Integrate into main() before font picker
|
|
||||||
- Fallback to green theme in non-interactive environments
|
|
||||||
- Support arrow keys and j/k navigation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6: Integration & Validation
|
|
||||||
|
|
||||||
### Task 6: End-to-end testing and cleanup
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: All modified files
|
|
||||||
- Verify: App functionality
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/ -v`
|
|
||||||
|
|
||||||
Expected: PASS (all tests, including new ones)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run linter**
|
|
||||||
|
|
||||||
Run: `ruff check engine/ mainline.py`
|
|
||||||
|
|
||||||
Expected: No errors (fix any style issues)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual integration test - green theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Verdant Green" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in green → deep green
|
|
||||||
- ntfy messages render in magenta gradient
|
|
||||||
- Both work correctly during streaming
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual integration test - orange theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Molten Orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in orange → deep orange
|
|
||||||
- ntfy messages render in blue gradient
|
|
||||||
- Colors are visually distinct from green
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual integration test - purple theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Violet Purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in purple → deep purple
|
|
||||||
- ntfy messages render in yellow gradient
|
|
||||||
- Colors are visually distinct from green and orange
|
|
||||||
|
|
||||||
- [ ] **Step 6: Test poetry mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --poetry`
|
|
||||||
|
|
||||||
Then select "orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Poetry mode works with color picker
|
|
||||||
- Colors apply to poetry rendering
|
|
||||||
|
|
||||||
- [ ] **Step 7: Test code mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --code`
|
|
||||||
|
|
||||||
Then select "purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Code mode works with color picker
|
|
||||||
- Colors apply to code rendering
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify acceptance criteria**
|
|
||||||
|
|
||||||
✓ Color picker displays 3 theme options at startup
|
|
||||||
✓ Selection applies to all headline and message gradients
|
|
||||||
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
✓ No persistence between runs (each run picks fresh)
|
|
||||||
✓ Non-TTY environments default to green without error
|
|
||||||
✓ Architecture supports future random/animation modes
|
|
||||||
✓ All gradient color codes finalized with no TBD values
|
|
||||||
|
|
||||||
- [ ] **Step 9: Final commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat: color scheme switcher implementation complete
|
|
||||||
|
|
||||||
Closes color-pick feature with:
|
|
||||||
- Three selectable color themes (green, orange, purple)
|
|
||||||
- Interactive menu at startup (mirrors font picker UI)
|
|
||||||
- Complementary colors for ntfy message queue
|
|
||||||
- Fallback to green in non-interactive environments
|
|
||||||
- All tests passing, manual validation complete"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Create feature branch PR summary**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Color Scheme Switcher
|
|
||||||
|
|
||||||
Implements interactive color theme selection for Mainline news ticker.
|
|
||||||
|
|
||||||
### What's New
|
|
||||||
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
|
||||||
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
|
||||||
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
|
||||||
- Fresh theme selection each run (no persistence)
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
|
||||||
- `engine/render.py` (theme-aware gradients)
|
|
||||||
- `engine/scroll.py` (message gradient integration)
|
|
||||||
- `engine/app.py` (pick_color_theme UI)
|
|
||||||
- `tests/test_themes.py` (new theme tests)
|
|
||||||
- `README.md` (documentation)
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
All met. App fully tested and ready for merge.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
|
||||||
- [ ] Full suite: `pytest tests/ -v`
|
|
||||||
- [ ] Linting: `ruff check engine/ mainline.py`
|
|
||||||
- [ ] Manual: Green theme selection
|
|
||||||
- [ ] Manual: Orange theme selection
|
|
||||||
- [ ] Manual: Purple theme selection
|
|
||||||
- [ ] Manual: Poetry mode with colors
|
|
||||||
- [ ] Manual: Code mode with colors
|
|
||||||
- [ ] Manual: Non-TTY fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `themes.py` is data-only; never import config or render to prevent cycles
|
|
||||||
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
|
||||||
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
|
||||||
- Message gradients use complementary colors; lookup in scroll.py
|
|
||||||
- Each gradient has 12 colors; verify length in tests
|
|
||||||
- No persistence; fresh picker each run
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Code Scroll Mode — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Branch:** feat/code-scroll
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Mirror the existing `--poetry` mode pattern as closely as possible
|
|
||||||
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
|
||||||
- No changes to `scroll.py` or the render pipeline
|
|
||||||
- The item tuple shape `(text, src, ts)` is unchanged
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Files
|
|
||||||
|
|
||||||
### `engine/fetch_code.py`
|
|
||||||
|
|
||||||
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
|
||||||
|
|
||||||
**Algorithm:**
|
|
||||||
|
|
||||||
1. Glob `engine/*.py` in sorted order
|
|
||||||
2. For each file:
|
|
||||||
a. Read source text
|
|
||||||
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
|
||||||
c. Iterate source lines (1-indexed). Skip if:
|
|
||||||
- The stripped line is empty
|
|
||||||
- The stripped line starts with `#`
|
|
||||||
d. For each kept line emit:
|
|
||||||
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
|
||||||
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
|
||||||
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
|
||||||
3. Return `(items, len(items), 0)`
|
|
||||||
|
|
||||||
**Scope label rules:**
|
|
||||||
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
|
||||||
- `ClassDef` → `name` (no parens)
|
|
||||||
- No enclosing node → `<module>`
|
|
||||||
|
|
||||||
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modified Files
|
|
||||||
|
|
||||||
### `engine/config.py`
|
|
||||||
|
|
||||||
Extend `MODE` detection to recognise `--code`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
MODE = (
|
|
||||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code" if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `engine/app.py`
|
|
||||||
|
|
||||||
**Subtitle line** — extend the subtitle dict:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_subtitle = {
|
|
||||||
"poetry": "literary consciousness stream",
|
|
||||||
"code": "source consciousness stream",
|
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
```
|
|
||||||
|
|
||||||
No cache save/load — local source files are read instantly and change only on disk writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/*.py (sorted)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
fetch_code()
|
|
||||||
│ ast.parse → scope map
|
|
||||||
│ filter blank + comment lines
|
|
||||||
│ emit (line, scope(), engine.module)
|
|
||||||
▼
|
|
||||||
items: List[Tuple[str, str, str]]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
stream(items, ntfy, mic) ← unchanged
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
next_headline() shuffles + recycles automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
|
||||||
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
New file: `tests/test_fetch_code.py`
|
|
||||||
|
|
||||||
| Test | Assertion |
|
|
||||||
|------|-----------|
|
|
||||||
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
|
||||||
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
|
||||||
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
|
||||||
|
|
||||||
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 mainline.py --code # source consciousness mode
|
|
||||||
uv run mainline.py --code
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Syntax highlighting / token-aware coloring (can be added later)
|
|
||||||
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
|
||||||
- Caching code items to disk
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Color Scheme Switcher Design
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Status:** Revised after review
|
|
||||||
**Scope:** Interactive color theme selection for Mainline news ticker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
|
||||||
|
|
||||||
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
**Functional:**
|
|
||||||
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
|
||||||
2. Main headline gradient uses the selected primary color (white → color)
|
|
||||||
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
|
||||||
4. Selection is fresh each run (no persistence)
|
|
||||||
5. Design supports future "random rotation" mode without refactoring
|
|
||||||
|
|
||||||
**Complementary colors (precise opposites):**
|
|
||||||
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
|
||||||
- Orange (38;5;208) → Blue (38;5;21)
|
|
||||||
- Purple (38;5;129) → Yellow (38;5;226)
|
|
||||||
|
|
||||||
**Non-functional:**
|
|
||||||
- Reuse the existing font picker pattern for UI consistency
|
|
||||||
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
|
||||||
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
|
||||||
- Font picker UI remains hardcoded green for visual continuity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### New Module: `engine/themes.py`
|
|
||||||
|
|
||||||
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient # white → primary color
|
|
||||||
self.message_gradient = message_gradient # white → complementary
|
|
||||||
```
|
|
||||||
|
|
||||||
**Theme Registry:**
|
|
||||||
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
|
||||||
|
|
||||||
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
|
||||||
```
|
|
||||||
[
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright color
|
|
||||||
"\033[38;5;40m", # color
|
|
||||||
"\033[38;5;34m", # medium color
|
|
||||||
"\033[38;5;28m", # dark color
|
|
||||||
"\033[38;5;22m", # deep color
|
|
||||||
"\033[2;38;5;22m", # dim deep color
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Finalized color codes:**
|
|
||||||
|
|
||||||
**Green (primary: 22, complementary: 89)** — unchanged from current
|
|
||||||
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
|
||||||
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
|
||||||
|
|
||||||
**Orange (primary: 208, complementary: 21)**
|
|
||||||
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
|
||||||
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
|
||||||
|
|
||||||
**Purple (primary: 129, complementary: 226)**
|
|
||||||
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
|
||||||
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
|
||||||
|
|
||||||
**Public API:**
|
|
||||||
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
|
||||||
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/config.py`
|
|
||||||
|
|
||||||
**New globals:**
|
|
||||||
```python
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
|
||||||
```
|
|
||||||
|
|
||||||
**New function:**
|
|
||||||
```python
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme. Defaults to 'green' if not specified."""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Called by `app.pick_color_theme()` with user selection
|
|
||||||
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
|
||||||
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
|
||||||
|
|
||||||
**Removal:**
|
|
||||||
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/render.py`
|
|
||||||
|
|
||||||
**Updated gradient access in existing functions:**
|
|
||||||
|
|
||||||
Current pattern (will be removed):
|
|
||||||
```python
|
|
||||||
GRAD_COLS = [...] # hardcoded green
|
|
||||||
MSG_GRAD_COLS = [...] # hardcoded magenta
|
|
||||||
```
|
|
||||||
|
|
||||||
New pattern — update `lr_gradient()` function:
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient())
|
|
||||||
# ... rest of function unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
**Define fallback:**
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (current colors)."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
|
||||||
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
|
||||||
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
|
||||||
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Message gradient handling:**
|
|
||||||
|
|
||||||
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
|
||||||
```python
|
|
||||||
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
# Use:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
or define a helper:
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/app.py`
|
|
||||||
|
|
||||||
**New function: `pick_color_theme()`**
|
|
||||||
|
|
||||||
Mirrors `pick_font_face()` pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use default
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker (similar to font picker)
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
|
||||||
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Placement in `main()`:**
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... signal handler setup ...
|
|
||||||
pick_color_theme() # NEW — before title/subtitle
|
|
||||||
pick_font_face()
|
|
||||||
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User starts: mainline.py
|
|
||||||
↓
|
|
||||||
main() called
|
|
||||||
↓
|
|
||||||
pick_color_theme()
|
|
||||||
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
|
||||||
→ If not TTY: silently call config.set_active_theme("green")
|
|
||||||
↓
|
|
||||||
pick_font_face() — renders in hardcoded green UI colors
|
|
||||||
↓
|
|
||||||
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
|
||||||
↓
|
|
||||||
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
|
||||||
↓
|
|
||||||
On exit: no persistence
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Initialization Guarantee
|
|
||||||
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
|
||||||
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
|
||||||
2. It's called before any rendering happens
|
|
||||||
3. Default fallback ensures non-TTY environments don't crash
|
|
||||||
|
|
||||||
### Module Independence
|
|
||||||
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
|
||||||
|
|
||||||
### Color Code Finalization
|
|
||||||
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
|
||||||
|
|
||||||
### Theme ID Naming
|
|
||||||
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
|
||||||
|
|
||||||
### Terminal Resize Handling
|
|
||||||
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
|
||||||
1. The picker completes quickly (< 5 seconds typical interaction)
|
|
||||||
2. Once a theme is selected, the menu closes and rendering begins
|
|
||||||
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
|
||||||
|
|
||||||
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
1. **Unit tests** (`tests/test_themes.py`):
|
|
||||||
- Verify Theme class construction
|
|
||||||
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
|
||||||
- Confirm gradient lists have correct length (12)
|
|
||||||
|
|
||||||
2. **Integration tests** (`tests/test_render.py`):
|
|
||||||
- Mock `config.ACTIVE_THEME` to each theme
|
|
||||||
- Verify `lr_gradient()` uses correct colors
|
|
||||||
- Verify fallback works when `ACTIVE_THEME` is None
|
|
||||||
|
|
||||||
3. **Existing tests:**
|
|
||||||
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
|
||||||
- Use pytest fixtures to set theme per test case
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
|
||||||
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
|
||||||
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
|
||||||
- `tests/test_themes.py` (new unit tests)
|
|
||||||
- `tests/test_render.py` (update mocking strategy)
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
1. ✓ Color picker displays 3 theme options at startup
|
|
||||||
2. ✓ Selection applies to all headline and message gradients
|
|
||||||
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
5. ✓ No persistence between runs
|
|
||||||
6. ✓ Non-TTY environments default to green without error
|
|
||||||
7. ✓ Architecture supports future random/animation modes
|
|
||||||
8. ✓ All gradient color codes finalized with no TBD values
|
|
||||||
@@ -5,7 +5,6 @@ PLUGIN_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_plugins():
|
def discover_plugins():
|
||||||
from engine.effects.registry import get_registry
|
from engine.effects.registry import get_registry
|
||||||
from engine.effects.types import EffectPlugin
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
imported = {}
|
imported = {}
|
||||||
@@ -23,8 +22,8 @@ def discover_plugins():
|
|||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
isinstance(attr, type)
|
isinstance(attr, type)
|
||||||
and issubclass(attr, EffectPlugin)
|
and hasattr(attr, "name")
|
||||||
and attr is not EffectPlugin
|
and hasattr(attr, "process")
|
||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import random
|
|||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
class FadeEffect(EffectPlugin):
|
class FadeEffect:
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect:
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
class GlitchEffect(EffectPlugin):
|
class GlitchEffect:
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect:
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
|||||||
215
engine/app.py
215
engine/app.py
@@ -10,11 +10,9 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render, themes
|
from engine import config, render
|
||||||
|
from engine.controller import StreamController
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
from engine.mic import MicMonitor
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
from engine.scroll import stream
|
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
CLR,
|
CLR,
|
||||||
CURSOR_OFF,
|
CURSOR_OFF,
|
||||||
@@ -65,30 +63,6 @@ def _read_picker_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
|
||||||
selected: Index of currently selected theme (0-2)
|
|
||||||
"""
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
|
||||||
)
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
def _normalize_preview_rows(rows):
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
non_empty = [r for r in rows if r.strip()]
|
non_empty = [r for r in rows if r.strip()]
|
||||||
@@ -155,50 +129,6 @@ def _draw_font_picker(faces, selected):
|
|||||||
print(f" {shown}")
|
print(f" {shown}")
|
||||||
|
|
||||||
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
|
||||||
|
|
||||||
Displays a menu of available themes and lets user select with arrow keys.
|
|
||||||
Non-interactive environments (piped stdin, CI) silently default to green.
|
|
||||||
"""
|
|
||||||
# Non-interactive fallback
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
def pick_font_face():
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
if not config.FONT_PICKER:
|
if not config.FONT_PICKER:
|
||||||
@@ -317,6 +247,110 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_effects_config():
|
||||||
|
"""Interactive picker for configuring effects pipeline."""
|
||||||
|
import effects_plugins
|
||||||
|
from engine.effects import get_effect_chain, get_registry
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = get_effect_chain()
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
|
||||||
|
effects = list(registry.list_all().values())
|
||||||
|
if not effects:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = 0
|
||||||
|
editing_intensity = False
|
||||||
|
intensity_value = 1.0
|
||||||
|
|
||||||
|
def _draw_effects_picker():
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print("\033[1;1H", end="")
|
||||||
|
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, effect in enumerate(effects):
|
||||||
|
prefix = " > " if i == selected else " "
|
||||||
|
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||||
|
if editing_intensity and i == selected:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print(
|
||||||
|
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_effects_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == " ":
|
||||||
|
return "toggle"
|
||||||
|
if ch == "q":
|
||||||
|
return "quit"
|
||||||
|
if ch == "+" or ch == "=":
|
||||||
|
return "up"
|
||||||
|
if ch == "-" or ch == "_":
|
||||||
|
return "down"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_effects_picker()
|
||||||
|
key = _read_effects_key()
|
||||||
|
|
||||||
|
if key == "quit" or key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "up" and editing_intensity:
|
||||||
|
intensity_value = min(1.0, intensity_value + 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "down" and editing_intensity:
|
||||||
|
intensity_value = max(0.0, intensity_value - 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(effects) - 1, selected + 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "toggle":
|
||||||
|
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
@@ -327,11 +361,13 @@ def main():
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
pick_color_theme()
|
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
|
pick_effects_config()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
@@ -341,10 +377,11 @@ def main():
|
|||||||
time.sleep(0.07)
|
time.sleep(0.07)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
_subtitle = {
|
_subtitle = (
|
||||||
"poetry": "literary consciousness stream",
|
"literary consciousness stream"
|
||||||
"code": "source consciousness stream",
|
if config.MODE == "poetry"
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
else "digital consciousness stream"
|
||||||
|
)
|
||||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
print()
|
print()
|
||||||
@@ -365,15 +402,6 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||||
save_cache(items)
|
save_cache(items)
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
else:
|
else:
|
||||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -391,9 +419,10 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
controller = StreamController()
|
||||||
mic_ok = mic.start()
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
if mic.available:
|
|
||||||
|
if controller.mic and controller.mic.available:
|
||||||
boot_ln(
|
boot_ln(
|
||||||
"Microphone",
|
"Microphone",
|
||||||
"ACTIVE"
|
"ACTIVE"
|
||||||
@@ -402,12 +431,6 @@ 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:
|
||||||
@@ -420,7 +443,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
stream(items, ntfy, mic)
|
controller.run(items)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
|||||||
638
engine/benchmark.py
Normal file
638
engine/benchmark.py
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
#!/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
|
||||||
|
) -> BenchmarkResult | None:
|
||||||
|
"""Benchmark a single display."""
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = StringIO()
|
||||||
|
sys.stderr = StringIO()
|
||||||
|
|
||||||
|
display = display_class()
|
||||||
|
display.init(80, 24)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> BenchmarkResult | None:
|
||||||
|
"""Benchmark an effect with a display."""
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = StringIO()
|
||||||
|
sys.stderr = StringIO()
|
||||||
|
|
||||||
|
effect = effect_class()
|
||||||
|
effect.configure(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
times = []
|
||||||
|
chars = sum(len(line) for line in buffer)
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
processed = effect.process(buffer)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
display.show(processed)
|
||||||
|
elapsed = (time.perf_counter() - t0) * 1000
|
||||||
|
times.append(elapsed)
|
||||||
|
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return displays
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_effects():
|
||||||
|
"""Get available effect classes."""
|
||||||
|
try:
|
||||||
|
from engine.effects import get_registry
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
effects = []
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
for name, effect in registry.list_all().items():
|
||||||
|
if effect:
|
||||||
|
effects.append((name, effect))
|
||||||
|
|
||||||
|
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)...")
|
||||||
|
|
||||||
|
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 verbose:
|
||||||
|
print()
|
||||||
|
|
||||||
|
for effect_name, effect_class in effects:
|
||||||
|
for display_name, display_class in displays:
|
||||||
|
if display_name == "websocket":
|
||||||
|
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")
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -105,6 +105,8 @@ class Config:
|
|||||||
firehose: bool = False
|
firehose: bool = False
|
||||||
|
|
||||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
ntfy_reconnect_delay: int = 5
|
ntfy_reconnect_delay: int = 5
|
||||||
message_display_secs: int = 30
|
message_display_secs: int = 30
|
||||||
|
|
||||||
@@ -127,6 +129,10 @@ 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)."""
|
||||||
@@ -148,6 +154,8 @@ class Config:
|
|||||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
firehose="--firehose" in argv,
|
firehose="--firehose" in argv,
|
||||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||||
|
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||||
ntfy_reconnect_delay=5,
|
ntfy_reconnect_delay=5,
|
||||||
message_display_secs=30,
|
message_display_secs=30,
|
||||||
font_dir=font_dir,
|
font_dir=font_dir,
|
||||||
@@ -164,6 +172,9 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,17 +199,13 @@ def set_config(config: Config) -> None:
|
|||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = (
|
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
||||||
"poetry"
|
|
||||||
if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code"
|
|
||||||
if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
FIREHOSE = "--firehose" in sys.argv
|
FIREHOSE = "--firehose" in sys.argv
|
||||||
|
|
||||||
# ─── 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
|
||||||
|
|
||||||
@@ -229,6 +236,11 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -237,26 +249,3 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: Theme identifier ("green", "orange", or "purple")
|
|
||||||
Defaults to "green"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is not in the theme registry
|
|
||||||
|
|
||||||
Side Effects:
|
|
||||||
Sets the ACTIVE_THEME global variable
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ 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,
|
||||||
|
MultiDisplay,
|
||||||
|
NullDisplay,
|
||||||
|
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
|
||||||
@@ -10,14 +19,76 @@ 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 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.
|
||||||
@@ -35,7 +106,38 @@ class StreamController:
|
|||||||
)
|
)
|
||||||
ntfy_ok = self.ntfy.start()
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok
|
self.ntfy_cc = NtfyPoller(
|
||||||
|
self.config.ntfy_cc_cmd_topic,
|
||||||
|
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||||
|
display_secs=5,
|
||||||
|
)
|
||||||
|
self.ntfy_cc.subscribe(self._handle_cc_message)
|
||||||
|
ntfy_cc_ok = self.ntfy_cc.start()
|
||||||
|
|
||||||
|
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
||||||
|
|
||||||
|
def _handle_cc_message(self, event) -> None:
|
||||||
|
"""Handle incoming C&C message - like a serial port control interface."""
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
||||||
|
if not cmd.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
response = handle_effects_command(cmd)
|
||||||
|
|
||||||
|
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
||||||
|
data = response.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=data,
|
||||||
|
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def run(self, items: list) -> None:
|
def run(self, items: list) -> None:
|
||||||
"""Run the stream with initialized sources."""
|
"""Run the stream with initialized sources."""
|
||||||
@@ -51,7 +153,10 @@ class StreamController:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
stream(items, self.ntfy, self.mic)
|
display = _get_display(self.config)
|
||||||
|
stream(items, self.ntfy, self.mic, display)
|
||||||
|
if display:
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
self.event_bus.publish(
|
self.event_bus.publish(
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
102
engine/display/__init__.py
Normal file
102
engine/display/__init__.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
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.multi import MultiDisplay
|
||||||
|
from engine.display.backends.null import NullDisplay
|
||||||
|
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."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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._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",
|
||||||
|
]
|
||||||
33
engine/display/backends/multi.py
Normal file
33
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Multi display backend - forwards to multiple displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MultiDisplay:
|
||||||
|
"""Display that forwards to multiple 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) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
for d in self.displays:
|
||||||
|
d.init(width, height)
|
||||||
|
|
||||||
|
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()
|
||||||
32
engine/display/backends/null.py
Normal file
32
engine/display/backends/null.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Null/headless display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
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
|
||||||
269
engine/display/backends/sixel.py
Normal file
269
engine/display/backends/sixel.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Sixel graphics display backend - renders to sixel graphics in terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ansi(
|
||||||
|
text: str,
|
||||||
|
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||||
|
"""Parse ANSI text into tokens with fg/bg colors.
|
||||||
|
|
||||||
|
Returns list of (text, fg_rgb, bg_rgb, bold).
|
||||||
|
"""
|
||||||
|
tokens = []
|
||||||
|
current_text = ""
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
n = int(c) if c else 0
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
elif n == 1:
|
||||||
|
bold = True
|
||||||
|
elif n == 22:
|
||||||
|
bold = False
|
||||||
|
elif n == 39:
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
elif n == 49:
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
elif 30 <= n <= 37:
|
||||||
|
fg = ANSI_COLORS.get(n - 30 + (8 if bold else 0), fg)
|
||||||
|
elif 40 <= n <= 47:
|
||||||
|
bg = ANSI_COLORS.get(n - 40, bg)
|
||||||
|
elif 90 <= n <= 97:
|
||||||
|
fg = ANSI_COLORS.get(n - 90 + 8, fg)
|
||||||
|
elif 100 <= n <= 107:
|
||||||
|
bg = ANSI_COLORS.get(n - 100 + 8, bg)
|
||||||
|
elif 1 <= n <= 256:
|
||||||
|
if n < 16:
|
||||||
|
fg = ANSI_COLORS.get(n, fg)
|
||||||
|
elif n < 232:
|
||||||
|
c = n - 16
|
||||||
|
r = (c // 36) * 51
|
||||||
|
g = ((c % 36) // 6) * 51
|
||||||
|
b = (c % 6) * 51
|
||||||
|
fg = (r, g, b)
|
||||||
|
else:
|
||||||
|
gray = (n - 232) * 10 + 8
|
||||||
|
fg = (gray, gray, gray)
|
||||||
|
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 _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
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||||
|
self.cell_height - 2,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
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
|
||||||
48
engine/display/backends/terminal.py
Normal file
48
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
ANSI terminal display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
266
engine/display/backends/websocket.py
Normal file
266
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
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) -> None:
|
||||||
|
"""Initialize display with dimensions and start server."""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
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,4 +1,3 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,11 +8,10 @@ class EffectContext:
|
|||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
ticker_height: int
|
ticker_height: int
|
||||||
camera_x: int = 0
|
mic_excess: float
|
||||||
mic_excess: float = 0.0
|
grad_offset: float
|
||||||
grad_offset: float = 0.0
|
frame_number: int
|
||||||
frame_number: int = 0
|
has_message: bool
|
||||||
has_message: bool = False
|
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,42 +22,15 @@ class EffectConfig:
|
|||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin(ABC):
|
class EffectPlugin:
|
||||||
name: str
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
|
||||||
@abstractmethod
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
def configure(self, config: EffectConfig) -> None:
|
||||||
def configure(self, config: EffectConfig) -> None: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def create_effect_context(
|
|
||||||
terminal_width: int = 80,
|
|
||||||
terminal_height: int = 24,
|
|
||||||
scroll_cam: int = 0,
|
|
||||||
ticker_height: int = 0,
|
|
||||||
camera_x: int = 0,
|
|
||||||
mic_excess: float = 0.0,
|
|
||||||
grad_offset: float = 0.0,
|
|
||||||
frame_number: int = 0,
|
|
||||||
has_message: bool = False,
|
|
||||||
items: list | None = None,
|
|
||||||
) -> EffectContext:
|
|
||||||
"""Factory function to create EffectContext with sensible defaults."""
|
|
||||||
return EffectContext(
|
|
||||||
terminal_width=terminal_width,
|
|
||||||
terminal_height=terminal_height,
|
|
||||||
scroll_cam=scroll_cam,
|
|
||||||
ticker_height=ticker_height,
|
|
||||||
camera_x=camera_x,
|
|
||||||
mic_excess=mic_excess,
|
|
||||||
grad_offset=grad_offset,
|
|
||||||
frame_number=frame_number,
|
|
||||||
has_message=has_message,
|
|
||||||
items=items or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
|
||||||
as scroll items. Used by --code mode.
|
|
||||||
Depends on: nothing (stdlib only).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_ENGINE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
|
|
||||||
def _scope_map(source: str) -> dict[int, str]:
|
|
||||||
"""Return {line_number: scope_label} for every line in source.
|
|
||||||
|
|
||||||
Nodes are sorted by range size descending so inner scopes overwrite
|
|
||||||
outer ones, guaranteeing the narrowest enclosing scope wins.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(source)
|
|
||||||
except SyntaxError:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
span = end - node.lineno
|
|
||||||
nodes.append((span, node))
|
|
||||||
|
|
||||||
# Largest range first → inner scopes overwrite on second pass
|
|
||||||
nodes.sort(key=lambda x: x[0], reverse=True)
|
|
||||||
|
|
||||||
scope = {}
|
|
||||||
for _, node in nodes:
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
if isinstance(node, ast.ClassDef):
|
|
||||||
label = node.name
|
|
||||||
else:
|
|
||||||
label = f"{node.name}()"
|
|
||||||
for ln in range(node.lineno, end + 1):
|
|
||||||
scope[ln] = label
|
|
||||||
|
|
||||||
return scope
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_code():
|
|
||||||
"""Read engine/*.py and return (items, line_count, 0).
|
|
||||||
|
|
||||||
Each item is (text, src, ts) where:
|
|
||||||
text = the code line (rstripped, indentation preserved)
|
|
||||||
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
|
||||||
ts = dotted module path, e.g. 'engine.scroll'
|
|
||||||
"""
|
|
||||||
items = []
|
|
||||||
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
|
||||||
module = f"engine.{path.stem}"
|
|
||||||
source = path.read_text(encoding="utf-8")
|
|
||||||
scope = _scope_map(source)
|
|
||||||
for lineno, raw in enumerate(source.splitlines(), start=1):
|
|
||||||
stripped = raw.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
label = scope.get(lineno, "<module>")
|
|
||||||
items.append((raw.rstrip(), label, module))
|
|
||||||
|
|
||||||
return items, len(items), 0
|
|
||||||
@@ -18,7 +18,7 @@ from engine.effects import (
|
|||||||
noise,
|
noise,
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||||
from engine.terminal import RST, W_COOL
|
from engine.terminal import RST, W_COOL
|
||||||
|
|
||||||
MSG_META = "\033[38;5;245m"
|
MSG_META = "\033[38;5;245m"
|
||||||
@@ -57,7 +57,7 @@ def render_message_overlay(
|
|||||||
else:
|
else:
|
||||||
msg_rows = msg_cache[1]
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
msg_rows = msg_gradient(
|
msg_rows = lr_gradient_opposite(
|
||||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
129
engine/render.py
129
engine/render.py
@@ -15,72 +15,38 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
|||||||
from engine.terminal import RST
|
from engine.terminal import RST
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
def _color_codes_to_ansi(color_codes):
|
# Left → right: white-hot leading edge fades to near-black
|
||||||
"""Convert a list of 256-color codes to ANSI escape code strings.
|
GRAD_COLS = [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
Args:
|
"\033[38;5;118m", # bright lime
|
||||||
color_codes: List of 12 integers (256-color palette codes)
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
Returns:
|
"\033[38;5;40m", # green
|
||||||
List of ANSI escape code strings
|
"\033[38;5;34m", # medium green
|
||||||
"""
|
"\033[38;5;28m", # dark green
|
||||||
if not color_codes or len(color_codes) != 12:
|
"\033[38;5;22m", # deep green
|
||||||
# Fallback to default green if invalid
|
"\033[2;38;5;22m", # dim deep green
|
||||||
return _default_green_gradient()
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
result = []
|
|
||||||
for i, code in enumerate(color_codes):
|
|
||||||
if i < 2:
|
|
||||||
# Bold for first 2 (bright leading edge)
|
|
||||||
result.append(f"\033[1;38;5;{code}m")
|
|
||||||
elif i < 10:
|
|
||||||
# Normal for middle 8
|
|
||||||
result.append(f"\033[38;5;{code}m")
|
|
||||||
else:
|
|
||||||
# Dim for last 2 (dark trailing edge)
|
|
||||||
result.append(f"\033[2;38;5;{code}m")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_magenta_gradient():
|
|
||||||
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||||
|
MSG_GRAD_COLS = [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
@@ -223,15 +189,9 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, cols=None):
|
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
if cols is None:
|
cols = grad_cols or GRAD_COLS
|
||||||
from engine import config
|
|
||||||
|
|
||||||
if config.ACTIVE_THEME:
|
|
||||||
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
|
||||||
else:
|
|
||||||
cols = _default_green_gradient()
|
|
||||||
n = len(cols)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -253,30 +213,7 @@ def lr_gradient(rows, offset=0.0, cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, _default_magenta_gradient())
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors.
|
|
||||||
|
|
||||||
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
|
||||||
falling back to default magenta if no theme is set.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text strings to colorize
|
|
||||||
offset: Gradient offset (0.0-1.0) for animation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of rows with ANSI color codes applied
|
|
||||||
"""
|
|
||||||
from engine import config
|
|
||||||
|
|
||||||
cols = (
|
|
||||||
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
Theme definitions with color gradients for terminal rendering.
|
|
||||||
|
|
||||||
This module is data-only and does not import config or render
|
|
||||||
to prevent circular dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Theme:
|
|
||||||
"""Represents a color theme with two gradients."""
|
|
||||||
|
|
||||||
def __init__(self, name, main_gradient, message_gradient):
|
|
||||||
"""Initialize a theme with name and color gradients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Theme identifier string
|
|
||||||
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
|
||||||
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient
|
|
||||||
self.message_gradient = message_gradient
|
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
|
||||||
# Each gradient is 12 ANSI 256-color codes in sequence
|
|
||||||
# Format: [light...] → [medium...] → [dark...] → [black]
|
|
||||||
|
|
||||||
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
|
||||||
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
|
||||||
|
|
||||||
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
|
||||||
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
|
||||||
|
|
||||||
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
|
||||||
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
THEME_REGISTRY = {
|
|
||||||
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
|
||||||
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
|
||||||
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme(theme_id):
|
|
||||||
"""Retrieve a theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: Theme identifier string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Theme object matching the ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is not in registry
|
|
||||||
"""
|
|
||||||
return THEME_REGISTRY[theme_id]
|
|
||||||
Binary file not shown.
Binary file not shown.
3
hk.pkl
3
hk.pkl
@@ -22,6 +22,9 @@ 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
mise.toml
61
mise.toml
@@ -5,45 +5,82 @@ pkl = "latest"
|
|||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# =====================
|
# =====================
|
||||||
# Development
|
# Testing
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-v = "uv run pytest -v"
|
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
||||||
|
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Linting & Formatting
|
||||||
|
# =====================
|
||||||
|
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "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
|
# Runtime Modes
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
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-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"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# 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 = "uv sync"
|
install = "mise run sync"
|
||||||
install-dev = "uv sync --group dev"
|
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
||||||
|
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
||||||
|
|
||||||
bootstrap = "uv sync && uv run mainline.py --help"
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
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 = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# Git Hooks (via hk)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
]
|
]
|
||||||
|
websocket = [
|
||||||
|
"websockets>=12.0",
|
||||||
|
]
|
||||||
|
sixel = [
|
||||||
|
"pysixel>=0.1.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",
|
||||||
|
|||||||
133
tests/e2e/test_web_client.py
Normal file
133
tests/e2e/test_web_client.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for web client with headless browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||||
|
"""Threaded HTTP server for handling concurrent requests."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def http_server():
|
||||||
|
"""Start a local HTTP server for the client."""
|
||||||
|
os.chdir(CLIENT_DIR)
|
||||||
|
|
||||||
|
handler = SimpleHTTPRequestHandler
|
||||||
|
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = server.server_address[1]
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClient:
|
||||||
|
"""Tests for the web client using Playwright."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_client_loads(self, http_server):
|
||||||
|
"""Web client loads without errors."""
|
||||||
|
response = self.page.goto(http_server)
|
||||||
|
assert response.status == 200, f"Page load failed with status {response.status}"
|
||||||
|
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
content = self.page.content()
|
||||||
|
assert "<canvas" in content, "Canvas element not found in page"
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_status_shows_connecting(self, http_server):
|
||||||
|
"""Status shows connecting initially."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
|
|
||||||
|
def test_canvas_has_dimensions(self, http_server):
|
||||||
|
"""Canvas has correct dimensions after load."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_no_console_errors_on_load(self, http_server):
|
||||||
|
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
||||||
|
js_errors = []
|
||||||
|
|
||||||
|
def handle_console(msg):
|
||||||
|
if msg.type == "error":
|
||||||
|
text = msg.text
|
||||||
|
if "WebSocket" not in text:
|
||||||
|
js_errors.append(text)
|
||||||
|
|
||||||
|
self.page.on("console", handle_console)
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClientProtocol:
|
||||||
|
"""Tests for WebSocket protocol handling in client."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_websocket_reconnection(self, http_server):
|
||||||
|
"""Client attempts reconnection on disconnect."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
55
tests/test_app.py
Normal file
55
tests/test_app.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.app module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.app import _normalize_preview_rows
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizePreviewRows:
|
||||||
|
"""Tests for _normalize_preview_rows function."""
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
"""Empty input returns empty list."""
|
||||||
|
result = _normalize_preview_rows([])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
def test_strips_left_padding(self):
|
||||||
|
"""Left padding is stripped."""
|
||||||
|
result = _normalize_preview_rows([" content", " more"])
|
||||||
|
assert all(not r.startswith(" ") for r in result)
|
||||||
|
|
||||||
|
def test_preserves_content(self):
|
||||||
|
"""Content is preserved."""
|
||||||
|
result = _normalize_preview_rows([" hello world "])
|
||||||
|
assert "hello world" in result[0]
|
||||||
|
|
||||||
|
def test_handles_all_empty_rows(self):
|
||||||
|
"""All empty rows returns single empty string."""
|
||||||
|
result = _normalize_preview_rows(["", " ", ""])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppConstants:
|
||||||
|
"""Tests for app module constants."""
|
||||||
|
|
||||||
|
def test_title_defined(self):
|
||||||
|
"""TITLE is defined."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert len(TITLE) > 0
|
||||||
|
|
||||||
|
def test_title_lines_are_strings(self):
|
||||||
|
"""TITLE contains string lines."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert all(isinstance(line, str) for line in TITLE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppImports:
|
||||||
|
"""Tests for app module imports."""
|
||||||
|
|
||||||
|
def test_app_imports_without_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine import app
|
||||||
|
|
||||||
|
assert app is not None
|
||||||
@@ -83,35 +83,3 @@ 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()
|
|
||||||
|
|||||||
234
tests/test_fetch.py
Normal file
234
tests/test_fetch.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.fetch module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.fetch import (
|
||||||
|
_fetch_gutenberg,
|
||||||
|
fetch_all,
|
||||||
|
fetch_feed,
|
||||||
|
fetch_poetry,
|
||||||
|
load_cache,
|
||||||
|
save_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchFeed:
|
||||||
|
"""Tests for fetch_feed function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_success(self, mock_urlopen):
|
||||||
|
"""Successful feed fetch returns parsed feed."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<rss>test</rss>"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns None."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchAll:
|
||||||
|
"""Tests for fetch_all function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
||||||
|
"""Successful fetch returns items."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||||
|
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.return_value = False
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||||
|
"""Feed error increments failed count."""
|
||||||
|
mock_fetch_feed.return_value = None
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_skips_filtered(
|
||||||
|
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
||||||
|
):
|
||||||
|
"""Filtered headlines are skipped."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Sports scores"},
|
||||||
|
{"title": "Valid headline"},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert any("Valid headline" in item[0] for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchGutenberg:
|
||||||
|
"""Tests for _fetch_gutenberg function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_success(self, mock_urlopen):
|
||||||
|
"""Successful gutenberg fetch returns items."""
|
||||||
|
text = """Project Gutenberg
|
||||||
|
|
||||||
|
*** START OF THE PROJECT GUTENBERG ***
|
||||||
|
This is a test poem with multiple lines
|
||||||
|
that should be parsed as a block.
|
||||||
|
|
||||||
|
Another stanza with more content here.
|
||||||
|
|
||||||
|
*** END OF THE PROJECT GUTENBERG ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns empty list."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
||||||
|
"""Blocks shorter than 20 chars are skipped."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
Short
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
||||||
|
"""All-caps lines are skipped as headers."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
THIS IS ALL CAPS HEADER
|
||||||
|
more content here
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchPoetry:
|
||||||
|
"""Tests for fetch_poetry function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
||||||
|
"""Successful poetry fetch returns items."""
|
||||||
|
mock_fetch.return_value = [
|
||||||
|
("Stanza 1 content here", "Test", ""),
|
||||||
|
("Stanza 2 content here", "Test", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
||||||
|
"""Failed fetch increments failed count."""
|
||||||
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCache:
|
||||||
|
"""Tests for cache functions."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_success(self, mock_path):
|
||||||
|
"""Successful cache load returns items."""
|
||||||
|
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.return_value = json.dumps(
|
||||||
|
{"items": [("title", "source", "time")]}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_missing_file(self, mock_path):
|
||||||
|
"""Missing cache file returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = False
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_invalid_json(self, mock_path):
|
||||||
|
"""Invalid JSON returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_save_cache_success(self, mock_path):
|
||||||
|
"""Save cache writes to file."""
|
||||||
|
mock_path.return_value.__truediv__ = MagicMock(
|
||||||
|
return_value=mock_path.return_value
|
||||||
|
)
|
||||||
|
|
||||||
|
save_cache([("title", "source", "time")])
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
|
|
||||||
|
|
||||||
def test_return_shape():
|
|
||||||
items, line_count, ignored = fetch_code()
|
|
||||||
assert isinstance(items, list)
|
|
||||||
assert line_count == len(items)
|
|
||||||
assert ignored == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_items_are_tuples():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
assert items, "expected at least one code line"
|
|
||||||
for item in items:
|
|
||||||
assert isinstance(item, tuple) and len(item) == 3
|
|
||||||
text, src, ts = item
|
|
||||||
assert isinstance(text, str)
|
|
||||||
assert isinstance(src, str)
|
|
||||||
assert isinstance(ts, str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_blank_and_comment_lines_excluded():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
for text, _, _ in items:
|
|
||||||
assert text.strip(), "blank line should have been filtered"
|
|
||||||
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_path_format():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
pattern = re.compile(r"^engine\.\w+$")
|
|
||||||
for _, _, ts in items:
|
|
||||||
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
|
||||||
127
tests/test_ntfy_integration.py
Normal file
127
tests/test_ntfy_integration.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for ntfy topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyTopics:
|
||||||
|
def test_cc_cmd_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C CMD topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_resp_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C RESP topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_RESP_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
||||||
|
|
||||||
|
def test_message_topic_exists_and_writable(self):
|
||||||
|
"""Verify message topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_cmd_topic_readable(self):
|
||||||
|
"""Verify we can read messages from C&C CMD topic."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
test_message = f"integration_test_{int(time.time())}"
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
poll_url,
|
||||||
|
headers={"User-Agent": "mainline-test/0.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
if body.strip():
|
||||||
|
data = json.loads(body.split("\n")[0])
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_topics_are_different(self):
|
||||||
|
"""Verify C&C CMD/RESP and message topics are different."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
||||||
|
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
||||||
|
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
||||||
|
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
||||||
@@ -2,300 +2,231 @@
|
|||||||
Tests for engine.render module.
|
Tests for engine.render module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from engine import config, render
|
from engine.render import (
|
||||||
|
GRAD_COLS,
|
||||||
|
MSG_GRAD_COLS,
|
||||||
|
clear_font_cache,
|
||||||
|
font_for_lang,
|
||||||
|
lr_gradient,
|
||||||
|
lr_gradient_opposite,
|
||||||
|
make_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultGradients:
|
class TestGradientConstants:
|
||||||
"""Tests for default gradient fallback functions."""
|
"""Tests for gradient color constants."""
|
||||||
|
|
||||||
def test_default_green_gradient_length(self):
|
def test_grad_cols_defined(self):
|
||||||
"""_default_green_gradient returns 12 colors."""
|
"""GRAD_COLS is defined with expected length."""
|
||||||
gradient = render._default_green_gradient()
|
assert len(GRAD_COLS) > 0
|
||||||
assert len(gradient) == 12
|
assert all(isinstance(c, str) for c in GRAD_COLS)
|
||||||
|
|
||||||
def test_default_green_gradient_is_list(self):
|
def test_msg_grad_cols_defined(self):
|
||||||
"""_default_green_gradient returns a list."""
|
"""MSG_GRAD_COLS is defined with expected length."""
|
||||||
gradient = render._default_green_gradient()
|
assert len(MSG_GRAD_COLS) > 0
|
||||||
assert isinstance(gradient, list)
|
assert all(isinstance(c, str) for c in MSG_GRAD_COLS)
|
||||||
|
|
||||||
def test_default_green_gradient_all_strings(self):
|
def test_grad_cols_start_with_white(self):
|
||||||
"""_default_green_gradient returns list of ANSI code strings."""
|
"""GRAD_COLS starts with white."""
|
||||||
gradient = render._default_green_gradient()
|
assert "231" in GRAD_COLS[0]
|
||||||
assert all(isinstance(code, str) for code in gradient)
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_length(self):
|
def test_msg_grad_cols_different_from_grad_cols(self):
|
||||||
"""_default_magenta_gradient returns 12 colors."""
|
"""MSG_GRAD_COLS is different from GRAD_COLS."""
|
||||||
gradient = render._default_magenta_gradient()
|
assert MSG_GRAD_COLS != GRAD_COLS
|
||||||
assert len(gradient) == 12
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_is_list(self):
|
|
||||||
"""_default_magenta_gradient returns a list."""
|
|
||||||
gradient = render._default_magenta_gradient()
|
|
||||||
assert isinstance(gradient, list)
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_all_strings(self):
|
|
||||||
"""_default_magenta_gradient returns list of ANSI code strings."""
|
|
||||||
gradient = render._default_magenta_gradient()
|
|
||||||
assert all(isinstance(code, str) for code in gradient)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLrGradientUsesActiveTheme:
|
class TestLrGradient:
|
||||||
"""Tests for lr_gradient using active theme."""
|
"""Tests for lr_gradient function."""
|
||||||
|
|
||||||
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
def test_empty_rows(self):
|
||||||
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
"""Empty input returns empty output."""
|
||||||
# Save original state
|
result = lr_gradient([], 0.0)
|
||||||
original_theme = config.ACTIVE_THEME
|
assert result == []
|
||||||
|
|
||||||
try:
|
def test_preserves_empty_rows(self):
|
||||||
# Set a theme
|
"""Empty rows are preserved."""
|
||||||
config.set_active_theme("green")
|
result = lr_gradient([""], 0.0)
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
# Create simple test data
|
def test_adds_gradient_to_content(self):
|
||||||
rows = ["text"]
|
"""Non-empty rows get gradient coloring."""
|
||||||
|
result = lr_gradient(["hello"], 0.0)
|
||||||
# Call without cols parameter (cols=None)
|
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Should not raise and should return colored output
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 1
|
|
||||||
# Should have ANSI codes (no plain "text")
|
|
||||||
assert result[0] != "text"
|
|
||||||
finally:
|
|
||||||
# Restore original state
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_fallback_when_no_theme(self):
|
|
||||||
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
|
||||||
# Save original state
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Clear the theme
|
|
||||||
config.ACTIVE_THEME = None
|
|
||||||
|
|
||||||
# Create simple test data
|
|
||||||
rows = ["text"]
|
|
||||||
|
|
||||||
# Call without cols parameter (should use fallback)
|
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Should not raise and should return colored output
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 1
|
|
||||||
# Should have ANSI codes (no plain "text")
|
|
||||||
assert result[0] != "text"
|
|
||||||
finally:
|
|
||||||
# Restore original state
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
|
||||||
"""lr_gradient with explicit cols parameter overrides theme."""
|
|
||||||
# Custom gradient
|
|
||||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
|
||||||
|
|
||||||
rows = ["xy"]
|
|
||||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
|
||||||
|
|
||||||
# Should use the provided cols
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
def test_lr_gradient_respects_cols_parameter_name(self):
|
def test_preserves_spaces(self):
|
||||||
"""lr_gradient accepts cols as keyword argument."""
|
"""Spaces are preserved without coloring."""
|
||||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
result = lr_gradient(["hello world"], 0.0)
|
||||||
|
assert " " in result[0]
|
||||||
|
|
||||||
rows = ["xy"]
|
def test_offset_wraps_around(self):
|
||||||
# Call with cols as keyword
|
"""Offset wraps around at 1.0."""
|
||||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
result1 = lr_gradient(["hello"], 0.0)
|
||||||
|
result2 = lr_gradient(["hello"], 1.0)
|
||||||
assert isinstance(result, list)
|
assert result1 != result2 or result1 == result2
|
||||||
|
|
||||||
|
|
||||||
class TestLrGradientBasicFunctionality:
|
class TestLrGradientOpposite:
|
||||||
"""Tests to ensure lr_gradient basic functionality still works."""
|
"""Tests for lr_gradient_opposite function."""
|
||||||
|
|
||||||
def test_lr_gradient_colors_non_space_chars(self):
|
def test_uses_msg_grad_cols(self):
|
||||||
"""lr_gradient colors non-space characters."""
|
"""Uses MSG_GRAD_COLS instead of GRAD_COLS."""
|
||||||
rows = ["hello"]
|
result = lr_gradient_opposite(["test"])
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
# Set a theme for the test
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
try:
|
|
||||||
config.set_active_theme("green")
|
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Result should have ANSI codes
|
class TestClearFontCache:
|
||||||
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
"""Tests for clear_font_cache function."""
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_preserves_spaces(self):
|
def test_clears_without_error(self):
|
||||||
"""lr_gradient preserves spaces in output."""
|
"""Function runs without error."""
|
||||||
rows = ["a b c"]
|
clear_font_cache()
|
||||||
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
try:
|
|
||||||
config.set_active_theme("green")
|
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Spaces should be preserved (not colored)
|
class TestFontForLang:
|
||||||
assert " " in result[0]
|
"""Tests for font_for_lang function."""
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_empty_rows(self):
|
@patch("engine.render.font")
|
||||||
"""lr_gradient handles empty rows correctly."""
|
def test_returns_default_for_none(self, mock_font):
|
||||||
rows = [""]
|
"""Returns default font when lang is None."""
|
||||||
|
result = font_for_lang(None)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
original_theme = config.ACTIVE_THEME
|
@patch("engine.render.font")
|
||||||
try:
|
def test_returns_default_for_unknown_lang(self, mock_font):
|
||||||
config.set_active_theme("green")
|
"""Returns default font for unknown language."""
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
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 == [""]
|
assert result == [""]
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_multiple_rows(self):
|
|
||||||
"""lr_gradient handles multiple rows."""
|
|
||||||
rows = ["row1", "row2", "row3"]
|
|
||||||
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
try:
|
|
||||||
config.set_active_theme("green")
|
|
||||||
result = render.lr_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
assert len(result) == 3
|
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
|
|
||||||
class TestMsgGradient:
|
class TestBigWrap:
|
||||||
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
"""Tests for big_wrap function."""
|
||||||
|
|
||||||
def test_msg_gradient_uses_active_theme(self):
|
def test_empty_string(self):
|
||||||
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
"""Empty string returns empty list."""
|
||||||
# Save original state
|
from engine.render import big_wrap
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
result = big_wrap("", 80)
|
||||||
# Set a theme
|
assert result == []
|
||||||
config.set_active_theme("green")
|
|
||||||
|
|
||||||
# Create simple test data
|
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||||
rows = ["MESSAGE"]
|
def test_single_word_fits(self):
|
||||||
|
"""Single short word returns rendered."""
|
||||||
|
from engine.render import big_wrap
|
||||||
|
|
||||||
# Call msg_gradient
|
with patch("engine.render.font") as mock_font:
|
||||||
result = render.msg_gradient(rows, offset=0.0)
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||||
# Should return colored output using theme's message_gradient
|
result = big_wrap("test", 80)
|
||||||
assert isinstance(result, list)
|
assert len(result) > 0
|
||||||
assert len(result) == 1
|
|
||||||
# Should have ANSI codes from the message gradient
|
|
||||||
assert result[0] != "MESSAGE"
|
|
||||||
assert "\033[" in result[0]
|
|
||||||
finally:
|
|
||||||
# Restore original state
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_fallback_when_no_theme(self):
|
|
||||||
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
|
||||||
# Save original state
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Clear the theme
|
|
||||||
config.ACTIVE_THEME = None
|
|
||||||
|
|
||||||
# Create simple test data
|
|
||||||
rows = ["MESSAGE"]
|
|
||||||
|
|
||||||
# Call msg_gradient
|
|
||||||
result = render.msg_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Should return colored output using default magenta
|
|
||||||
assert isinstance(result, list)
|
|
||||||
assert len(result) == 1
|
|
||||||
# Should have ANSI codes
|
|
||||||
assert result[0] != "MESSAGE"
|
|
||||||
assert "\033[" in result[0]
|
|
||||||
finally:
|
|
||||||
# Restore original state
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_returns_colored_rows(self):
|
|
||||||
"""msg_gradient returns properly colored rows with animation offset."""
|
|
||||||
# Save original state
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Set a theme
|
|
||||||
config.set_active_theme("orange")
|
|
||||||
|
|
||||||
rows = ["NTFY", "ALERT"]
|
|
||||||
|
|
||||||
# Call with offset
|
|
||||||
result = render.msg_gradient(rows, offset=0.5)
|
|
||||||
|
|
||||||
# Should return same number of rows
|
|
||||||
assert len(result) == 2
|
|
||||||
# Both should be colored
|
|
||||||
assert all("\033[" in r for r in result)
|
|
||||||
# Should not be the original text
|
|
||||||
assert result != rows
|
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_different_themes_produce_different_results(self):
|
|
||||||
"""msg_gradient produces different colors for different themes."""
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
rows = ["TEST"]
|
|
||||||
|
|
||||||
# Get result with green theme
|
|
||||||
config.set_active_theme("green")
|
|
||||||
result_green = render.msg_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Get result with orange theme
|
|
||||||
config.set_active_theme("orange")
|
|
||||||
result_orange = render.msg_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Results should be different (different message gradients)
|
|
||||||
assert result_green != result_orange
|
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_preserves_spacing(self):
|
|
||||||
"""msg_gradient preserves spaces in rows."""
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
config.set_active_theme("purple")
|
|
||||||
rows = ["M E S S A G E"]
|
|
||||||
|
|
||||||
result = render.msg_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Spaces should be preserved
|
|
||||||
assert " " in result[0]
|
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_empty_rows(self):
|
|
||||||
"""msg_gradient handles empty rows correctly."""
|
|
||||||
original_theme = config.ACTIVE_THEME
|
|
||||||
|
|
||||||
try:
|
|
||||||
config.set_active_theme("green")
|
|
||||||
rows = [""]
|
|
||||||
|
|
||||||
result = render.msg_gradient(rows, offset=0.0)
|
|
||||||
|
|
||||||
# Empty row should stay empty
|
|
||||||
assert result == [""]
|
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.themes module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine import themes
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeConstruction:
|
|
||||||
"""Tests for Theme class initialization."""
|
|
||||||
|
|
||||||
def test_theme_construction(self):
|
|
||||||
"""Theme stores name and gradients correctly."""
|
|
||||||
main_grad = ["color1", "color2", "color3"]
|
|
||||||
msg_grad = ["msg1", "msg2", "msg3"]
|
|
||||||
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
|
||||||
|
|
||||||
assert theme.name == "test_theme"
|
|
||||||
assert theme.main_gradient == main_grad
|
|
||||||
assert theme.message_gradient == msg_grad
|
|
||||||
|
|
||||||
|
|
||||||
class TestGradientLength:
|
|
||||||
"""Tests for gradient length validation."""
|
|
||||||
|
|
||||||
def test_gradient_length_green(self):
|
|
||||||
"""Green theme has exactly 12 colors in each gradient."""
|
|
||||||
green = themes.THEME_REGISTRY["green"]
|
|
||||||
assert len(green.main_gradient) == 12
|
|
||||||
assert len(green.message_gradient) == 12
|
|
||||||
|
|
||||||
def test_gradient_length_orange(self):
|
|
||||||
"""Orange theme has exactly 12 colors in each gradient."""
|
|
||||||
orange = themes.THEME_REGISTRY["orange"]
|
|
||||||
assert len(orange.main_gradient) == 12
|
|
||||||
assert len(orange.message_gradient) == 12
|
|
||||||
|
|
||||||
def test_gradient_length_purple(self):
|
|
||||||
"""Purple theme has exactly 12 colors in each gradient."""
|
|
||||||
purple = themes.THEME_REGISTRY["purple"]
|
|
||||||
assert len(purple.main_gradient) == 12
|
|
||||||
assert len(purple.message_gradient) == 12
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeRegistry:
|
|
||||||
"""Tests for THEME_REGISTRY dictionary."""
|
|
||||||
|
|
||||||
def test_theme_registry_has_three_themes(self):
|
|
||||||
"""Registry contains exactly three themes: green, orange, purple."""
|
|
||||||
assert len(themes.THEME_REGISTRY) == 3
|
|
||||||
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
|
||||||
|
|
||||||
def test_registry_values_are_themes(self):
|
|
||||||
"""All registry values are Theme instances."""
|
|
||||||
for theme_id, theme in themes.THEME_REGISTRY.items():
|
|
||||||
assert isinstance(theme, themes.Theme)
|
|
||||||
assert theme.name == theme_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTheme:
|
|
||||||
"""Tests for get_theme function."""
|
|
||||||
|
|
||||||
def test_get_theme_valid_green(self):
|
|
||||||
"""get_theme('green') returns correct green Theme."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
assert isinstance(green, themes.Theme)
|
|
||||||
assert green.name == "green"
|
|
||||||
|
|
||||||
def test_get_theme_valid_orange(self):
|
|
||||||
"""get_theme('orange') returns correct orange Theme."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
assert isinstance(orange, themes.Theme)
|
|
||||||
assert orange.name == "orange"
|
|
||||||
|
|
||||||
def test_get_theme_valid_purple(self):
|
|
||||||
"""get_theme('purple') returns correct purple Theme."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
assert isinstance(purple, themes.Theme)
|
|
||||||
assert purple.name == "purple"
|
|
||||||
|
|
||||||
def test_get_theme_invalid(self):
|
|
||||||
"""get_theme with invalid ID raises KeyError."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
themes.get_theme("invalid_theme")
|
|
||||||
|
|
||||||
def test_get_theme_invalid_none(self):
|
|
||||||
"""get_theme with None raises KeyError."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
themes.get_theme(None)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGreenTheme:
|
|
||||||
"""Tests for green theme specific values."""
|
|
||||||
|
|
||||||
def test_green_theme_unchanged(self):
|
|
||||||
"""Green theme maintains original color sequence."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
|
||||||
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
|
||||||
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
|
||||||
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
|
||||||
|
|
||||||
assert green.main_gradient == expected_main
|
|
||||||
assert green.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_green_theme_name(self):
|
|
||||||
"""Green theme has correct name."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
assert green.name == "green"
|
|
||||||
|
|
||||||
|
|
||||||
class TestOrangeTheme:
|
|
||||||
"""Tests for orange theme specific values."""
|
|
||||||
|
|
||||||
def test_orange_theme_unchanged(self):
|
|
||||||
"""Orange theme maintains original color sequence."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
|
||||||
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
|
||||||
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
|
||||||
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
|
||||||
|
|
||||||
assert orange.main_gradient == expected_main
|
|
||||||
assert orange.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_orange_theme_name(self):
|
|
||||||
"""Orange theme has correct name."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
assert orange.name == "orange"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPurpleTheme:
|
|
||||||
"""Tests for purple theme specific values."""
|
|
||||||
|
|
||||||
def test_purple_theme_unchanged(self):
|
|
||||||
"""Purple theme maintains original color sequence."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
|
||||||
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
|
||||||
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
|
||||||
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
|
||||||
|
|
||||||
assert purple.main_gradient == expected_main
|
|
||||||
assert purple.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_purple_theme_name(self):
|
|
||||||
"""Purple theme has correct name."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
assert purple.name == "purple"
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeDataOnly:
|
|
||||||
"""Tests to ensure themes module has no problematic imports."""
|
|
||||||
|
|
||||||
def test_themes_module_imports(self):
|
|
||||||
"""themes module should be data-only without config/render imports."""
|
|
||||||
import inspect
|
|
||||||
source = inspect.getsource(themes)
|
|
||||||
# Verify no imports of config or render (look for actual import statements)
|
|
||||||
lines = source.split('\n')
|
|
||||||
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
|
||||||
# Filter out empty and comment lines
|
|
||||||
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
|
||||||
# Should have no import lines
|
|
||||||
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
|
||||||
115
tests/test_translate.py
Normal file
115
tests/test_translate.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.translate module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.translate import (
|
||||||
|
_translate_cached,
|
||||||
|
detect_location_language,
|
||||||
|
translate_headline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_translate_cache():
|
||||||
|
"""Clear the LRU cache between tests."""
|
||||||
|
_translate_cached.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLocationLanguage:
|
||||||
|
"""Tests for detect_location_language function."""
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown_location(self):
|
||||||
|
"""Returns None when no location pattern matches."""
|
||||||
|
result = detect_location_language("Breaking news about technology")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_detects_berlin(self):
|
||||||
|
"""Detects Berlin location."""
|
||||||
|
result = detect_location_language("Berlin police arrest protesters")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_detects_paris(self):
|
||||||
|
"""Detects Paris location."""
|
||||||
|
result = detect_location_language("Paris fashion week begins")
|
||||||
|
assert result == "fr"
|
||||||
|
|
||||||
|
def test_detects_tokyo(self):
|
||||||
|
"""Detects Tokyo location."""
|
||||||
|
result = detect_location_language("Tokyo stocks rise")
|
||||||
|
assert result == "ja"
|
||||||
|
|
||||||
|
def test_detects_berlin_again(self):
|
||||||
|
"""Detects Berlin location again."""
|
||||||
|
result = detect_location_language("Berlin marathon set to begin")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Detection is case insensitive."""
|
||||||
|
result = detect_location_language("BERLIN SUMMER FESTIVAL")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_returns_first_match(self):
|
||||||
|
"""Returns first matching pattern."""
|
||||||
|
result = detect_location_language("Berlin in Paris for the event")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateHeadline:
|
||||||
|
"""Tests for translate_headline function."""
|
||||||
|
|
||||||
|
def test_returns_translated_text(self):
|
||||||
|
"""Returns translated text from cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.translate_headline") as mock_fn:
|
||||||
|
mock_fn.return_value = "Translated title"
|
||||||
|
from engine.translate import translate_headline as th
|
||||||
|
|
||||||
|
result = th("Original title", "de")
|
||||||
|
assert result == "Translated title"
|
||||||
|
|
||||||
|
def test_uses_cached_result(self):
|
||||||
|
"""Translation uses LRU cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
result1 = translate_headline("Test unique", "es")
|
||||||
|
result2 = translate_headline("Test unique", "es")
|
||||||
|
assert result1 == result2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateCached:
|
||||||
|
"""Tests for _translate_cached function."""
|
||||||
|
|
||||||
|
def test_translation_network_error(self):
|
||||||
|
"""Network error returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _translate_cached("Hello world", "de")
|
||||||
|
|
||||||
|
assert result == "Hello world"
|
||||||
|
|
||||||
|
def test_translation_invalid_json(self):
|
||||||
|
"""Invalid JSON returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"invalid json"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
|
|
||||||
|
def test_translation_empty_response(self):
|
||||||
|
"""Empty translation response returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps([[[""], None, "de"], None])
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
161
tests/test_websocket.py
Normal file
161
tests/test_websocket.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display.backends.websocket module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayImport:
|
||||||
|
"""Test that websocket module can be imported."""
|
||||||
|
|
||||||
|
def test_import_does_not_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine.display import backends
|
||||||
|
|
||||||
|
assert backends is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayInit:
|
||||||
|
"""Tests for WebSocketDisplay initialization."""
|
||||||
|
|
||||||
|
def test_default_init(self):
|
||||||
|
"""Default initialization sets correct defaults."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", None):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.host == "0.0.0.0"
|
||||||
|
assert display.port == 8765
|
||||||
|
assert display.http_port == 8766
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_custom_init(self):
|
||||||
|
"""Custom initialization uses provided values."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", None):
|
||||||
|
display = WebSocketDisplay(host="localhost", port=9000, http_port=9001)
|
||||||
|
assert display.host == "localhost"
|
||||||
|
assert display.port == 9000
|
||||||
|
assert display.http_port == 9001
|
||||||
|
|
||||||
|
def test_is_available_when_websockets_present(self):
|
||||||
|
"""is_available returns True when websockets is available."""
|
||||||
|
pytest.importorskip("websockets")
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is True
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_is_available_when_websockets_missing(self):
|
||||||
|
"""is_available returns False when websockets is not available."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayProtocol:
|
||||||
|
"""Test that WebSocketDisplay satisfies Display protocol."""
|
||||||
|
|
||||||
|
def test_websocket_display_is_display(self):
|
||||||
|
"""WebSocketDisplay satisfies Display protocol."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayMethods:
|
||||||
|
"""Tests for WebSocketDisplay methods."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.init(100, 40)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 40
|
||||||
|
|
||||||
|
def test_client_count_initially_zero(self):
|
||||||
|
"""client_count returns 0 when no clients connected."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.client_count() == 0
|
||||||
|
|
||||||
|
def test_get_ws_port(self):
|
||||||
|
"""get_ws_port returns configured port."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(port=9000)
|
||||||
|
assert display.get_ws_port() == 9000
|
||||||
|
|
||||||
|
def test_get_http_port(self):
|
||||||
|
"""get_http_port returns configured port."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(http_port=9001)
|
||||||
|
assert display.get_http_port() == 9001
|
||||||
|
|
||||||
|
def test_frame_delay_defaults_to_zero(self):
|
||||||
|
"""get_frame_delay returns 0 by default."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.get_frame_delay() == 0.0
|
||||||
|
|
||||||
|
def test_set_frame_delay(self):
|
||||||
|
"""set_frame_delay stores the value."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.set_frame_delay(0.05)
|
||||||
|
assert display.get_frame_delay() == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayCallbacks:
|
||||||
|
"""Tests for WebSocketDisplay callback methods."""
|
||||||
|
|
||||||
|
def test_set_client_connected_callback(self):
|
||||||
|
"""set_client_connected_callback stores callback."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_connected_callback(callback)
|
||||||
|
assert display._client_connected_callback is callback
|
||||||
|
|
||||||
|
def test_set_client_disconnected_callback(self):
|
||||||
|
"""set_client_disconnected_callback stores callback."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_disconnected_callback(callback)
|
||||||
|
assert display._client_disconnected_callback is callback
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayUnavailable:
|
||||||
|
"""Tests when WebSocket support is unavailable."""
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_server_noop_when_unavailable(self):
|
||||||
|
"""start_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_server()
|
||||||
|
assert display._server_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_http_server_noop_when_unavailable(self):
|
||||||
|
"""start_http_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_http_server()
|
||||||
|
assert display._http_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_show_noops_when_unavailable(self):
|
||||||
|
"""show does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.show(["line1", "line2"])
|
||||||
Reference in New Issue
Block a user