Compare commits
85 Commits
feat/mod_p
...
ac9b47f668
| Author | SHA1 | Date | |
|---|---|---|---|
| ac9b47f668 | |||
| b149825bcb | |||
| 1b29e91f9d | |||
| 001158214c | |||
| 31f5d9f171 | |||
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee | |||
| 8b696c96ce | |||
| 72d21459ca | |||
| 58dbbbdba7 | |||
| 7ff78c66ed | |||
| 2229ccdea4 | |||
| f13e89f823 | |||
| 4228400c43 | |||
| 05cc475858 | |||
| cfd7e8931e | |||
| 15de46722a | |||
| 35e5c8d38b | |||
| cdc8094de2 | |||
| f170143939 | |||
| 19fb4bc4fe | |||
| ae10fd78ca | |||
| 4afab642f7 | |||
| f6f177590b | |||
| 9ae4dc2b07 | |||
| 1ac2dec3b0 | |||
| 757c854584 | |||
| 4844a64203 | |||
| 9201117096 | |||
| d758541156 | |||
| b979621dd4 | |||
| f91cc9844e | |||
| bddbd69371 | |||
| 6e39a2dad2 | |||
| 1ba3848bed | |||
| a986df344a | |||
| c84bd5c05a | |||
| 7b0f886e53 | |||
| 9eeb817dca | |||
| ac80ab23cc | |||
| 516123345e | |||
| 11226872a1 | |||
| e6826c884c | |||
| 0740e34293 | |||
| 1e99d70387 | |||
| 7098b2f5aa | |||
| e7de09be50 | |||
| 9140bfd32b | |||
| c49c0aab33 | |||
| 66c13b5829 | |||
| 089c8ed66a | |||
| 086214f05e | |||
| 0f762475b5 | |||
| b00b612da0 | |||
| 39dab4b22b | |||
| 47f17e12ef | |||
| 851c4a77b4 | |||
| cdbb6dfd1c | |||
| 45a202e955 | |||
| 339510dd60 | |||
| 9bd8115c55 | |||
| 2c777729f5 | |||
| 0e500d1b71 | |||
| 3571e2780b | |||
| dfd902fb90 | |||
| 2e6b2c48bd | |||
| 1ff2e54586 | |||
| 424332e065 | |||
| f6ad89769f | |||
| d3c403848c | |||
| 119ed193c0 | |||
| dcc3718012 | |||
| 2e69cad984 | |||
| 7274f57bbb | |||
| c857d7bd81 | |||
| 6a5a73fd88 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,11 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.mainline_venv/
|
.mainline_venv/
|
||||||
|
.venv/
|
||||||
|
uv.lock
|
||||||
.mainline_cache_*.json
|
.mainline_cache_*.json
|
||||||
|
.DS_Store
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Agent Development Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||||
|
- **hk** (hk.jdx.dev) - git hook manager
|
||||||
|
- **uv** - fast Python package installer
|
||||||
|
- **ruff** - linter and formatter
|
||||||
|
- **pytest** - test runner
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
mise run install
|
||||||
|
|
||||||
|
# Or equivalently:
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run test # Run tests
|
||||||
|
mise run test-v # Run tests verbose
|
||||||
|
mise run test-cov # Run tests with coverage report
|
||||||
|
mise run lint # Run ruff linter
|
||||||
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
|
mise run format # Run ruff formatter
|
||||||
|
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Hooks
|
||||||
|
|
||||||
|
**At the start of every agent session**, verify hooks are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la .git/hooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
If hooks are not installed, install them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hk init --mise
|
||||||
|
mise run pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses hk configured in `hk.pkl`:
|
||||||
|
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||||
|
- **pre-push**: runs ruff check
|
||||||
|
|
||||||
|
## Workflow Rules
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
|
||||||
|
1. **Always run the test suite** - never commit code that fails tests:
|
||||||
|
```bash
|
||||||
|
mise run test
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Always run the linter**:
|
||||||
|
```bash
|
||||||
|
mise run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Fix any lint errors** before committing (or let the pre-commit hook handle it).
|
||||||
|
|
||||||
|
4. **Review your changes** using `git diff` to understand what will be committed.
|
||||||
|
|
||||||
|
### On Failing Tests
|
||||||
|
|
||||||
|
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
||||||
|
|
||||||
|
- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior.
|
||||||
|
|
||||||
|
- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test.
|
||||||
|
|
||||||
|
**Never** modify a test to make it pass without understanding why it failed.
|
||||||
|
|
||||||
|
### Code Review
|
||||||
|
|
||||||
|
Before committing significant changes:
|
||||||
|
- Run `git diff` to review all changes
|
||||||
|
- Ensure new code follows existing patterns in the codebase
|
||||||
|
- Check that type hints are added for new functions
|
||||||
|
- Verify that tests exist for new functionality
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||||
|
|
||||||
|
Run all tests:
|
||||||
|
```bash
|
||||||
|
mise run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with coverage:
|
||||||
|
```bash
|
||||||
|
mise run test-cov
|
||||||
|
```
|
||||||
|
|
||||||
|
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||||
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
@@ -3,29 +3,29 @@
|
|||||||
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
|
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
|
||||||
## Architecture: Server \+ Thin Client
|
## Architecture: Server \+ Thin Client
|
||||||
Split the system into two halves that are designed together\.
|
Split the system into two halves that are designed together\.
|
||||||
**Server \(mainline\.py `--serve` mode, runs on any always\-on machine\)**
|
**Server (mainline\.py `--serve` mode, runs on any always\-on machine)**
|
||||||
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
|
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
|
||||||
* Pre\-renders each headline into a 1\-bit bitmap strip \(the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`\)\.
|
* Pre\-renders each headline into a 1\-bit bitmap strip (the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`)\.
|
||||||
* Exposes a lightweight HTTP API the ESP32 polls\.
|
* Exposes a lightweight HTTP API the ESP32 polls\.
|
||||||
**ESP32 thin client \(Arduino sketch\)**
|
**ESP32 thin client (Arduino sketch)**
|
||||||
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP \(no TLS needed if on the same LAN\)\.
|
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP (no TLS needed if on the same LAN)\.
|
||||||
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic \(`DoorbellLogic.cpp:155-192`\)\.
|
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic (`DoorbellLogic.cpp:155-192`)\.
|
||||||
* Manages scrolling, gradient coloring, and glitch effects locally \(cheap per\-frame GPU work\)\.
|
* Manages scrolling, gradient coloring, and glitch effects locally (cheap per\-frame GPU work)\.
|
||||||
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
|
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
|
||||||
## Server API \(mainline repo\)
|
## Server API (mainline repo)
|
||||||
New file: `serve.py` \(or `--serve` mode in mainline\.py\)\.
|
New file: `serve.py` (or `--serve` mode in mainline\.py)\.
|
||||||
Endpoints:
|
Endpoints:
|
||||||
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
|
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
|
||||||
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
|
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
|
||||||
* `GET /api/health` — `{"ok": true, "last_fetch": "...", "headline_count": 120}`
|
* `GET /api/health` — `{"ok": true, "last_fetch": "...", "headline_count": 120}`
|
||||||
The server renders at a configurable target width \(e\.g\. 800px for Board 3, 320px for Boards 1/2\) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
|
The server renders at a configurable target width (e\.g\. 800px for Board 3, 320px for Boards 1/2) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
|
||||||
The server refreshes feeds on a timer \(reusing `_SCROLL_DUR` cadence or a longer interval\), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically \(e\.g\. every 60s\) and swaps in the new set\.
|
The server refreshes feeds on a timer (reusing `_SCROLL_DUR` cadence or a longer interval), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically (e\.g\. every 60s) and swaps in the new set\.
|
||||||
## Render pipeline \(server side\)
|
## Render pipeline (server side)
|
||||||
The existing `_render_line()` in mainline\.py already does:
|
The existing `_render_line()` in mainline\.py already does:
|
||||||
1. `ImageFont.truetype()` → `ImageDraw.text()` → grayscale `Image`
|
1. `ImageFont.truetype()` → `ImageDraw.text()` → grayscale `Image`
|
||||||
2. Resize to target height
|
2. Resize to target height
|
||||||
3. Threshold to 1\-bit \(the `thr = 80` step\)
|
3. Threshold to 1\-bit (the `thr = 80` step)
|
||||||
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 \(it's just an index lookup per pixel column\)\.
|
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 (it's just an index lookup per pixel column)\.
|
||||||
## ESP32 client
|
## ESP32 client
|
||||||
### State machine
|
### State machine
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
@@ -35,19 +35,19 @@ BOOT → SCROLL ⇄ MESSAGE
|
|||||||
* **BOOT** — WiFi connect, initial headline fetch from server\.
|
* **BOOT** — WiFi connect, initial headline fetch from server\.
|
||||||
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
|
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
|
||||||
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
|
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
|
||||||
* **OFF** — Backlight off after inactivity \(polling continues in background\)\.
|
* **OFF** — Backlight off after inactivity (polling continues in background)\.
|
||||||
### ntfy integration
|
### ntfy integration
|
||||||
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
|
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
|
||||||
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
|
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
|
||||||
2. Save current scroll position\.
|
2. Save current scroll position\.
|
||||||
3. Transition to MESSAGE state\.
|
3. Transition to MESSAGE state\.
|
||||||
4. Render message text using the display library's built\-in fonts \(messages are short, no custom font needed\)\.
|
4. Render message text using the display library's built\-in fonts (messages are short, no custom font needed)\.
|
||||||
5. After `MESSAGE_TIMEOUT_MS` \(e\.g\. 30s\) or touch, restore scroll position and resume\.
|
5. After `MESSAGE_TIMEOUT_MS` (e\.g\. 30s) or touch, restore scroll position and resume\.
|
||||||
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline \(the device just shows the last cached headlines\)\.
|
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline (the device just shows the last cached headlines)\.
|
||||||
### Headline storage
|
### Headline storage
|
||||||
* Board 3 \(8 MB PSRAM\): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
|
* Board 3 (8 MB PSRAM): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
|
||||||
* Boards 1/2 \(PSRAM TBD\): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer \(~13 KB\)\.
|
* Boards 1/2 (PSRAM TBD): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer (~13 KB)\.
|
||||||
### Gradient coloring \(local\)
|
### Gradient coloring (local)
|
||||||
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
|
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
const uint16_t GRADIENT[] = {
|
const uint16_t GRADIENT[] = {
|
||||||
@@ -68,8 +68,8 @@ mainline.py (existing, unchanged)
|
|||||||
serve.py (new — HTTP server, imports mainline rendering functions)
|
serve.py (new — HTTP server, imports mainline rendering functions)
|
||||||
klubhaus-doorbell-hardware.md (existing)
|
klubhaus-doorbell-hardware.md (existing)
|
||||||
```
|
```
|
||||||
`serve.py` imports the rendering functions from mainline\.py \(after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`\)\.
|
`serve.py` imports the rendering functions from mainline\.py (after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`)\.
|
||||||
### klubhaus\-doorbell repo \(or mainline repo under firmware/\)
|
### klubhaus\-doorbell repo (or mainline repo under firmware/)
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
boards/esp32-mainline/
|
boards/esp32-mainline/
|
||||||
├── esp32-mainline.ino Main sketch
|
├── esp32-mainline.ino Main sketch
|
||||||
@@ -79,31 +79,31 @@ boards/esp32-mainline/
|
|||||||
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
|
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
|
||||||
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
|
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
|
||||||
```
|
```
|
||||||
The display driver is reused from the target board \(e\.g\. `DisplayDriverGFX` for Board 3\)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
|
The display driver is reused from the target board (e\.g\. `DisplayDriverGFX` for Board 3)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
|
||||||
## Branch strategy recommendation
|
## Branch strategy recommendation
|
||||||
The work spans two repos and has clear dependency ordering\.
|
The work spans two repos and has clear dependency ordering\.
|
||||||
### Phase 1 — Finish current branch \(mainline repo\)
|
### Phase 1 — Finish current branch (mainline repo)
|
||||||
**Branch:** `feat/arduino` \(current\)
|
**Branch:** `feat/arduino` (current)
|
||||||
**Content:** Hardware spec doc\. Already done\.
|
**Content:** Hardware spec doc\. Already done\.
|
||||||
**Action:** Merge to main when ready\.
|
**Action:** Merge to main when ready\.
|
||||||
### Phase 2 — Server renderer \(mainline repo\)
|
### Phase 2 — Server renderer (mainline repo)
|
||||||
**Branch:** `feat/renderer` \(branch from main after Phase 1 merges\)
|
**Branch:** `feat/renderer` (branch from main after Phase 1 merges)
|
||||||
**Content:**
|
**Content:**
|
||||||
* Refactor mainline\.py rendering functions to be importable \(extract from `__main__` guard\)
|
* Refactor mainline\.py rendering functions to be importable (extract from `__main__` guard)
|
||||||
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
|
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
|
||||||
* Bitmap packing utility \(1\-bit row\-major\)
|
* Bitmap packing utility (1\-bit row\-major)
|
||||||
**Why a separate branch:** This changes mainline\.py's structure \(refactoring for imports\) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
|
**Why a separate branch:** This changes mainline\.py's structure (refactoring for imports) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
|
||||||
### Phase 3 — ESP32 client \(klubhaus\-doorbell repo, or mainline repo\)
|
### Phase 3 — ESP32 client (klubhaus\-doorbell repo, or mainline repo)
|
||||||
**Branch:** `feat/mainline-client` in whichever repo hosts it
|
**Branch:** `feat/mainline-client` in whichever repo hosts it
|
||||||
**Content:**
|
**Content:**
|
||||||
* `MainlineLogic` state machine
|
* `MainlineLogic` state machine
|
||||||
* `HeadlineStore` bitmap buffer
|
* `HeadlineStore` bitmap buffer
|
||||||
* `NtfyPoller` for `klubhaus_terminal_mainline`
|
* `NtfyPoller` for `klubhaus_terminal_mainline`
|
||||||
* Board\-specific sketch for the target board
|
* Board\-specific sketch for the target board
|
||||||
**Depends on:** Phase 2 \(needs a running server to test against\)
|
**Depends on:** Phase 2 (needs a running server to test against)
|
||||||
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
|
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
|
||||||
### Merge order
|
### Merge order
|
||||||
1. `feat/arduino` → main \(hardware spec\)
|
1. `feat/arduino` → main (hardware spec)
|
||||||
2. `feat/renderer` → main \(server\)
|
2. `feat/renderer` → main (server)
|
||||||
3. `feat/mainline-client` → main in whichever repo \(ESP32 client\)
|
3. `feat/mainline-client` → main in whichever repo (ESP32 client)
|
||||||
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.
|
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.
|
||||||
234
README.md
234
README.md
@@ -2,58 +2,249 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages.
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run
|
## Using
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 mainline.py # news stream
|
python3 mainline.py # news stream
|
||||||
python3 mainline.py --poetry # literary consciousness mode
|
python3 mainline.py --poetry # literary consciousness mode
|
||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
|
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||||
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
|
python3 mainline.py --font-index 1 # select face index within a collection
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately.
|
Or with uv:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
uv run mainline.py
|
||||||
|
```
|
||||||
|
|
||||||
## Config
|
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.
|
||||||
|
|
||||||
At the top of `mainline.py`:
|
### Config
|
||||||
|
|
||||||
|
All constants live in `engine/config.py`:
|
||||||
|
|
||||||
| Constant | Default | What it does |
|
| Constant | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
| `_FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `_FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
| `_RENDER_H` | `8` | Terminal rows per headline line |
|
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||||
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||||
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
|
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||||
|
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||||
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||||
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
|
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||||
|
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||||
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
|
|
||||||
**Font:** `_FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well.
|
### Feeds
|
||||||
|
|
||||||
|
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
||||||
|
|
||||||
|
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
||||||
|
|
||||||
|
### Fonts
|
||||||
|
|
||||||
|
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
|
|
||||||
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
|
|
||||||
|
To push a message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## Internals
|
||||||
|
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped)
|
### How it works
|
||||||
- Headlines are rasterized via Pillow into half-block characters (`▀▄█ `) at the configured font size
|
|
||||||
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
|
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||||
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||||
|
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||||
|
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/
|
||||||
|
__init__.py package marker
|
||||||
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
|
config.py constants, CLI flags, glyph tables
|
||||||
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
|
filter.py HTML stripping, content filter
|
||||||
|
translate.py Google Translate wrapper + region detection
|
||||||
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
|
effects.py noise, glitch_bar, fade, firehose
|
||||||
|
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||||
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
|
scroll.py stream() frame loop + message rendering
|
||||||
|
viewport.py terminal dimension tracking (tw/th)
|
||||||
|
frame.py scroll step calculation, timing
|
||||||
|
layers.py ticker zone, firehose, message overlay rendering
|
||||||
|
eventbus.py thread-safe event publishing for decoupled communication
|
||||||
|
events.py event types and definitions
|
||||||
|
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||||
|
emitters.py background emitters for ntfy and mic
|
||||||
|
types.py type definitions and dataclasses
|
||||||
|
```
|
||||||
|
|
||||||
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feeds
|
## Extending
|
||||||
|
|
||||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap in `FEEDS`.
|
`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.
|
||||||
|
|
||||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson.
|
### 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ideas / Future
|
## Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # minimal (no mic)
|
||||||
|
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||||
|
uv sync --all-extras --group dev # full dev environment
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
With [mise](https://mise.jdx.dev/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run test # run test suite
|
||||||
|
mise run test-cov # run with coverage report
|
||||||
|
mise run lint # ruff check
|
||||||
|
mise run lint-fix # ruff check --fix
|
||||||
|
mise run format # ruff format
|
||||||
|
mise run run # uv run mainline.py
|
||||||
|
mise run run-poetry # uv run mainline.py --poetry
|
||||||
|
mise run run-firehose # uv run mainline.py --firehose
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check engine/ mainline.py
|
||||||
|
uv run ruff format engine/ mainline.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-commit hooks run lint automatically via `hk`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||||
@@ -62,7 +253,6 @@ At the top of `mainline.py`:
|
|||||||
|
|
||||||
### Graphics
|
### Graphics
|
||||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||||
- **Animated gradient** — shift the white-hot leading edge left/right each frame for a pulse/comet effect
|
|
||||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||||
@@ -75,6 +265,10 @@ At the top of `mainline.py`:
|
|||||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
||||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
- **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 (system font paths hardcoded). Python 3.9+.*
|
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
||||||
|
|||||||
178
Refactor mainline.md
Normal file
178
Refactor mainline.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Refactor mainline\.py into modular package
|
||||||
|
## Problem
|
||||||
|
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||||
|
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||||
|
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||||
|
* Testing any concern in isolation
|
||||||
|
* Porting individual layers to Rust independently
|
||||||
|
## Target structure
|
||||||
|
```warp-runnable-command
|
||||||
|
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||||
|
engine/
|
||||||
|
__init__.py
|
||||||
|
config.py # constants, CLI flags, glyph tables
|
||||||
|
sources.py # FEEDS, POETRY_SOURCES, SOURCE_LANGS, _LOCATION_LANGS
|
||||||
|
terminal.py # ANSI codes, tw/th, type_out, slow_print, boot_ln
|
||||||
|
filter.py # HTML stripping, content filter (_SKIP_RE)
|
||||||
|
translate.py # Google Translate wrapper + location→language detection
|
||||||
|
render.py # OTF font loading, _render_line, _big_wrap, _lr_gradient, _make_block
|
||||||
|
effects.py # noise, glitch_bar, _fade_line, _vis_trunc, _firehose_line, _next_headline
|
||||||
|
fetch.py # RSS/Gutenberg fetching, cache load/save
|
||||||
|
ntfy.py # NtfyPoller class — standalone, zero internal deps
|
||||||
|
mic.py # MicMonitor class — standalone
|
||||||
|
scroll.py # stream() frame loop + message rendering
|
||||||
|
app.py # main(), TITLE art, boot sequence, signal handler
|
||||||
|
```
|
||||||
|
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||||
|
## Module dependency graph
|
||||||
|
```warp-runnable-command
|
||||||
|
config ← (nothing)
|
||||||
|
sources ← (nothing)
|
||||||
|
terminal ← (nothing)
|
||||||
|
filter ← (nothing)
|
||||||
|
translate ← sources
|
||||||
|
render ← config, terminal, sources
|
||||||
|
effects ← config, terminal, sources
|
||||||
|
fetch ← config, sources, filter, terminal
|
||||||
|
ntfy ← (nothing — stdlib only, fully standalone)
|
||||||
|
mic ← (nothing — sounddevice only)
|
||||||
|
scroll ← config, terminal, render, effects, ntfy, mic
|
||||||
|
app ← everything above
|
||||||
|
```
|
||||||
|
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||||
|
## Module details
|
||||||
|
### mainline\.py \(entrypoint — slimmed down\)
|
||||||
|
Keeps only the venv bootstrap \(lines 10\-38\) which must run before any third\-party imports\. After bootstrap, delegates to `engine.app.main()`\.
|
||||||
|
### engine/config\.py
|
||||||
|
From current mainline\.py:
|
||||||
|
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||||
|
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||||
|
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||||
|
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||||
|
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||||
|
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||||
|
### engine/sources\.py
|
||||||
|
Pure data, no logic:
|
||||||
|
* `FEEDS` dict \(lines 102\-140\)
|
||||||
|
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||||
|
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||||
|
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||||
|
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||||
|
* `_NO_UPPER` set \(line 167\)
|
||||||
|
### engine/terminal\.py
|
||||||
|
ANSI primitives and terminal I/O:
|
||||||
|
* All ANSI constants: `RST`, `BOLD`, `DIM`, `G_HI`, `G_MID`, `G_LO`, `G_DIM`, `W_COOL`, `W_DIM`, `W_GHOST`, `C_DIM`, `CLR`, `CURSOR_OFF`, `CURSOR_ON` \(lines 83\-99\)
|
||||||
|
* `tw()`, `th()` \(lines 223\-234\)
|
||||||
|
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||||
|
### engine/filter\.py
|
||||||
|
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||||
|
* `strip_tags()` \(lines 217\-220\)
|
||||||
|
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||||
|
* `_skip()` predicate \(lines 349\-351\)
|
||||||
|
### engine/translate\.py
|
||||||
|
* `_TRANSLATE_CACHE` \(line 291\)
|
||||||
|
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||||
|
* `_translate_headline()` \(lines 303\-319\)
|
||||||
|
### engine/render\.py
|
||||||
|
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
||||||
|
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||||
|
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||||
|
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||||
|
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||||
|
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
||||||
|
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||||
|
### engine/effects\.py
|
||||||
|
Visual effects applied during the frame loop:
|
||||||
|
* `noise()` \(lines 237\-245\)
|
||||||
|
* `glitch_bar()` \(lines 248\-252\)
|
||||||
|
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||||
|
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||||
|
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||||
|
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||||
|
### engine/fetch\.py
|
||||||
|
* `fetch_feed()` \(lines 390\-396\)
|
||||||
|
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||||
|
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||||
|
* `fetch_poetry()` \(lines 459\-472\)
|
||||||
|
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||||
|
### engine/ntfy\.py — standalone, reusable
|
||||||
|
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||||
|
```python
|
||||||
|
class NtfyPoller:
|
||||||
|
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||||
|
...
|
||||||
|
def start(self):
|
||||||
|
"""Start background polling thread."""
|
||||||
|
def get_active_message(self):
|
||||||
|
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
|
||||||
|
def dismiss(self):
|
||||||
|
"""Manually dismiss current message."""
|
||||||
|
```
|
||||||
|
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||||
|
Other visualizers use it like:
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
|
poller.start()
|
||||||
|
# in render loop:
|
||||||
|
msg = poller.get_active_message()
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
### engine/mic\.py — standalone
|
||||||
|
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||||
|
```python
|
||||||
|
class MicMonitor:
|
||||||
|
def __init__(self, threshold_db=50):
|
||||||
|
...
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start background mic stream. Returns False if unavailable."""
|
||||||
|
def stop(self):
|
||||||
|
...
|
||||||
|
@property
|
||||||
|
def db(self) -> float:
|
||||||
|
"""Current RMS dB level."""
|
||||||
|
@property
|
||||||
|
def excess(self) -> float:
|
||||||
|
"""dB above threshold (clamped to 0)."""
|
||||||
|
```
|
||||||
|
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||||
|
### engine/scroll\.py
|
||||||
|
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||||
|
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
||||||
|
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||||
|
### engine/app\.py
|
||||||
|
The orchestrator:
|
||||||
|
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||||
|
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||||
|
## Execution order
|
||||||
|
### Step 1: Create engine/ package skeleton
|
||||||
|
Create `engine/__init__.py` and all empty module files\.
|
||||||
|
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||||
|
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||||
|
### Step 3: Extract terminal\.py
|
||||||
|
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||||
|
### Step 4: Extract filter\.py and translate\.py
|
||||||
|
Both are small, self\-contained\. translate imports from sources\.
|
||||||
|
### Step 5: Extract render\.py
|
||||||
|
Font loading \+ the OTF→half\-block pipeline\. Imports from config, terminal, sources\. This is the module `serve.py` will later import\.
|
||||||
|
### Step 6: Extract effects\.py
|
||||||
|
Visual effects\. Imports from config, terminal, sources\.
|
||||||
|
### Step 7: Extract fetch\.py
|
||||||
|
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||||
|
### Step 8: Extract ntfy\.py and mic\.py
|
||||||
|
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||||
|
### Step 9: Extract scroll\.py
|
||||||
|
The frame loop\. Last to extract because it depends on everything above\.
|
||||||
|
### Step 10: Extract app\.py
|
||||||
|
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||||
|
### Step 11: Slim down mainline\.py
|
||||||
|
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||||
|
### Step 12: Verify
|
||||||
|
Run `python3 mainline.py`, `python3 mainline.py --poetry`, and `python3 mainline.py --firehose` to confirm identical behavior\. No behavioral changes in this refactor\.
|
||||||
|
## What this enables
|
||||||
|
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
||||||
|
* **Other visualizers**: `from engine.ntfy import NtfyPoller` — doorbell feature with no coupling to mainline's scroll engine
|
||||||
|
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||||
|
* **Testing**: Each module can be unit\-tested in isolation
|
||||||
250
cmdline.py
Normal file
250
cmdline.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python cmdline.py # Interactive TUI mode
|
||||||
|
python cmdline.py --help # Show help
|
||||||
|
python cmdline.py /effects list # Send single command via ntfy
|
||||||
|
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||||
|
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||||
|
|
||||||
|
The TUI mode provides:
|
||||||
|
- Arrow keys to navigate command history
|
||||||
|
- Tab completion for commands
|
||||||
|
- Auto-refresh for performance stats
|
||||||
|
|
||||||
|
C&C works like a serial port:
|
||||||
|
1. Send command to ntfy_cc_topic
|
||||||
|
2. Mainline receives, processes, responds to same topic
|
||||||
|
3. Cmdline polls for response
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
||||||
|
|
||||||
|
try:
|
||||||
|
CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
|
||||||
|
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
|
||||||
|
except AttributeError:
|
||||||
|
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyResponsePoller:
|
||||||
|
"""Polls ntfy for command responses."""
|
||||||
|
|
||||||
|
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||||
|
self.cmd_topic = cmd_topic
|
||||||
|
self.resp_topic = resp_topic
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_id = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _build_url(self) -> str:
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
parsed = urlparse(self.resp_topic)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||||
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def send_and_wait(self, cmd: str) -> str:
|
||||||
|
"""Send command and wait for response."""
|
||||||
|
url = self.cmd_topic.replace("/json", "")
|
||||||
|
data = cmd.encode("utf-8")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-cmdline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error sending command: {e}"
|
||||||
|
|
||||||
|
return self._wait_for_response(cmd)
|
||||||
|
|
||||||
|
def _wait_for_response(self, expected_cmd: str = "") -> str:
|
||||||
|
"""Poll for response message."""
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < self.timeout:
|
||||||
|
try:
|
||||||
|
url = self._build_url()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
for line in resp:
|
||||||
|
try:
|
||||||
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
self._last_id = data.get("id")
|
||||||
|
msg = data.get("message", "")
|
||||||
|
if msg:
|
||||||
|
return msg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
return "Timeout waiting for response"
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_COMMANDS = """Available commands:
|
||||||
|
/effects list - List all effects and status
|
||||||
|
/effects <name> on - Enable an effect
|
||||||
|
/effects <name> off - Disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
||||||
|
/effects stats - Show performance statistics
|
||||||
|
/help - Show this help
|
||||||
|
/quit - Exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def print_header():
|
||||||
|
w = 60
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print(f"\033[1;1H", end="")
|
||||||
|
print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
|
||||||
|
)
|
||||||
|
print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_response(response: str, is_error: bool = False) -> None:
|
||||||
|
"""Print response with nice formatting."""
|
||||||
|
print()
|
||||||
|
if is_error:
|
||||||
|
print(f" \033[1;38;5;196m✗ Error\033[0m")
|
||||||
|
print(f" \033[38;5;196m{'─' * 40}\033[0m")
|
||||||
|
else:
|
||||||
|
print(f" \033[1;38;5;82m✓ Response\033[0m")
|
||||||
|
print(f" \033[38;5;37m{'─' * 40}\033[0m")
|
||||||
|
|
||||||
|
for line in response.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_mode():
|
||||||
|
"""Interactive TUI for sending commands."""
|
||||||
|
import readline
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
break
|
||||||
|
|
||||||
|
if not cmd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.startswith("/"):
|
||||||
|
if cmd == "/quit" or cmd == "/exit":
|
||||||
|
print(f"\n \033[1;38;5;245mGoodbye!{RST}\n")
|
||||||
|
break
|
||||||
|
|
||||||
|
if cmd == "/help":
|
||||||
|
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" \033[38;5;245m⟳ Sending to mainline...{RST}")
|
||||||
|
result = poller.send_and_wait(cmd)
|
||||||
|
print_response(result, is_error=result.startswith("Error"))
|
||||||
|
else:
|
||||||
|
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Mainline command-line interface",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=AVAILABLE_COMMANDS,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
nargs="?",
|
||||||
|
default=None,
|
||||||
|
help="Command to send (e.g., /effects list)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--watch",
|
||||||
|
"-w",
|
||||||
|
action="store_true",
|
||||||
|
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command is None:
|
||||||
|
return interactive_mode()
|
||||||
|
|
||||||
|
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||||
|
|
||||||
|
if args.watch and "/effects stats" in args.command:
|
||||||
|
import signal
|
||||||
|
|
||||||
|
def handle_sigterm(*_):
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
print(CURSOR_ON, end="")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||||
|
|
||||||
|
print_header()
|
||||||
|
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(f"\033[2J\033[1;1H", end="")
|
||||||
|
print(
|
||||||
|
f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}"
|
||||||
|
)
|
||||||
|
print(f" \033[38;5;37m{'─' * 44}{RST}")
|
||||||
|
for line in result.split("\n"):
|
||||||
|
print(f" {line}")
|
||||||
|
time.sleep(2)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
result = poller.send_and_wait(args.command)
|
||||||
|
print(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# Color Scheme Switcher Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||||
|
|
||||||
|
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Purpose | Change Type |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||||
|
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||||
|
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||||
|
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||||
|
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||||
|
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Theme Data Module
|
||||||
|
|
||||||
|
### Task 1: Create themes.py with Theme class and registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `engine/themes.py`
|
||||||
|
- Test: `tests/test_themes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for Theme class**
|
||||||
|
|
||||||
|
Create `tests/test_themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Test color themes and registry."""
|
||||||
|
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_construction():
|
||||||
|
"""Theme stores name and gradient lists."""
|
||||||
|
main = ["\033[1;38;5;231m"] * 12
|
||||||
|
msg = ["\033[1;38;5;225m"] * 12
|
||||||
|
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||||
|
|
||||||
|
assert theme.name == "Test Green"
|
||||||
|
assert theme.main_gradient == main
|
||||||
|
assert theme.message_gradient == msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradient_length():
|
||||||
|
"""Each gradient must have exactly 12 ANSI codes."""
|
||||||
|
for theme_id, theme in THEME_REGISTRY.items():
|
||||||
|
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||||
|
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes():
|
||||||
|
"""Registry contains green, orange, purple."""
|
||||||
|
assert len(THEME_REGISTRY) == 3
|
||||||
|
assert "green" in THEME_REGISTRY
|
||||||
|
assert "orange" in THEME_REGISTRY
|
||||||
|
assert "purple" in THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_valid():
|
||||||
|
"""get_theme returns Theme object for valid ID."""
|
||||||
|
theme = get_theme("green")
|
||||||
|
assert isinstance(theme, Theme)
|
||||||
|
assert theme.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_invalid():
|
||||||
|
"""get_theme raises KeyError for invalid ID."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
get_theme("invalid_theme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_green_theme_unchanged():
|
||||||
|
"""Green theme uses original green → magenta colors."""
|
||||||
|
green_theme = get_theme("green")
|
||||||
|
# First color should be white (bold)
|
||||||
|
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||||
|
# Last deep green
|
||||||
|
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||||
|
# Message gradient is magenta
|
||||||
|
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: FAIL (module doesn't exist)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||||
|
|
||||||
|
Create `engine/themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Color theme definitions and registry."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
"""Initialize theme with display name and gradient lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Display name (e.g., "Verdant Green")
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||||
|
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||||
|
# Each gradient: white → primary/complementary, 12 steps total
|
||||||
|
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||||
|
|
||||||
|
_GREEN_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_GREEN_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;215m", # pale orange-white
|
||||||
|
"\033[38;5;209m", # bright orange
|
||||||
|
"\033[38;5;208m", # vibrant orange
|
||||||
|
"\033[38;5;202m", # orange
|
||||||
|
"\033[38;5;166m", # dark orange
|
||||||
|
"\033[38;5;130m", # burnt orange
|
||||||
|
"\033[38;5;94m", # rust
|
||||||
|
"\033[38;5;58m", # dark rust
|
||||||
|
"\033[38;5;94m", # rust (hold)
|
||||||
|
"\033[2;38;5;94m", # dim rust
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;33m", # bright blue
|
||||||
|
"\033[38;5;27m", # blue
|
||||||
|
"\033[38;5;21m", # deep blue
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;18m", # navy
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[2;38;5;18m", # dim navy
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale purple-white
|
||||||
|
"\033[38;5;177m", # bright purple
|
||||||
|
"\033[38;5;171m", # vibrant purple
|
||||||
|
"\033[38;5;165m", # purple
|
||||||
|
"\033[38;5;135m", # medium purple
|
||||||
|
"\033[38;5;129m", # purple
|
||||||
|
"\033[38;5;93m", # dark purple
|
||||||
|
"\033[38;5;57m", # deep purple
|
||||||
|
"\033[38;5;57m", # deep purple (hold)
|
||||||
|
"\033[2;38;5;57m", # dim deep purple
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;226m", # pale yellow-white
|
||||||
|
"\033[38;5;226m", # bright yellow
|
||||||
|
"\033[38;5;220m", # yellow
|
||||||
|
"\033[38;5;220m", # yellow (hold)
|
||||||
|
"\033[38;5;184m", # dark yellow
|
||||||
|
"\033[38;5;184m", # dark yellow (hold)
|
||||||
|
"\033[38;5;178m", # olive-yellow
|
||||||
|
"\033[38;5;178m", # olive-yellow (hold)
|
||||||
|
"\033[38;5;172m", # golden
|
||||||
|
"\033[2;38;5;172m", # dim golden
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme(
|
||||||
|
name="Verdant Green",
|
||||||
|
main_gradient=_GREEN_MAIN,
|
||||||
|
message_gradient=_GREEN_MESSAGE,
|
||||||
|
),
|
||||||
|
"orange": Theme(
|
||||||
|
name="Molten Orange",
|
||||||
|
main_gradient=_ORANGE_MAIN,
|
||||||
|
message_gradient=_ORANGE_MESSAGE,
|
||||||
|
),
|
||||||
|
"purple": Theme(
|
||||||
|
name="Violet Purple",
|
||||||
|
main_gradient=_PURPLE_MAIN,
|
||||||
|
message_gradient=_PURPLE_MESSAGE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id: str) -> Theme:
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id not found in registry
|
||||||
|
"""
|
||||||
|
if theme_id not in THEME_REGISTRY:
|
||||||
|
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: PASS (all 6 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/themes.py tests/test_themes.py
|
||||||
|
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||||
|
|
||||||
|
- Define Theme class to encapsulate name and main/message gradients
|
||||||
|
- Create THEME_REGISTRY with green, orange, purple themes
|
||||||
|
- Each gradient has 12 ANSI 256-color codes finalized
|
||||||
|
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||||
|
- Add get_theme() lookup with error handling
|
||||||
|
- Add comprehensive unit tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Config Integration
|
||||||
|
|
||||||
|
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/config.py:1-30`
|
||||||
|
- Test: `tests/test_config.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for config changes**
|
||||||
|
|
||||||
|
Add to `tests/test_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_active_theme_initially_none():
|
||||||
|
"""ACTIVE_THEME is None before initialization."""
|
||||||
|
# This test may fail if config is already initialized
|
||||||
|
# We'll set it to None first for testing
|
||||||
|
import engine.config
|
||||||
|
engine.config.ACTIVE_THEME = None
|
||||||
|
assert engine.config.ACTIVE_THEME is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_green():
|
||||||
|
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
set_active_theme("green")
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME is not None
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
assert config.ACTIVE_THEME == get_theme("green")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_default():
|
||||||
|
"""set_active_theme() with no args defaults to green."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
set_active_theme()
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_invalid():
|
||||||
|
"""set_active_theme() with invalid ID raises KeyError."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
set_active_theme("invalid")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: FAIL (functions don't exist yet)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||||
|
|
||||||
|
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is invalid
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DELETED:
|
||||||
|
# GRAD_COLS = [...]
|
||||||
|
# MSG_GRAD_COLS = [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 4 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify existing config tests still pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (all existing + new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/config.py tests/test_config.py
|
||||||
|
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||||
|
|
||||||
|
- Add ACTIVE_THEME global (initialized to None)
|
||||||
|
- Add set_active_theme(theme_id) function with green default
|
||||||
|
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||||
|
- Add comprehensive tests for theme setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Render Pipeline Integration
|
||||||
|
|
||||||
|
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/render.py:15-220`
|
||||||
|
- Test: `tests/test_render.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||||
|
|
||||||
|
Add to `tests/test_render.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||||
|
from engine import config, render
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
# Set orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
# Create simple rows
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Result should start with first color from orange main gradient
|
||||||
|
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
# Clear active theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Should not crash and should return something
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_green_gradient_length():
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
from engine import render
|
||||||
|
|
||||||
|
colors = render._default_green_gradient()
|
||||||
|
assert len(colors) == 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Expected: FAIL (function signature doesn't match)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
"""
|
||||||
|
Render rows through a left-to-right color sweep.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text rows to colorize
|
||||||
|
offset: Gradient position offset (for animation)
|
||||||
|
cols: Optional list of color codes. If None, uses active theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of colorized rows
|
||||||
|
"""
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... rest of function unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||||
|
|
||||||
|
Add to `engine/render.py` before `lr_gradient()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (original colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 3 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full render test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing tests may need adjustment for mocking)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/render.py tests/test_render.py
|
||||||
|
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||||
|
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||||
|
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||||
|
- Add tests for theme-aware and fallback gradient rendering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Message Gradient Integration
|
||||||
|
|
||||||
|
### Task 4: Update scroll.py to use message gradient from config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/scroll.py:85-95`
|
||||||
|
- Test: existing `tests/test_scroll.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||||
|
|
||||||
|
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||||
|
|
||||||
|
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||||
|
|
||||||
|
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||||
|
|
||||||
|
Old code:
|
||||||
|
```python
|
||||||
|
# Some variation of:
|
||||||
|
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
```
|
||||||
|
|
||||||
|
New code:
|
||||||
|
```python
|
||||||
|
from engine import config
|
||||||
|
msg_cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else render._default_magenta_gradient()
|
||||||
|
)
|
||||||
|
rows = lr_gradient(rows, offset, msg_cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in scroll.py:
|
||||||
|
```python
|
||||||
|
rows = render.msg_gradient(rows, offset)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing scroll tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_scroll.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing functionality unchanged)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/scroll.py engine/render.py
|
||||||
|
git commit -m "feat: update scroll.py to use theme message gradient
|
||||||
|
|
||||||
|
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||||
|
- Use fallback magenta gradient when theme not initialized
|
||||||
|
- Ensure ntfy messages render in complementary color from selected theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Color Picker UI
|
||||||
|
|
||||||
|
### Task 5: Create pick_color_theme() function in app.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/app.py:1-300`
|
||||||
|
- Test: manual/integration (interactive)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu."""
|
||||||
|
import sys
|
||||||
|
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create pick_color_theme() function**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add after helper function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use green
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... existing signal handler setup ...
|
||||||
|
|
||||||
|
pick_color_theme() # NEW LINE - before font picker
|
||||||
|
pick_font_face()
|
||||||
|
|
||||||
|
# ... rest of main unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- See color theme picker menu before font picker
|
||||||
|
- Can navigate with ↑/↓ or j/k
|
||||||
|
- Can select with Enter or q
|
||||||
|
- Selected theme applies to scrolling headlines
|
||||||
|
- Can select different themes and see colors change
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||||
|
|
||||||
|
Run: `echo "" | python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- No color picker menu shown
|
||||||
|
- Defaults to green theme
|
||||||
|
- App runs without error
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/app.py
|
||||||
|
git commit -m "feat: add pick_color_theme() UI and integration
|
||||||
|
|
||||||
|
- Create _draw_color_picker() to render menu
|
||||||
|
- Create pick_color_theme() function mirroring font picker pattern
|
||||||
|
- Integrate into main() before font picker
|
||||||
|
- Fallback to green theme in non-interactive environments
|
||||||
|
- Support arrow keys and j/k navigation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: Integration & Validation
|
||||||
|
|
||||||
|
### Task 6: End-to-end testing and cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: All modified files
|
||||||
|
- Verify: App functionality
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
|
||||||
|
Expected: PASS (all tests, including new ones)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
Run: `ruff check engine/ mainline.py`
|
||||||
|
|
||||||
|
Expected: No errors (fix any style issues)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual integration test - green theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Verdant Green" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in green → deep green
|
||||||
|
- ntfy messages render in magenta gradient
|
||||||
|
- Both work correctly during streaming
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual integration test - orange theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Molten Orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in orange → deep orange
|
||||||
|
- ntfy messages render in blue gradient
|
||||||
|
- Colors are visually distinct from green
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual integration test - purple theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Violet Purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in purple → deep purple
|
||||||
|
- ntfy messages render in yellow gradient
|
||||||
|
- Colors are visually distinct from green and orange
|
||||||
|
|
||||||
|
- [ ] **Step 6: Test poetry mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --poetry`
|
||||||
|
|
||||||
|
Then select "orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Poetry mode works with color picker
|
||||||
|
- Colors apply to poetry rendering
|
||||||
|
|
||||||
|
- [ ] **Step 7: Test code mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --code`
|
||||||
|
|
||||||
|
Then select "purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Code mode works with color picker
|
||||||
|
- Colors apply to code rendering
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify acceptance criteria**
|
||||||
|
|
||||||
|
✓ Color picker displays 3 theme options at startup
|
||||||
|
✓ Selection applies to all headline and message gradients
|
||||||
|
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
✓ No persistence between runs (each run picks fresh)
|
||||||
|
✓ Non-TTY environments default to green without error
|
||||||
|
✓ Architecture supports future random/animation modes
|
||||||
|
✓ All gradient color codes finalized with no TBD values
|
||||||
|
|
||||||
|
- [ ] **Step 9: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: color scheme switcher implementation complete
|
||||||
|
|
||||||
|
Closes color-pick feature with:
|
||||||
|
- Three selectable color themes (green, orange, purple)
|
||||||
|
- Interactive menu at startup (mirrors font picker UI)
|
||||||
|
- Complementary colors for ntfy message queue
|
||||||
|
- Fallback to green in non-interactive environments
|
||||||
|
- All tests passing, manual validation complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Create feature branch PR summary**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Color Scheme Switcher
|
||||||
|
|
||||||
|
Implements interactive color theme selection for Mainline news ticker.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||||
|
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||||
|
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||||
|
- Fresh theme selection each run (no persistence)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||||
|
- `engine/render.py` (theme-aware gradients)
|
||||||
|
- `engine/scroll.py` (message gradient integration)
|
||||||
|
- `engine/app.py` (pick_color_theme UI)
|
||||||
|
- `tests/test_themes.py` (new theme tests)
|
||||||
|
- `README.md` (documentation)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
All met. App fully tested and ready for merge.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||||
|
- [ ] Full suite: `pytest tests/ -v`
|
||||||
|
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||||
|
- [ ] Manual: Green theme selection
|
||||||
|
- [ ] Manual: Orange theme selection
|
||||||
|
- [ ] Manual: Purple theme selection
|
||||||
|
- [ ] Manual: Poetry mode with colors
|
||||||
|
- [ ] Manual: Code mode with colors
|
||||||
|
- [ ] Manual: Non-TTY fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||||
|
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||||
|
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||||
|
- Message gradients use complementary colors; lookup in scroll.py
|
||||||
|
- Each gradient has 12 colors; verify length in tests
|
||||||
|
- No persistence; fresh picker each run
|
||||||
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# README Update Design — 2026-03-15
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Restructure and expand `README.md` to:
|
||||||
|
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
||||||
|
2. Add extensibility-focused content (`Extending` section)
|
||||||
|
3. Add developer workflow coverage (`Development` section)
|
||||||
|
4. Improve navigability via top-level grouping (Approach C)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
# MAINLINE
|
||||||
|
> tagline + description
|
||||||
|
|
||||||
|
## Using
|
||||||
|
### Run
|
||||||
|
### Config
|
||||||
|
### Feeds
|
||||||
|
### Fonts
|
||||||
|
### ntfy.sh
|
||||||
|
|
||||||
|
## Internals
|
||||||
|
### How it works
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
### NtfyPoller
|
||||||
|
### MicMonitor
|
||||||
|
### Render pipeline
|
||||||
|
|
||||||
|
## Development
|
||||||
|
### Setup
|
||||||
|
### Tasks
|
||||||
|
### Testing
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
---
|
||||||
|
*footer*
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section-by-section design
|
||||||
|
|
||||||
|
### Using
|
||||||
|
|
||||||
|
All existing content preserved verbatim. Two changes:
|
||||||
|
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
||||||
|
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
||||||
|
|
||||||
|
Subsections moved into Using (currently standalone):
|
||||||
|
- `Feeds` — it's configuration, not a concept
|
||||||
|
- `ntfy.sh` (usage half)
|
||||||
|
|
||||||
|
### Internals
|
||||||
|
|
||||||
|
All existing content preserved verbatim. One change:
|
||||||
|
- **Architecture**: append `tests/` directory listing to the module tree
|
||||||
|
|
||||||
|
### Extending
|
||||||
|
|
||||||
|
Entirely new section. Three subsections:
|
||||||
|
|
||||||
|
**NtfyPoller**
|
||||||
|
- Minimal working import + usage example
|
||||||
|
- Note: stdlib only dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
|
poller.start()
|
||||||
|
|
||||||
|
# in your render loop:
|
||||||
|
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
|
||||||
|
**MicMonitor**
|
||||||
|
- Minimal working import + usage example
|
||||||
|
- Note: sounddevice/numpy optional, degrades gracefully
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
mic = MicMonitor(threshold_db=50)
|
||||||
|
if mic.start(): # returns False if sounddevice unavailable
|
||||||
|
excess = mic.excess # dB above threshold, clamped to 0
|
||||||
|
db = mic.db # raw RMS dB level
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render pipeline**
|
||||||
|
- Brief prose about `engine.render` as importable pipeline
|
||||||
|
- Minimal sketch of serve.py / ESP32 usage pattern
|
||||||
|
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Entirely new section. Four subsections:
|
||||||
|
|
||||||
|
**Setup**
|
||||||
|
- Hard requirements: Python 3.10+, uv
|
||||||
|
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
||||||
|
|
||||||
|
**Tasks** (via mise)
|
||||||
|
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
||||||
|
|
||||||
|
**Testing**
|
||||||
|
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
||||||
|
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
||||||
|
|
||||||
|
**Linting**
|
||||||
|
- `uv run ruff check` and `uv run ruff format`
|
||||||
|
- Note: pre-commit hooks run lint via `hk`
|
||||||
|
|
||||||
|
### Roadmap
|
||||||
|
|
||||||
|
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
|
||||||
|
Update `Python 3.9+` → `Python 3.10+`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `README.md` — restructured and expanded as above
|
||||||
|
- No other files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not changing
|
||||||
|
|
||||||
|
- All existing prose, examples, and config table values — preserved verbatim where retained
|
||||||
|
- The Ideas/Future content — kept intact under the new Roadmap heading
|
||||||
|
- The cyberpunk voice and terse style of the existing README
|
||||||
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
154
docs/superpowers/specs/2026-03-16-code-scroll-design.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# Code Scroll Mode — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Branch:** feat/code-scroll
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Mirror the existing `--poetry` mode pattern as closely as possible
|
||||||
|
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
||||||
|
- No changes to `scroll.py` or the render pipeline
|
||||||
|
- The item tuple shape `(text, src, ts)` is unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
### `engine/fetch_code.py`
|
||||||
|
|
||||||
|
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
|
||||||
|
1. Glob `engine/*.py` in sorted order
|
||||||
|
2. For each file:
|
||||||
|
a. Read source text
|
||||||
|
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
||||||
|
c. Iterate source lines (1-indexed). Skip if:
|
||||||
|
- The stripped line is empty
|
||||||
|
- The stripped line starts with `#`
|
||||||
|
d. For each kept line emit:
|
||||||
|
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
||||||
|
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
||||||
|
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
||||||
|
3. Return `(items, len(items), 0)`
|
||||||
|
|
||||||
|
**Scope label rules:**
|
||||||
|
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
||||||
|
- `ClassDef` → `name` (no parens)
|
||||||
|
- No enclosing node → `<module>`
|
||||||
|
|
||||||
|
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
### `engine/config.py`
|
||||||
|
|
||||||
|
Extend `MODE` detection to recognise `--code`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MODE = (
|
||||||
|
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code" if "--code" in sys.argv
|
||||||
|
else "news"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `engine/app.py`
|
||||||
|
|
||||||
|
**Subtitle line** — extend the subtitle dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_subtitle = {
|
||||||
|
"poetry": "literary consciousness stream",
|
||||||
|
"code": "source consciousness stream",
|
||||||
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
elif config.MODE == "code":
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, line_count, _ = fetch_code()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
||||||
|
```
|
||||||
|
|
||||||
|
No cache save/load — local source files are read instantly and change only on disk writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
engine/*.py (sorted)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
fetch_code()
|
||||||
|
│ ast.parse → scope map
|
||||||
|
│ filter blank + comment lines
|
||||||
|
│ emit (line, scope(), engine.module)
|
||||||
|
▼
|
||||||
|
items: List[Tuple[str, str, str]]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
stream(items, ntfy, mic) ← unchanged
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
next_headline() shuffles + recycles automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
||||||
|
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New file: `tests/test_fetch_code.py`
|
||||||
|
|
||||||
|
| Test | Assertion |
|
||||||
|
|------|-----------|
|
||||||
|
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
||||||
|
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
||||||
|
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
||||||
|
|
||||||
|
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 mainline.py --code # source consciousness mode
|
||||||
|
uv run mainline.py --code
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Syntax highlighting / token-aware coloring (can be added later)
|
||||||
|
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
||||||
|
- Caching code items to disk
|
||||||
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Color Scheme Switcher Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Status:** Revised after review
|
||||||
|
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||||
|
|
||||||
|
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Functional:**
|
||||||
|
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||||
|
2. Main headline gradient uses the selected primary color (white → color)
|
||||||
|
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||||
|
4. Selection is fresh each run (no persistence)
|
||||||
|
5. Design supports future "random rotation" mode without refactoring
|
||||||
|
|
||||||
|
**Complementary colors (precise opposites):**
|
||||||
|
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||||
|
- Orange (38;5;208) → Blue (38;5;21)
|
||||||
|
- Purple (38;5;129) → Yellow (38;5;226)
|
||||||
|
|
||||||
|
**Non-functional:**
|
||||||
|
- Reuse the existing font picker pattern for UI consistency
|
||||||
|
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||||
|
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||||
|
- Font picker UI remains hardcoded green for visual continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Module: `engine/themes.py`
|
||||||
|
|
||||||
|
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient # white → primary color
|
||||||
|
self.message_gradient = message_gradient # white → complementary
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Registry:**
|
||||||
|
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||||
|
|
||||||
|
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright color
|
||||||
|
"\033[38;5;40m", # color
|
||||||
|
"\033[38;5;34m", # medium color
|
||||||
|
"\033[38;5;28m", # dark color
|
||||||
|
"\033[38;5;22m", # deep color
|
||||||
|
"\033[2;38;5;22m", # dim deep color
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finalized color codes:**
|
||||||
|
|
||||||
|
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||||
|
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||||
|
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||||
|
|
||||||
|
**Orange (primary: 208, complementary: 21)**
|
||||||
|
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||||
|
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||||
|
|
||||||
|
**Purple (primary: 129, complementary: 226)**
|
||||||
|
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||||
|
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||||
|
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/config.py`
|
||||||
|
|
||||||
|
**New globals:**
|
||||||
|
```python
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||||
|
```
|
||||||
|
|
||||||
|
**New function:**
|
||||||
|
```python
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Called by `app.pick_color_theme()` with user selection
|
||||||
|
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||||
|
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||||
|
|
||||||
|
**Removal:**
|
||||||
|
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/render.py`
|
||||||
|
|
||||||
|
**Updated gradient access in existing functions:**
|
||||||
|
|
||||||
|
Current pattern (will be removed):
|
||||||
|
```python
|
||||||
|
GRAD_COLS = [...] # hardcoded green
|
||||||
|
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||||
|
```
|
||||||
|
|
||||||
|
New pattern — update `lr_gradient()` function:
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient())
|
||||||
|
# ... rest of function unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Define fallback:**
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (current colors)."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||||
|
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||||
|
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||||
|
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message gradient handling:**
|
||||||
|
|
||||||
|
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||||
|
```python
|
||||||
|
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
# Use:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
or define a helper:
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/app.py`
|
||||||
|
|
||||||
|
**New function: `pick_color_theme()`**
|
||||||
|
|
||||||
|
Mirrors `pick_font_face()` pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use default
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker (similar to font picker)
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||||
|
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement in `main()`:**
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... signal handler setup ...
|
||||||
|
pick_color_theme() # NEW — before title/subtitle
|
||||||
|
pick_font_face()
|
||||||
|
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User starts: mainline.py
|
||||||
|
↓
|
||||||
|
main() called
|
||||||
|
↓
|
||||||
|
pick_color_theme()
|
||||||
|
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||||
|
→ If not TTY: silently call config.set_active_theme("green")
|
||||||
|
↓
|
||||||
|
pick_font_face() — renders in hardcoded green UI colors
|
||||||
|
↓
|
||||||
|
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||||
|
↓
|
||||||
|
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||||
|
↓
|
||||||
|
On exit: no persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Initialization Guarantee
|
||||||
|
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||||
|
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||||
|
2. It's called before any rendering happens
|
||||||
|
3. Default fallback ensures non-TTY environments don't crash
|
||||||
|
|
||||||
|
### Module Independence
|
||||||
|
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||||
|
|
||||||
|
### Color Code Finalization
|
||||||
|
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||||
|
|
||||||
|
### Theme ID Naming
|
||||||
|
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||||
|
|
||||||
|
### Terminal Resize Handling
|
||||||
|
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||||
|
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||||
|
2. Once a theme is selected, the menu closes and rendering begins
|
||||||
|
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||||
|
|
||||||
|
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
1. **Unit tests** (`tests/test_themes.py`):
|
||||||
|
- Verify Theme class construction
|
||||||
|
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||||
|
- Confirm gradient lists have correct length (12)
|
||||||
|
|
||||||
|
2. **Integration tests** (`tests/test_render.py`):
|
||||||
|
- Mock `config.ACTIVE_THEME` to each theme
|
||||||
|
- Verify `lr_gradient()` uses correct colors
|
||||||
|
- Verify fallback works when `ACTIVE_THEME` is None
|
||||||
|
|
||||||
|
3. **Existing tests:**
|
||||||
|
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||||
|
- Use pytest fixtures to set theme per test case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||||
|
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||||
|
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||||
|
- `tests/test_themes.py` (new unit tests)
|
||||||
|
- `tests/test_render.py` (update mocking strategy)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. ✓ Color picker displays 3 theme options at startup
|
||||||
|
2. ✓ Selection applies to all headline and message gradients
|
||||||
|
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
5. ✓ No persistence between runs
|
||||||
|
6. ✓ Non-TTY environments default to green without error
|
||||||
|
7. ✓ Architecture supports future random/animation modes
|
||||||
|
8. ✓ All gradient color codes finalized with no TBD values
|
||||||
35
effects_plugins/__init__.py
Normal file
35
effects_plugins/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins():
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||||
|
if file_path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = file_path.stem
|
||||||
|
if module_name in ("base", "types"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||||
|
for attr_name in dir(module):
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and hasattr(attr, "name")
|
||||||
|
and hasattr(attr, "process")
|
||||||
|
and attr_name.endswith("Effect")
|
||||||
|
):
|
||||||
|
plugin = attr()
|
||||||
|
registry.register(plugin)
|
||||||
|
imported[plugin.name] = plugin
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return imported
|
||||||
58
effects_plugins/fade.py
Normal file
58
effects_plugins/fade.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class FadeEffect:
|
||||||
|
name = "fade"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||||
|
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
if r >= ctx.ticker_height:
|
||||||
|
continue
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = (
|
||||||
|
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||||
|
if bot_zone > 0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
row_fade = min(top_f, bot_f) * intensity
|
||||||
|
|
||||||
|
if row_fade < 1.0 and result[r].strip():
|
||||||
|
result[r] = self._fade_line(result[r], row_fade)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _fade_line(self, s: str, fade: float) -> str:
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ""
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == " ":
|
||||||
|
result.append(" ")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
72
effects_plugins/firehose.py
Normal file
72
effects_plugins/firehose.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class FirehoseEffect:
|
||||||
|
name = "firehose"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
if firehose_h <= 0 or not ctx.items:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
h = ctx.terminal_height
|
||||||
|
|
||||||
|
for fr in range(firehose_h):
|
||||||
|
scr_row = h - firehose_h + fr + 1
|
||||||
|
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||||
|
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35 * intensity:
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55 * intensity:
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78 * intensity:
|
||||||
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = "".join(
|
||||||
|
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||||
|
)
|
||||||
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
37
effects_plugins/glitch.py
Normal file
37
effects_plugins/glitch.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchEffect:
|
||||||
|
name = "glitch"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||||
|
glitch_prob = glitch_prob * intensity
|
||||||
|
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||||
|
n_hits = int(n_hits * intensity)
|
||||||
|
|
||||||
|
if random.random() < glitch_prob:
|
||||||
|
for _ in range(min(n_hits, len(result))):
|
||||||
|
gi = random.randint(0, len(result) - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _glitch_bar(self, w: int) -> str:
|
||||||
|
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
36
effects_plugins/noise.py
Normal file
36
effects_plugins/noise.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseEffect:
|
||||||
|
name = "noise"
|
||||||
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
probability = intensity * 0.15
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
cy = ctx.scroll_cam + r
|
||||||
|
if random.random() < probability:
|
||||||
|
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate_noise(self, w: int, cy: int) -> str:
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
1
engine/__init__.py
Normal file
1
engine/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# engine — modular internals for mainline
|
||||||
429
engine/app.py
Normal file
429
engine/app.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
"""
|
||||||
|
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
import tty
|
||||||
|
|
||||||
|
from engine import config, render, themes
|
||||||
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.scroll import stream
|
||||||
|
from engine.terminal import (
|
||||||
|
CLR,
|
||||||
|
CURSOR_OFF,
|
||||||
|
CURSOR_ON,
|
||||||
|
G_DIM,
|
||||||
|
G_HI,
|
||||||
|
G_MID,
|
||||||
|
RST,
|
||||||
|
W_DIM,
|
||||||
|
W_GHOST,
|
||||||
|
boot_ln,
|
||||||
|
slow_print,
|
||||||
|
tw,
|
||||||
|
)
|
||||||
|
|
||||||
|
TITLE = [
|
||||||
|
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||||
|
" ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝",
|
||||||
|
" ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ",
|
||||||
|
" ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ",
|
||||||
|
" ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗",
|
||||||
|
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_picker_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
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
|
||||||
|
if ch in ("k", "K"):
|
||||||
|
return "up"
|
||||||
|
if ch in ("j", "J"):
|
||||||
|
return "down"
|
||||||
|
if ch in ("q", "Q"):
|
||||||
|
return "enter"
|
||||||
|
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):
|
||||||
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
|
non_empty = [r for r in rows if r.strip()]
|
||||||
|
if not non_empty:
|
||||||
|
return [""]
|
||||||
|
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
if left_pad < len(row):
|
||||||
|
out.append(row[left_pad:].rstrip())
|
||||||
|
else:
|
||||||
|
out.append(row.rstrip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_font_picker(faces, selected):
|
||||||
|
w = tw()
|
||||||
|
h = 24
|
||||||
|
try:
|
||||||
|
h = os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
max_preview_w = max(24, w - 8)
|
||||||
|
header_h = 6
|
||||||
|
footer_h = 3
|
||||||
|
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
||||||
|
visible = max(1, h - header_h - preview_h - footer_h)
|
||||||
|
top = max(0, selected - (visible // 2))
|
||||||
|
bottom = min(len(faces), top + visible)
|
||||||
|
top = max(0, bottom - visible)
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
||||||
|
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for pos in range(top, bottom):
|
||||||
|
face = faces[pos]
|
||||||
|
active = pos == selected
|
||||||
|
pointer = "▶" if active else " "
|
||||||
|
color = G_HI if active else W_DIM
|
||||||
|
print(
|
||||||
|
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if top > 0:
|
||||||
|
print(f" {W_GHOST}… {top} above{RST}")
|
||||||
|
if bottom < len(faces):
|
||||||
|
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(
|
||||||
|
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
||||||
|
)
|
||||||
|
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
||||||
|
for row in preview_rows:
|
||||||
|
shown = row[:max_preview_w]
|
||||||
|
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():
|
||||||
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
|
if not config.FONT_PICKER:
|
||||||
|
return
|
||||||
|
|
||||||
|
font_files = config.list_repo_font_files()
|
||||||
|
if not font_files:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
||||||
|
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
prepared = []
|
||||||
|
for font_path in font_files:
|
||||||
|
try:
|
||||||
|
faces = render.list_font_faces(font_path, max_faces=64)
|
||||||
|
except Exception:
|
||||||
|
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
||||||
|
faces = [{"index": 0, "name": fallback}]
|
||||||
|
for face in faces:
|
||||||
|
idx = face["index"]
|
||||||
|
name = face["name"]
|
||||||
|
file_name = os.path.basename(font_path)
|
||||||
|
try:
|
||||||
|
fnt = render.load_font_face(font_path, idx)
|
||||||
|
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
||||||
|
except Exception:
|
||||||
|
rows = ["(preview unavailable)"]
|
||||||
|
prepared.append(
|
||||||
|
{
|
||||||
|
"font_path": font_path,
|
||||||
|
"font_index": idx,
|
||||||
|
"name": name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"preview_rows": rows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not prepared:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _same_path(a, b):
|
||||||
|
try:
|
||||||
|
return os.path.samefile(a, b)
|
||||||
|
except Exception:
|
||||||
|
return os.path.abspath(a) == os.path.abspath(b)
|
||||||
|
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, f in enumerate(prepared)
|
||||||
|
if _same_path(f["font_path"], config.FONT_PATH)
|
||||||
|
and f["font_index"] == config.FONT_INDEX
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||||
|
)
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_font_picker(prepared, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(prepared) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||||
|
)
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
|
def handle_sigint(*_):
|
||||||
|
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
||||||
|
print(f" {W_GHOST}> connection terminated{RST}\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_color_theme()
|
||||||
|
pick_font_face()
|
||||||
|
w = tw()
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
for ln in TITLE:
|
||||||
|
print(f"{G_HI}{ln}{RST}")
|
||||||
|
time.sleep(0.07)
|
||||||
|
|
||||||
|
print()
|
||||||
|
_subtitle = {
|
||||||
|
"poetry": "literary consciousness stream",
|
||||||
|
"code": "source consciousness stream",
|
||||||
|
}.get(config.MODE, "digital consciousness stream")
|
||||||
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
cached = load_cache() if "--refresh" not in sys.argv else None
|
||||||
|
if cached:
|
||||||
|
items = cached
|
||||||
|
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||||
|
elif config.MODE == "poetry":
|
||||||
|
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||||
|
)
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||||
|
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:
|
||||||
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||||
|
)
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||||
|
save_cache(items)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print()
|
||||||
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||||
|
mic_ok = mic.start()
|
||||||
|
if mic.available:
|
||||||
|
boot_ln(
|
||||||
|
"Microphone",
|
||||||
|
"ACTIVE"
|
||||||
|
if mic_ok
|
||||||
|
else "OFFLINE · check System Settings → Privacy → Microphone",
|
||||||
|
bool(mic_ok),
|
||||||
|
)
|
||||||
|
|
||||||
|
ntfy = NtfyPoller(
|
||||||
|
config.NTFY_TOPIC,
|
||||||
|
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||||
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||||
|
)
|
||||||
|
ntfy_ok = ntfy.start()
|
||||||
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
|
if config.FIREHOSE:
|
||||||
|
boot_ln("Firehose", "ENGAGED", True)
|
||||||
|
|
||||||
|
time.sleep(0.4)
|
||||||
|
slow_print(" > STREAMING...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
stream(items, ntfy, mic)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||||
|
print(f" {W_GHOST}> end of stream{RST}")
|
||||||
|
print()
|
||||||
262
engine/config.py
Normal file
262
engine/config.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Configuration constants, CLI flags, and glyph tables.
|
||||||
|
Supports both global constants (backward compatible) and injected config for testing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_value(flag, argv: list[str] | None = None):
|
||||||
|
"""Get value following a CLI flag, if present."""
|
||||||
|
argv = argv or sys.argv
|
||||||
|
if flag not in argv:
|
||||||
|
return None
|
||||||
|
i = argv.index(flag)
|
||||||
|
return argv[i + 1] if i + 1 < len(argv) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_int(flag, default, argv: list[str] | None = None):
|
||||||
|
"""Get int CLI argument with safe fallback."""
|
||||||
|
raw = _arg_value(flag, argv)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_font_path(raw_path):
|
||||||
|
"""Resolve font path; relative paths are anchored to repo root."""
|
||||||
|
p = Path(raw_path).expanduser()
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
return str((_REPO_ROOT / p).resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _list_font_files(font_dir):
|
||||||
|
"""List supported font files within a font directory."""
|
||||||
|
font_root = Path(font_dir)
|
||||||
|
if not font_root.exists() or not font_root.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
str(path.resolve())
|
||||||
|
for path in sorted(font_root.iterdir())
|
||||||
|
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_repo_font_files():
|
||||||
|
"""Public helper for discovering repository font files."""
|
||||||
|
return _list_font_files(FONT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_platform_font_paths() -> dict[str, str]:
|
||||||
|
"""Get platform-appropriate font paths for non-Latin scripts."""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Darwin":
|
||||||
|
return {
|
||||||
|
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||||
|
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||||
|
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||||
|
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||||
|
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||||
|
}
|
||||||
|
elif system == "Linux":
|
||||||
|
return {
|
||||||
|
"zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||||
|
"th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
"""Immutable configuration container for injected config."""
|
||||||
|
|
||||||
|
headline_limit: int = 1000
|
||||||
|
feed_timeout: int = 10
|
||||||
|
mic_threshold_db: int = 50
|
||||||
|
mode: str = "news"
|
||||||
|
firehose: bool = False
|
||||||
|
|
||||||
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_reconnect_delay: int = 5
|
||||||
|
message_display_secs: int = 30
|
||||||
|
|
||||||
|
font_dir: str = "fonts"
|
||||||
|
font_path: str = ""
|
||||||
|
font_index: int = 0
|
||||||
|
font_picker: bool = True
|
||||||
|
font_sz: int = 60
|
||||||
|
render_h: int = 8
|
||||||
|
|
||||||
|
ssaa: int = 4
|
||||||
|
|
||||||
|
scroll_dur: float = 5.625
|
||||||
|
frame_dt: float = 0.05
|
||||||
|
firehose_h: int = 12
|
||||||
|
grad_speed: float = 0.08
|
||||||
|
|
||||||
|
glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
|
kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
|
argv = argv or sys.argv
|
||||||
|
|
||||||
|
font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts")
|
||||||
|
font_file_arg = _arg_value("--font-file", argv)
|
||||||
|
font_files = _list_font_files(font_dir)
|
||||||
|
font_path = (
|
||||||
|
_resolve_font_path(font_file_arg)
|
||||||
|
if font_file_arg
|
||||||
|
else (font_files[0] if font_files else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
headline_limit=1000,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
|
firehose="--firehose" in argv,
|
||||||
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir=font_dir,
|
||||||
|
font_path=font_path,
|
||||||
|
font_index=max(0, _arg_int("--font-index", 0, argv)),
|
||||||
|
font_picker="--no-font-picker" not in argv,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_config: Config | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> Config:
|
||||||
|
"""Get the global config instance (lazy-loaded)."""
|
||||||
|
global _config
|
||||||
|
if _config is None:
|
||||||
|
_config = Config.from_args()
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
def set_config(config: Config) -> None:
|
||||||
|
"""Set the global config instance (for testing)."""
|
||||||
|
global _config
|
||||||
|
_config = config
|
||||||
|
|
||||||
|
|
||||||
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
|
HEADLINE_LIMIT = 1000
|
||||||
|
FEED_TIMEOUT = 10
|
||||||
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
|
MODE = (
|
||||||
|
"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
|
||||||
|
|
||||||
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
|
# ─── FONT RENDERING ──────────────────────────────────────
|
||||||
|
FONT_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts")
|
||||||
|
_FONT_FILE_ARG = _arg_value("--font-file")
|
||||||
|
_FONT_FILES = _list_font_files(FONT_DIR)
|
||||||
|
FONT_PATH = (
|
||||||
|
_resolve_font_path(_FONT_FILE_ARG)
|
||||||
|
if _FONT_FILE_ARG
|
||||||
|
else (_FONT_FILES[0] if _FONT_FILES else "")
|
||||||
|
)
|
||||||
|
FONT_INDEX = max(0, _arg_int("--font-index", 0))
|
||||||
|
FONT_PICKER = "--no-font-picker" not in sys.argv
|
||||||
|
FONT_SZ = 60
|
||||||
|
RENDER_H = 8 # terminal rows per rendered text line
|
||||||
|
|
||||||
|
# ─── FONT RENDERING (ADVANCED) ────────────────────────────
|
||||||
|
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
||||||
|
|
||||||
|
# ─── SCROLL / FRAME ──────────────────────────────────────
|
||||||
|
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||||
|
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||||
|
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||||
|
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||||
|
|
||||||
|
# ─── GLYPHS ───────────────────────────────────────────────
|
||||||
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
|
||||||
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
|
"""Set runtime primary font selection."""
|
||||||
|
global FONT_PATH, FONT_INDEX
|
||||||
|
if font_path is not None:
|
||||||
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
|
if font_index is not None:
|
||||||
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||||
|
Defaults to "green"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
68
engine/controller.py
Normal file
68
engine/controller.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Stream controller - manages input sources and orchestrates the render stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.config import Config, get_config
|
||||||
|
from engine.eventbus import EventBus
|
||||||
|
from engine.events import EventType, StreamEvent
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.scroll import stream
|
||||||
|
|
||||||
|
|
||||||
|
class StreamController:
|
||||||
|
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||||
|
self.config = config or get_config()
|
||||||
|
self.event_bus = event_bus
|
||||||
|
self.mic: MicMonitor | None = None
|
||||||
|
self.ntfy: NtfyPoller | None = None
|
||||||
|
|
||||||
|
def initialize_sources(self) -> tuple[bool, bool]:
|
||||||
|
"""Initialize microphone and ntfy sources.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(mic_ok, ntfy_ok) - success status for each source
|
||||||
|
"""
|
||||||
|
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
|
||||||
|
mic_ok = self.mic.start() if self.mic.available else False
|
||||||
|
|
||||||
|
self.ntfy = NtfyPoller(
|
||||||
|
self.config.ntfy_topic,
|
||||||
|
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||||
|
display_secs=self.config.message_display_secs,
|
||||||
|
)
|
||||||
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
|
return bool(mic_ok), ntfy_ok
|
||||||
|
|
||||||
|
def run(self, items: list) -> None:
|
||||||
|
"""Run the stream with initialized sources."""
|
||||||
|
if self.mic is None or self.ntfy is None:
|
||||||
|
self.initialize_sources()
|
||||||
|
|
||||||
|
if self.event_bus:
|
||||||
|
self.event_bus.publish(
|
||||||
|
EventType.STREAM_START,
|
||||||
|
StreamEvent(
|
||||||
|
event_type=EventType.STREAM_START,
|
||||||
|
headline_count=len(items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stream(items, self.ntfy, self.mic)
|
||||||
|
|
||||||
|
if self.event_bus:
|
||||||
|
self.event_bus.publish(
|
||||||
|
EventType.STREAM_END,
|
||||||
|
StreamEvent(
|
||||||
|
event_type=EventType.STREAM_END,
|
||||||
|
headline_count=len(items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up resources."""
|
||||||
|
if self.mic:
|
||||||
|
self.mic.stop()
|
||||||
102
engine/display.py
Normal file
102
engine/display.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Display output abstraction - allows swapping output backends.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
- init(width, height): Initialize display with terminal dimensions
|
||||||
|
- show(buffer): Render buffer (list of strings) to display
|
||||||
|
- clear(): Clear the display
|
||||||
|
- cleanup(): Shutdown display
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sys.stdout.buffer.write("".join(buffer).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
42
engine/effects/__init__.py
Normal file
42
engine/effects/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from engine.effects.chain import EffectChain
|
||||||
|
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||||
|
from engine.effects.legacy import (
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain():
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EffectChain",
|
||||||
|
"EffectRegistry",
|
||||||
|
"EffectConfig",
|
||||||
|
"EffectContext",
|
||||||
|
"PipelineConfig",
|
||||||
|
"get_registry",
|
||||||
|
"set_registry",
|
||||||
|
"get_effect_chain",
|
||||||
|
"get_monitor",
|
||||||
|
"set_monitor",
|
||||||
|
"PerformanceMonitor",
|
||||||
|
"handle_effects_command",
|
||||||
|
"show_effects_menu",
|
||||||
|
"fade_line",
|
||||||
|
"firehose_line",
|
||||||
|
"glitch_bar",
|
||||||
|
"noise",
|
||||||
|
"next_headline",
|
||||||
|
"vis_trunc",
|
||||||
|
]
|
||||||
71
engine/effects/chain.py
Normal file
71
engine/effects/chain.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||||
|
from engine.effects.registry import EffectRegistry
|
||||||
|
from engine.effects.types import EffectContext
|
||||||
|
|
||||||
|
|
||||||
|
class EffectChain:
|
||||||
|
def __init__(
|
||||||
|
self, registry: EffectRegistry, monitor: PerformanceMonitor | None = None
|
||||||
|
):
|
||||||
|
self._registry = registry
|
||||||
|
self._order: list[str] = []
|
||||||
|
self._monitor = monitor
|
||||||
|
|
||||||
|
def _get_monitor(self) -> PerformanceMonitor:
|
||||||
|
if self._monitor is not None:
|
||||||
|
return self._monitor
|
||||||
|
return get_monitor()
|
||||||
|
|
||||||
|
def set_order(self, names: list[str]) -> None:
|
||||||
|
self._order = list(names)
|
||||||
|
|
||||||
|
def get_order(self) -> list[str]:
|
||||||
|
return self._order.copy()
|
||||||
|
|
||||||
|
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||||
|
if name not in self._registry.list_all():
|
||||||
|
return False
|
||||||
|
if position is None:
|
||||||
|
self._order.append(name)
|
||||||
|
else:
|
||||||
|
self._order.insert(position, name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_effect(self, name: str) -> bool:
|
||||||
|
if name in self._order:
|
||||||
|
self._order.remove(name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reorder(self, new_order: list[str]) -> bool:
|
||||||
|
all_plugins = set(self._registry.list_all().keys())
|
||||||
|
if not all(name in all_plugins for name in new_order):
|
||||||
|
return False
|
||||||
|
self._order = list(new_order)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
monitor = self._get_monitor()
|
||||||
|
frame_number = ctx.frame_number
|
||||||
|
monitor.start_frame(frame_number)
|
||||||
|
|
||||||
|
frame_start = time.perf_counter()
|
||||||
|
result = list(buf)
|
||||||
|
for name in self._order:
|
||||||
|
plugin = self._registry.get(name)
|
||||||
|
if plugin and plugin.config.enabled:
|
||||||
|
chars_in = sum(len(line) for line in result)
|
||||||
|
effect_start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
result = plugin.process(result, ctx)
|
||||||
|
except Exception:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
elapsed = time.perf_counter() - effect_start
|
||||||
|
chars_out = sum(len(line) for line in result)
|
||||||
|
monitor.record_effect(name, elapsed * 1000, chars_in, chars_out)
|
||||||
|
|
||||||
|
total_elapsed = time.perf_counter() - frame_start
|
||||||
|
monitor.end_frame(frame_number, total_elapsed * 1000)
|
||||||
|
return result
|
||||||
144
engine/effects/controller.py
Normal file
144
engine/effects/controller.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from engine.effects.performance import get_monitor
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
_effect_chain_ref = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effect_chain():
|
||||||
|
global _effect_chain_ref
|
||||||
|
if _effect_chain_ref is not None:
|
||||||
|
return _effect_chain_ref
|
||||||
|
try:
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def set_effect_chain_ref(chain) -> None:
|
||||||
|
global _effect_chain_ref
|
||||||
|
_effect_chain_ref = chain
|
||||||
|
|
||||||
|
|
||||||
|
def handle_effects_command(cmd: str) -> str:
|
||||||
|
"""Handle /effects command from NTFY message.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/effects list - list all effects and their status
|
||||||
|
/effects <name> on - enable an effect
|
||||||
|
/effects <name> off - disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - set intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||||
|
/effects stats - show performance statistics
|
||||||
|
"""
|
||||||
|
parts = cmd.strip().split()
|
||||||
|
if not parts or parts[0] != "/effects":
|
||||||
|
return "Unknown command"
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
if len(parts) == 1 or parts[1] == "list":
|
||||||
|
result = ["Effects:"]
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
result.append(f" {name}: {status} (intensity={intensity})")
|
||||||
|
if chain:
|
||||||
|
result.append(f"Order: {chain.get_order()}")
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
if parts[1] == "stats":
|
||||||
|
return _format_stats()
|
||||||
|
|
||||||
|
if parts[1] == "reorder" and len(parts) >= 3:
|
||||||
|
new_order = parts[2].split(",")
|
||||||
|
if chain and chain.reorder(new_order):
|
||||||
|
return f"Reordered pipeline: {new_order}"
|
||||||
|
return "Failed to reorder pipeline"
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
|
return "Usage: /effects <name> on|off|intensity <value>"
|
||||||
|
|
||||||
|
effect_name = parts[1]
|
||||||
|
action = parts[2]
|
||||||
|
|
||||||
|
if effect_name not in registry.list_all():
|
||||||
|
return f"Unknown effect: {effect_name}"
|
||||||
|
|
||||||
|
if action == "on":
|
||||||
|
registry.enable(effect_name)
|
||||||
|
return f"Enabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "off":
|
||||||
|
registry.disable(effect_name)
|
||||||
|
return f"Disabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "intensity" and len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
value = float(parts[3])
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
return "Intensity must be between 0.0 and 1.0"
|
||||||
|
plugin = registry.get(effect_name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.intensity = value
|
||||||
|
return f"Set {effect_name} intensity to {value}"
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid intensity value"
|
||||||
|
|
||||||
|
return f"Unknown action: {action}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_stats() -> str:
|
||||||
|
monitor = get_monitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
|
||||||
|
if "error" in stats:
|
||||||
|
return stats["error"]
|
||||||
|
|
||||||
|
lines = ["Performance Stats:"]
|
||||||
|
|
||||||
|
pipeline = stats["pipeline"]
|
||||||
|
lines.append(
|
||||||
|
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if stats["effects"]:
|
||||||
|
lines.append(" Per-effect (avg ms):")
|
||||||
|
for name, effect_stats in stats["effects"].items():
|
||||||
|
lines.append(
|
||||||
|
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def show_effects_menu() -> str:
|
||||||
|
"""Generate effects menu text for display."""
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||||
|
"",
|
||||||
|
"Effects:",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||||
|
|
||||||
|
if chain:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Controls:")
|
||||||
|
lines.append(" /effects <name> on|off")
|
||||||
|
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||||
|
lines.append(" /effects reorder name1,name2,...")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
134
engine/effects/legacy.py
Normal file
134
engine/effects/legacy.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||||
|
Depends on: config, terminal, sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
def noise(w):
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def glitch_bar(w):
|
||||||
|
c = random.choice(["░", "▒", "─", "╌"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
|
||||||
|
def fade_line(s, fade):
|
||||||
|
"""Dissolve a rendered line by probabilistically dropping characters."""
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ""
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == " ":
|
||||||
|
result.append(" ")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def vis_trunc(s, w):
|
||||||
|
"""Truncate string to visual width w, skipping ANSI escape codes."""
|
||||||
|
result = []
|
||||||
|
vw = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if vw >= w:
|
||||||
|
break
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
else:
|
||||||
|
result.append(s[i])
|
||||||
|
vw += 1
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def next_headline(pool, items, seen):
|
||||||
|
"""Pull the next unique headline from pool, refilling as needed."""
|
||||||
|
while True:
|
||||||
|
if not pool:
|
||||||
|
pool.extend(items)
|
||||||
|
random.shuffle(pool)
|
||||||
|
seen.clear()
|
||||||
|
title, src, ts = pool.pop()
|
||||||
|
sig = title.lower().strip()
|
||||||
|
if sig not in seen:
|
||||||
|
seen.add(sig)
|
||||||
|
return title, src, ts
|
||||||
|
|
||||||
|
|
||||||
|
def firehose_line(items, w):
|
||||||
|
"""Generate one line of rapidly cycling firehose content."""
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35:
|
||||||
|
# Raw headline text
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55:
|
||||||
|
# Dense glitch noise
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78:
|
||||||
|
# Status / program output
|
||||||
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
# Headline fragment with glitch prefix
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = "".join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
||||||
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
103
engine/effects/performance.py
Normal file
103
engine/effects/performance.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectTiming:
|
||||||
|
name: str
|
||||||
|
duration_ms: float
|
||||||
|
buffer_chars_in: int
|
||||||
|
buffer_chars_out: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameTiming:
|
||||||
|
frame_number: int
|
||||||
|
total_ms: float
|
||||||
|
effects: list[EffectTiming]
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceMonitor:
|
||||||
|
"""Collects and stores performance metrics for effect pipeline."""
|
||||||
|
|
||||||
|
def __init__(self, max_frames: int = 60):
|
||||||
|
self._max_frames = max_frames
|
||||||
|
self._frames: deque[FrameTiming] = deque(maxlen=max_frames)
|
||||||
|
self._current_frame: list[EffectTiming] = []
|
||||||
|
|
||||||
|
def start_frame(self, frame_number: int) -> None:
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
def record_effect(
|
||||||
|
self, name: str, duration_ms: float, chars_in: int, chars_out: int
|
||||||
|
) -> None:
|
||||||
|
self._current_frame.append(
|
||||||
|
EffectTiming(
|
||||||
|
name=name,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
buffer_chars_in=chars_in,
|
||||||
|
buffer_chars_out=chars_out,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def end_frame(self, frame_number: int, total_ms: float) -> None:
|
||||||
|
self._frames.append(
|
||||||
|
FrameTiming(
|
||||||
|
frame_number=frame_number,
|
||||||
|
total_ms=total_ms,
|
||||||
|
effects=self._current_frame,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
if not self._frames:
|
||||||
|
return {"error": "No timing data available"}
|
||||||
|
|
||||||
|
total_times = [f.total_ms for f in self._frames]
|
||||||
|
avg_total = sum(total_times) / len(total_times)
|
||||||
|
min_total = min(total_times)
|
||||||
|
max_total = max(total_times)
|
||||||
|
|
||||||
|
effect_stats: dict[str, dict] = {}
|
||||||
|
for frame in self._frames:
|
||||||
|
for effect in frame.effects:
|
||||||
|
if effect.name not in effect_stats:
|
||||||
|
effect_stats[effect.name] = {"times": [], "total_chars": 0}
|
||||||
|
effect_stats[effect.name]["times"].append(effect.duration_ms)
|
||||||
|
effect_stats[effect.name]["total_chars"] += effect.buffer_chars_out
|
||||||
|
|
||||||
|
for name, stats in effect_stats.items():
|
||||||
|
times = stats["times"]
|
||||||
|
stats["avg_ms"] = sum(times) / len(times)
|
||||||
|
stats["min_ms"] = min(times)
|
||||||
|
stats["max_ms"] = max(times)
|
||||||
|
del stats["times"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_count": len(self._frames),
|
||||||
|
"pipeline": {
|
||||||
|
"avg_ms": avg_total,
|
||||||
|
"min_ms": min_total,
|
||||||
|
"max_ms": max_total,
|
||||||
|
},
|
||||||
|
"effects": effect_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._frames.clear()
|
||||||
|
self._current_frame = []
|
||||||
|
|
||||||
|
|
||||||
|
_monitor: PerformanceMonitor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor() -> PerformanceMonitor:
|
||||||
|
global _monitor
|
||||||
|
if _monitor is None:
|
||||||
|
_monitor = PerformanceMonitor()
|
||||||
|
return _monitor
|
||||||
|
|
||||||
|
|
||||||
|
def set_monitor(monitor: PerformanceMonitor) -> None:
|
||||||
|
global _monitor
|
||||||
|
_monitor = monitor
|
||||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from engine.effects.types import EffectConfig, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class EffectRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._plugins: dict[str, EffectPlugin] = {}
|
||||||
|
self._discovered: bool = False
|
||||||
|
|
||||||
|
def register(self, plugin: EffectPlugin) -> None:
|
||||||
|
self._plugins[plugin.name] = plugin
|
||||||
|
|
||||||
|
def get(self, name: str) -> EffectPlugin | None:
|
||||||
|
return self._plugins.get(name)
|
||||||
|
|
||||||
|
def list_all(self) -> dict[str, EffectPlugin]:
|
||||||
|
return self._plugins.copy()
|
||||||
|
|
||||||
|
def list_enabled(self) -> list[EffectPlugin]:
|
||||||
|
return [p for p in self._plugins.values() if p.config.enabled]
|
||||||
|
|
||||||
|
def enable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.configure(config)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_enabled(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
return plugin.config.enabled if plugin else False
|
||||||
|
|
||||||
|
|
||||||
|
_registry: EffectRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> EffectRegistry:
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = EffectRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def set_registry(registry: EffectRegistry) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
39
engine/effects/types.py
Normal file
39
engine/effects/types.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectContext:
|
||||||
|
terminal_width: int
|
||||||
|
terminal_height: int
|
||||||
|
scroll_cam: int
|
||||||
|
ticker_height: int
|
||||||
|
mic_excess: float
|
||||||
|
grad_offset: float
|
||||||
|
frame_number: int
|
||||||
|
has_message: bool
|
||||||
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
intensity: float = 1.0
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPlugin:
|
||||||
|
name: str
|
||||||
|
config: EffectConfig
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
order: list[str] = field(default_factory=list)
|
||||||
|
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||||
25
engine/emitters.py
Normal file
25
engine/emitters.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Event emitter protocols - abstract interfaces for event-producing components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class EventEmitter(Protocol):
|
||||||
|
"""Protocol for components that emit events."""
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
def unsubscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Startable(Protocol):
|
||||||
|
"""Protocol for components that can be started."""
|
||||||
|
|
||||||
|
def start(self) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Stoppable(Protocol):
|
||||||
|
"""Protocol for components that can be stopped."""
|
||||||
|
|
||||||
|
def stop(self) -> None: ...
|
||||||
72
engine/eventbus.py
Normal file
72
engine/eventbus.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""
|
||||||
|
Event bus - pub/sub messaging for decoupled component communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.events import EventType
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""Thread-safe event bus for publish-subscribe messaging."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._subscribers: dict[EventType, list[Callable[[Any], None]]] = defaultdict(
|
||||||
|
list
|
||||||
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||||
|
"""Register a callback for a specific event type."""
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers[event_type].append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(
|
||||||
|
self, event_type: EventType, callback: Callable[[Any], None]
|
||||||
|
) -> None:
|
||||||
|
"""Remove a callback for a specific event type."""
|
||||||
|
with self._lock:
|
||||||
|
if callback in self._subscribers[event_type]:
|
||||||
|
self._subscribers[event_type].remove(callback)
|
||||||
|
|
||||||
|
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||||
|
"""Publish an event to all subscribers."""
|
||||||
|
with self._lock:
|
||||||
|
callbacks = list(self._subscribers.get(event_type, []))
|
||||||
|
for callback in callbacks:
|
||||||
|
try:
|
||||||
|
callback(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Remove all subscribers."""
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.clear()
|
||||||
|
|
||||||
|
def subscriber_count(self, event_type: EventType | None = None) -> int:
|
||||||
|
"""Get subscriber count for an event type, or total if None."""
|
||||||
|
with self._lock:
|
||||||
|
if event_type is None:
|
||||||
|
return sum(len(cb) for cb in self._subscribers.values())
|
||||||
|
return len(self._subscribers.get(event_type, []))
|
||||||
|
|
||||||
|
|
||||||
|
_event_bus: EventBus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_bus() -> EventBus:
|
||||||
|
"""Get the global event bus instance."""
|
||||||
|
global _event_bus
|
||||||
|
if _event_bus is None:
|
||||||
|
_event_bus = EventBus()
|
||||||
|
return _event_bus
|
||||||
|
|
||||||
|
|
||||||
|
def set_event_bus(bus: EventBus) -> None:
|
||||||
|
"""Set the global event bus instance (for testing)."""
|
||||||
|
global _event_bus
|
||||||
|
_event_bus = bus
|
||||||
67
engine/events.py
Normal file
67
engine/events.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Event types for the mainline application.
|
||||||
|
Defines the core events that flow through the system.
|
||||||
|
These types support a future migration to an event-driven architecture.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
"""Core event types in the mainline application."""
|
||||||
|
|
||||||
|
NEW_HEADLINE = auto()
|
||||||
|
FRAME_TICK = auto()
|
||||||
|
MIC_LEVEL = auto()
|
||||||
|
NTFY_MESSAGE = auto()
|
||||||
|
STREAM_START = auto()
|
||||||
|
STREAM_END = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadlineEvent:
|
||||||
|
"""Event emitted when a new headline is ready for display."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameTickEvent:
|
||||||
|
"""Event emitted on each render frame."""
|
||||||
|
|
||||||
|
frame_number: int
|
||||||
|
timestamp: datetime
|
||||||
|
delta_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MicLevelEvent:
|
||||||
|
"""Event emitted when microphone level changes significantly."""
|
||||||
|
|
||||||
|
db_level: float
|
||||||
|
excess_above_threshold: float
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NtfyMessageEvent:
|
||||||
|
"""Event emitted when an ntfy message is received."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
message_id: str | None = None
|
||||||
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamEvent:
|
||||||
|
"""Event emitted when stream starts or ends."""
|
||||||
|
|
||||||
|
event_type: EventType
|
||||||
|
headline_count: int = 0
|
||||||
|
timestamp: datetime | None = None
|
||||||
145
engine/fetch.py
Normal file
145
engine/fetch.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
RSS feed fetching, Project Gutenberg parsing, and headline caching.
|
||||||
|
Depends on: config, sources, filter, terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.filter import skip, strip_tags
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
|
# Type alias for headline items
|
||||||
|
HeadlineTuple = tuple[str, str, str]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||||
|
def fetch_feed(url: str) -> Any | None:
|
||||||
|
"""Fetch and parse a single RSS feed URL."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
|
return feedparser.parse(resp.read())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||||
|
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
|
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
||||||
|
items: list[HeadlineTuple] = []
|
||||||
|
linked = failed = 0
|
||||||
|
for src, url in FEEDS.items():
|
||||||
|
feed = fetch_feed(url)
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
boot_ln(src, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
n = 0
|
||||||
|
for e in feed.entries:
|
||||||
|
t = strip_tags(e.get("title", ""))
|
||||||
|
if not t or skip(t):
|
||||||
|
continue
|
||||||
|
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||||
|
try:
|
||||||
|
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||||
|
except Exception:
|
||||||
|
ts = "——:——"
|
||||||
|
items.append((t, src, ts))
|
||||||
|
n += 1
|
||||||
|
if n:
|
||||||
|
boot_ln(src, f"LINKED [{n}]", True)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(src, "EMPTY", False)
|
||||||
|
failed += 1
|
||||||
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||||
|
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||||
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
text = (
|
||||||
|
resp.read()
|
||||||
|
.decode("utf-8", errors="replace")
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.replace("\r", "\n")
|
||||||
|
)
|
||||||
|
# Strip PG boilerplate
|
||||||
|
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||||
|
if m:
|
||||||
|
text = text[m.end() :]
|
||||||
|
m = re.search(r"\*\*\*\s*END OF", text)
|
||||||
|
if m:
|
||||||
|
text = text[: m.start()]
|
||||||
|
# Split on blank lines into stanzas/passages
|
||||||
|
blocks = re.split(r"\n{2,}", text.strip())
|
||||||
|
items = []
|
||||||
|
for blk in blocks:
|
||||||
|
blk = " ".join(blk.split()) # flatten to one line
|
||||||
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
|
continue
|
||||||
|
if blk.isupper(): # skip all-caps headers
|
||||||
|
continue
|
||||||
|
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
||||||
|
continue
|
||||||
|
items.append((blk, label, ""))
|
||||||
|
return items
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_poetry():
|
||||||
|
"""Fetch all poetry/literature sources."""
|
||||||
|
items = []
|
||||||
|
linked = failed = 0
|
||||||
|
for label, url in POETRY_SOURCES.items():
|
||||||
|
stanzas = _fetch_gutenberg(url, label)
|
||||||
|
if stanzas:
|
||||||
|
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||||
|
items.extend(stanzas)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(label, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CACHE ────────────────────────────────────────────────
|
||||||
|
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_path():
|
||||||
|
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache():
|
||||||
|
"""Load cached items from disk if available."""
|
||||||
|
p = _cache_path()
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
items = [tuple(i) for i in data["items"]]
|
||||||
|
return items if items else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_cache(items):
|
||||||
|
"""Save fetched items to disk for fast subsequent runs."""
|
||||||
|
try:
|
||||||
|
_cache_path().write_text(json.dumps({"items": items}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
67
engine/fetch_code.py
Normal file
67
engine/fetch_code.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
||||||
|
as scroll items. Used by --code mode.
|
||||||
|
Depends on: nothing (stdlib only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_ENGINE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_map(source: str) -> dict[int, str]:
|
||||||
|
"""Return {line_number: scope_label} for every line in source.
|
||||||
|
|
||||||
|
Nodes are sorted by range size descending so inner scopes overwrite
|
||||||
|
outer ones, guaranteeing the narrowest enclosing scope wins.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
except SyntaxError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
span = end - node.lineno
|
||||||
|
nodes.append((span, node))
|
||||||
|
|
||||||
|
# Largest range first → inner scopes overwrite on second pass
|
||||||
|
nodes.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
|
scope = {}
|
||||||
|
for _, node in nodes:
|
||||||
|
end = getattr(node, "end_lineno", node.lineno)
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
label = node.name
|
||||||
|
else:
|
||||||
|
label = f"{node.name}()"
|
||||||
|
for ln in range(node.lineno, end + 1):
|
||||||
|
scope[ln] = label
|
||||||
|
|
||||||
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_code():
|
||||||
|
"""Read engine/*.py and return (items, line_count, 0).
|
||||||
|
|
||||||
|
Each item is (text, src, ts) where:
|
||||||
|
text = the code line (rstripped, indentation preserved)
|
||||||
|
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
||||||
|
ts = dotted module path, e.g. 'engine.scroll'
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
||||||
|
module = f"engine.{path.stem}"
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
scope = _scope_map(source)
|
||||||
|
for lineno, raw in enumerate(source.splitlines(), start=1):
|
||||||
|
stripped = raw.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
label = scope.get(lineno, "<module>")
|
||||||
|
items.append((raw.rstrip(), label, module))
|
||||||
|
|
||||||
|
return items, len(items), 0
|
||||||
60
engine/filter.py
Normal file
60
engine/filter.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
HTML stripping and content filter (sports, vapid, insipid).
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML STRIPPING ───────────────────────────────────────
|
||||||
|
class _Strip(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._t = []
|
||||||
|
|
||||||
|
def handle_data(self, d):
|
||||||
|
self._t.append(d)
|
||||||
|
|
||||||
|
def text(self):
|
||||||
|
return "".join(self._t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags(html):
|
||||||
|
s = _Strip()
|
||||||
|
s.feed(unescape(html or ""))
|
||||||
|
return s.text()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CONTENT FILTER ───────────────────────────────────────
|
||||||
|
_SKIP_RE = re.compile(
|
||||||
|
r"\b(?:"
|
||||||
|
# ── sports ──
|
||||||
|
r"football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|"
|
||||||
|
r"hockey|lacrosse|volleyball|badminton|"
|
||||||
|
r"nba|nfl|nhl|mlb|mls|fifa|uefa|"
|
||||||
|
r"premier league|champions league|la liga|serie a|bundesliga|"
|
||||||
|
r"world cup|super bowl|world series|stanley cup|"
|
||||||
|
r"playoff|playoffs|touchdown|goalkeeper|striker|quarterback|"
|
||||||
|
r"slam dunk|home run|grand slam|offside|halftime|"
|
||||||
|
r"batting|wicket|innings|"
|
||||||
|
r"formula 1|nascar|motogp|"
|
||||||
|
r"boxing|ufc|mma|"
|
||||||
|
r"marathon|tour de france|"
|
||||||
|
r"transfer window|draft pick|relegation|"
|
||||||
|
# ── vapid / insipid ──
|
||||||
|
r"kardashian|jenner|reality tv|reality show|"
|
||||||
|
r"influencer|viral video|tiktok|instagram|"
|
||||||
|
r"best dressed|worst dressed|red carpet|"
|
||||||
|
r"horoscope|zodiac|gossip|bikini|selfie|"
|
||||||
|
r"you won.t believe|what happened next|"
|
||||||
|
r"celebrity couple|celebrity feud|baby bump"
|
||||||
|
r")\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def skip(title):
|
||||||
|
"""Return True if headline is sports, vapid, or insipid."""
|
||||||
|
return bool(_SKIP_RE.search(title))
|
||||||
57
engine/frame.py
Normal file
57
engine/frame.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Frame timing utilities — FPS control and precise timing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class FrameTimer:
|
||||||
|
"""Frame timer for consistent render loop timing."""
|
||||||
|
|
||||||
|
def __init__(self, target_frame_dt: float = 0.05):
|
||||||
|
self.target_frame_dt = target_frame_dt
|
||||||
|
self._frame_count = 0
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
self._last_frame_time = self._start_time
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fps(self) -> float:
|
||||||
|
"""Current FPS based on elapsed frames."""
|
||||||
|
elapsed = time.monotonic() - self._start_time
|
||||||
|
if elapsed > 0:
|
||||||
|
return self._frame_count / elapsed
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def sleep_until_next_frame(self) -> float:
|
||||||
|
"""Sleep to maintain target frame rate. Returns actual elapsed time."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_frame_time
|
||||||
|
self._last_frame_time = now
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
sleep_time = max(0, self.target_frame_dt - elapsed)
|
||||||
|
if sleep_time > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
return elapsed
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset frame counter and start time."""
|
||||||
|
self._frame_count = 0
|
||||||
|
self._start_time = time.monotonic()
|
||||||
|
self._last_frame_time = self._start_time
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_scroll_step(
|
||||||
|
scroll_dur: float, view_height: int, padding: int = 15
|
||||||
|
) -> float:
|
||||||
|
"""Calculate scroll step interval for smooth scrolling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scroll_dur: Duration in seconds for one headline to scroll through view
|
||||||
|
view_height: Terminal height in rows
|
||||||
|
padding: Extra rows for off-screen content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time in seconds between scroll steps
|
||||||
|
"""
|
||||||
|
return scroll_dur / (view_height + padding) * 2
|
||||||
260
engine/layers.py
Normal file
260
engine/layers.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""
|
||||||
|
Layer compositing — message overlay, ticker zone, firehose, noise.
|
||||||
|
Depends on: config, render, effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectContext,
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.render import big_wrap, lr_gradient, msg_gradient
|
||||||
|
from engine.terminal import RST, W_COOL
|
||||||
|
|
||||||
|
MSG_META = "\033[38;5;245m"
|
||||||
|
MSG_BORDER = "\033[2;38;5;37m"
|
||||||
|
|
||||||
|
|
||||||
|
def render_message_overlay(
|
||||||
|
msg: tuple[str, str, float] | None,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
msg_cache: tuple,
|
||||||
|
) -> tuple[list[str], tuple]:
|
||||||
|
"""Render ntfy message overlay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: (title, body, timestamp) or None
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
msg_cache: (cache_key, rendered_rows) for caching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(list of ANSI strings, updated cache)
|
||||||
|
"""
|
||||||
|
overlay = []
|
||||||
|
if msg is None:
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
m_title, m_body, m_ts = msg
|
||||||
|
display_text = m_body or m_title or "(empty)"
|
||||||
|
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||||
|
|
||||||
|
cache_key = (display_text, w)
|
||||||
|
if msg_cache[0] != cache_key:
|
||||||
|
msg_rows = big_wrap(display_text, w - 4)
|
||||||
|
msg_cache = (cache_key, msg_rows)
|
||||||
|
else:
|
||||||
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
|
msg_rows = msg_gradient(
|
||||||
|
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
|
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||||
|
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||||
|
panel_h = len(msg_rows) + 2
|
||||||
|
panel_top = max(0, (h - panel_h) // 2)
|
||||||
|
|
||||||
|
row_idx = 0
|
||||||
|
for mr in msg_rows:
|
||||||
|
ln = vis_trunc(mr, w)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
meta_parts = []
|
||||||
|
if m_title and m_title != m_body:
|
||||||
|
meta_parts.append(m_title)
|
||||||
|
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||||
|
meta = (
|
||||||
|
" " + " \u00b7 ".join(meta_parts)
|
||||||
|
if len(meta_parts) > 1
|
||||||
|
else " " + meta_parts[0]
|
||||||
|
)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}\033[0m\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
bar = "\u2500" * (w - 4)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}\033[0m\033[K")
|
||||||
|
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
|
||||||
|
def render_ticker_zone(
|
||||||
|
active: list,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
w: int,
|
||||||
|
noise_cache: dict,
|
||||||
|
grad_offset: float,
|
||||||
|
) -> tuple[list[str], dict]:
|
||||||
|
"""Render the ticker scroll zone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||||
|
scroll_cam: camera position (viewport top)
|
||||||
|
ticker_h: height of ticker zone
|
||||||
|
w: terminal width
|
||||||
|
noise_cache: dict of cy -> noise string
|
||||||
|
grad_offset: gradient animation offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(list of ANSI strings, updated noise_cache)
|
||||||
|
"""
|
||||||
|
buf = []
|
||||||
|
top_zone = max(1, int(ticker_h * 0.25))
|
||||||
|
bot_zone = max(1, int(ticker_h * 0.10))
|
||||||
|
|
||||||
|
def noise_at(cy):
|
||||||
|
if cy not in noise_cache:
|
||||||
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
for r in range(ticker_h):
|
||||||
|
scr_row = r + 1
|
||||||
|
cy = scroll_cam + r
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||||
|
row_fade = min(top_f, bot_f)
|
||||||
|
drawn = False
|
||||||
|
|
||||||
|
for content, hc, by, midx in active:
|
||||||
|
cr = cy - by
|
||||||
|
if 0 <= cr < len(content):
|
||||||
|
raw = content[cr]
|
||||||
|
if cr != midx:
|
||||||
|
colored = lr_gradient([raw], grad_offset)[0]
|
||||||
|
else:
|
||||||
|
colored = raw
|
||||||
|
ln = vis_trunc(colored, w)
|
||||||
|
if row_fade < 1.0:
|
||||||
|
ln = fade_line(ln, row_fade)
|
||||||
|
|
||||||
|
if cr == midx:
|
||||||
|
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||||
|
elif ln.strip():
|
||||||
|
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
drawn = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not drawn:
|
||||||
|
n = noise_at(cy)
|
||||||
|
if row_fade < 1.0 and n:
|
||||||
|
n = fade_line(n, row_fade)
|
||||||
|
if n:
|
||||||
|
buf.append(f"\033[{scr_row};1H{n}")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
|
||||||
|
return buf, noise_cache
|
||||||
|
|
||||||
|
|
||||||
|
def apply_glitch(
|
||||||
|
buf: list[str],
|
||||||
|
ticker_buf_start: int,
|
||||||
|
mic_excess: float,
|
||||||
|
w: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Apply glitch effect to ticker buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: current buffer
|
||||||
|
ticker_buf_start: index where ticker starts in buffer
|
||||||
|
mic_excess: mic level above threshold
|
||||||
|
w: terminal width
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated buffer with glitches applied
|
||||||
|
"""
|
||||||
|
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||||
|
n_hits = 4 + int(mic_excess / 2)
|
||||||
|
ticker_buf_len = len(buf) - ticker_buf_start
|
||||||
|
|
||||||
|
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||||
|
for _ in range(min(n_hits, ticker_buf_len)):
|
||||||
|
gi = random.randint(0, ticker_buf_len - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||||
|
"""Render firehose strip at bottom of screen."""
|
||||||
|
buf = []
|
||||||
|
if fh > 0:
|
||||||
|
for fr in range(fh):
|
||||||
|
scr_row = h - fh + fr + 1
|
||||||
|
fline = firehose_line(items, w)
|
||||||
|
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
_effect_chain = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_effects() -> None:
|
||||||
|
"""Initialize effect plugins and chain."""
|
||||||
|
global _effect_chain
|
||||||
|
from engine.effects import EffectChain, get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
import effects_plugins
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
_effect_chain = chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_effects(
|
||||||
|
buf: list[str],
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
mic_excess: float,
|
||||||
|
grad_offset: float,
|
||||||
|
frame_number: int,
|
||||||
|
has_message: bool,
|
||||||
|
items: list,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Process buffer through effect chain."""
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_h,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return _effect_chain.process(buf, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain() -> EffectChain | None:
|
||||||
|
"""Get the effect chain instance."""
|
||||||
|
global _effect_chain
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
return _effect_chain
|
||||||
96
engine/mic.py
Normal file
96
engine/mic.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Microphone input monitor — standalone, no internal dependencies.
|
||||||
|
Gracefully degrades if sounddevice/numpy are unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as _np
|
||||||
|
import sounddevice as _sd
|
||||||
|
|
||||||
|
_HAS_MIC = True
|
||||||
|
except Exception:
|
||||||
|
_HAS_MIC = False
|
||||||
|
|
||||||
|
|
||||||
|
from engine.events import MicLevelEvent
|
||||||
|
|
||||||
|
|
||||||
|
class MicMonitor:
|
||||||
|
"""Background mic stream that exposes current RMS dB level."""
|
||||||
|
|
||||||
|
def __init__(self, threshold_db=50):
|
||||||
|
self.threshold_db = threshold_db
|
||||||
|
self._db = -99.0
|
||||||
|
self._stream = None
|
||||||
|
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""True if sounddevice is importable."""
|
||||||
|
return _HAS_MIC
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Current RMS dB level."""
|
||||||
|
return self._db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excess(self):
|
||||||
|
"""dB above threshold (clamped to 0)."""
|
||||||
|
return max(0.0, self._db - self.threshold_db)
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||||
|
"""Register a callback to be called when mic level changes."""
|
||||||
|
self._subscribers.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||||
|
"""Remove a registered callback."""
|
||||||
|
if callback in self._subscribers:
|
||||||
|
self._subscribers.remove(callback)
|
||||||
|
|
||||||
|
def _emit(self, event: MicLevelEvent) -> None:
|
||||||
|
"""Emit an event to all subscribers."""
|
||||||
|
for cb in self._subscribers:
|
||||||
|
try:
|
||||||
|
cb(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||||
|
if not _HAS_MIC:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cb(indata, frames, t, status):
|
||||||
|
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||||
|
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||||
|
if self._subscribers:
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=self._db,
|
||||||
|
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
)
|
||||||
|
self._emit(event)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._stream = _sd.InputStream(
|
||||||
|
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
||||||
|
)
|
||||||
|
self._stream.start()
|
||||||
|
atexit.register(self.stop)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the mic stream if running."""
|
||||||
|
if self._stream:
|
||||||
|
try:
|
||||||
|
self._stream.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._stream = None
|
||||||
122
engine/ntfy.py
Normal file
122
engine/ntfy.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
ntfy.sh SSE stream listener — standalone, zero internal dependencies.
|
||||||
|
Reusable by any visualizer:
|
||||||
|
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||||
|
poller.start()
|
||||||
|
# in render loop:
|
||||||
|
msg = poller.get_active_message()
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
from engine.events import NtfyMessageEvent
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyPoller:
|
||||||
|
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||||
|
|
||||||
|
def __init__(self, topic_url, reconnect_delay=5, display_secs=30):
|
||||||
|
self.topic_url = topic_url
|
||||||
|
self.reconnect_delay = reconnect_delay
|
||||||
|
self.display_secs = display_secs
|
||||||
|
self._message = None # (title, body, monotonic_timestamp) or None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._subscribers: list[Callable[[NtfyMessageEvent], None]] = []
|
||||||
|
|
||||||
|
def subscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||||
|
"""Register a callback to be called when a message is received."""
|
||||||
|
self._subscribers.append(callback)
|
||||||
|
|
||||||
|
def unsubscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||||
|
"""Remove a registered callback."""
|
||||||
|
if callback in self._subscribers:
|
||||||
|
self._subscribers.remove(callback)
|
||||||
|
|
||||||
|
def _emit(self, event: NtfyMessageEvent) -> None:
|
||||||
|
"""Emit an event to all subscribers."""
|
||||||
|
for cb in self._subscribers:
|
||||||
|
try:
|
||||||
|
cb(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start background stream thread. Returns True."""
|
||||||
|
t = threading.Thread(target=self._stream_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_active_message(self):
|
||||||
|
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
|
||||||
|
with self._lock:
|
||||||
|
if self._message is None:
|
||||||
|
return None
|
||||||
|
title, body, ts = self._message
|
||||||
|
if time.monotonic() - ts < self.display_secs:
|
||||||
|
return self._message
|
||||||
|
self._message = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dismiss(self):
|
||||||
|
"""Manually dismiss the current message."""
|
||||||
|
with self._lock:
|
||||||
|
self._message = None
|
||||||
|
|
||||||
|
def _build_url(self, last_id=None):
|
||||||
|
"""Build the stream URL, substituting since= to avoid message replays on reconnect."""
|
||||||
|
parsed = urlparse(self.topic_url)
|
||||||
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
params["since"] = [last_id if last_id else "20s"]
|
||||||
|
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||||
|
return urlunparse(parsed._replace(query=new_query))
|
||||||
|
|
||||||
|
def _stream_loop(self):
|
||||||
|
last_id = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
url = self._build_url(last_id)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, headers={"User-Agent": "mainline/0.1"}
|
||||||
|
)
|
||||||
|
# timeout=90 keeps the socket alive through ntfy.sh keepalive heartbeats
|
||||||
|
resp = urllib.request.urlopen(req, timeout=90)
|
||||||
|
while True:
|
||||||
|
line = resp.readline()
|
||||||
|
if not line:
|
||||||
|
break # server closed connection — reconnect
|
||||||
|
try:
|
||||||
|
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
# Advance cursor on every event (message + keepalive) to
|
||||||
|
# avoid replaying already-seen events after a reconnect.
|
||||||
|
if "id" in data:
|
||||||
|
last_id = data["id"]
|
||||||
|
if data.get("event") == "message":
|
||||||
|
with self._lock:
|
||||||
|
self._message = (
|
||||||
|
data.get("title", ""),
|
||||||
|
data.get("message", ""),
|
||||||
|
time.monotonic(),
|
||||||
|
)
|
||||||
|
event = NtfyMessageEvent(
|
||||||
|
title=data.get("title", ""),
|
||||||
|
body=data.get("message", ""),
|
||||||
|
message_id=data.get("id"),
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
)
|
||||||
|
self._emit(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(self.reconnect_delay)
|
||||||
332
engine/render.py
Normal file
332
engine/render.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
OTF → terminal half-block rendering pipeline.
|
||||||
|
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
|
||||||
|
Depends on: config, terminal, sources, translate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||||
|
from engine.terminal import RST
|
||||||
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
|
def _color_codes_to_ansi(color_codes):
|
||||||
|
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||||
|
|
||||||
|
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color_codes: List of 12 integers (256-color palette codes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI escape code strings
|
||||||
|
"""
|
||||||
|
if not color_codes or len(color_codes) != 12:
|
||||||
|
# Fallback to default green if invalid
|
||||||
|
return _default_green_gradient()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, code in enumerate(color_codes):
|
||||||
|
if i < 2:
|
||||||
|
# Bold for first 2 (bright leading edge)
|
||||||
|
result.append(f"\033[1;38;5;{code}m")
|
||||||
|
elif i < 10:
|
||||||
|
# Normal for middle 8
|
||||||
|
result.append(f"\033[38;5;{code}m")
|
||||||
|
else:
|
||||||
|
# Dim for last 2 (dark trailing edge)
|
||||||
|
result.append(f"\033[2;38;5;{code}m")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
|
_FONT_OBJ = None
|
||||||
|
_FONT_OBJ_KEY = None
|
||||||
|
_FONT_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def font():
|
||||||
|
"""Lazy-load the primary OTF font (path + face index aware)."""
|
||||||
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
|
if not config.FONT_PATH:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
||||||
|
)
|
||||||
|
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
||||||
|
if _FONT_OBJ is None or key != _FONT_OBJ_KEY:
|
||||||
|
_FONT_OBJ = ImageFont.truetype(
|
||||||
|
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
||||||
|
)
|
||||||
|
_FONT_OBJ_KEY = key
|
||||||
|
return _FONT_OBJ
|
||||||
|
|
||||||
|
|
||||||
|
def clear_font_cache():
|
||||||
|
"""Reset cached font objects after changing primary font selection."""
|
||||||
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
|
_FONT_OBJ = None
|
||||||
|
_FONT_OBJ_KEY = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_font_face(font_path, font_index=0, size=None):
|
||||||
|
"""Load a specific face from a font file or collection."""
|
||||||
|
font_size = size or config.FONT_SZ
|
||||||
|
return ImageFont.truetype(font_path, font_size, index=font_index)
|
||||||
|
|
||||||
|
|
||||||
|
def list_font_faces(font_path, max_faces=64):
|
||||||
|
"""Return discoverable face indexes + display names from a font file."""
|
||||||
|
faces = []
|
||||||
|
for idx in range(max_faces):
|
||||||
|
try:
|
||||||
|
fnt = load_font_face(font_path, idx)
|
||||||
|
except Exception:
|
||||||
|
if idx == 0:
|
||||||
|
raise
|
||||||
|
break
|
||||||
|
family, style = fnt.getname()
|
||||||
|
display = f"{family} {style}".strip()
|
||||||
|
if not display:
|
||||||
|
display = f"{Path(font_path).stem} [{idx}]"
|
||||||
|
faces.append({"index": idx, "name": display})
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
|
def font_for_lang(lang=None):
|
||||||
|
"""Get appropriate font for a language."""
|
||||||
|
if lang is None or lang not in SCRIPT_FONTS:
|
||||||
|
return font()
|
||||||
|
if lang not in _FONT_CACHE:
|
||||||
|
try:
|
||||||
|
_FONT_CACHE[lang] = ImageFont.truetype(SCRIPT_FONTS[lang], config.FONT_SZ)
|
||||||
|
except Exception:
|
||||||
|
_FONT_CACHE[lang] = font()
|
||||||
|
return _FONT_CACHE[lang]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── RASTERIZATION ────────────────────────────────────────
|
||||||
|
def render_line(text, fnt=None):
|
||||||
|
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
||||||
|
if fnt is None:
|
||||||
|
fnt = font()
|
||||||
|
bbox = fnt.getbbox(text)
|
||||||
|
if not bbox or bbox[2] <= bbox[0]:
|
||||||
|
return [""]
|
||||||
|
pad = 4
|
||||||
|
img_w = bbox[2] - bbox[0] + pad * 2
|
||||||
|
img_h = bbox[3] - bbox[1] + pad * 2
|
||||||
|
img = Image.new("L", (img_w, img_h), 0)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
||||||
|
pix_h = config.RENDER_H * 2
|
||||||
|
hi_h = pix_h * config.SSAA
|
||||||
|
scale = hi_h / max(img_h, 1)
|
||||||
|
new_w_hi = max(1, int(img_w * scale))
|
||||||
|
img = img.resize((new_w_hi, hi_h), Image.Resampling.LANCZOS)
|
||||||
|
new_w = max(1, int(new_w_hi / config.SSAA))
|
||||||
|
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
|
||||||
|
data = img.tobytes()
|
||||||
|
thr = 80
|
||||||
|
rows = []
|
||||||
|
for y in range(0, pix_h, 2):
|
||||||
|
row = []
|
||||||
|
for x in range(new_w):
|
||||||
|
top = data[y * new_w + x] > thr
|
||||||
|
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
|
||||||
|
if top and bot:
|
||||||
|
row.append("█")
|
||||||
|
elif top:
|
||||||
|
row.append("▀")
|
||||||
|
elif bot:
|
||||||
|
row.append("▄")
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
rows.append("".join(row))
|
||||||
|
while rows and not rows[-1].strip():
|
||||||
|
rows.pop()
|
||||||
|
while rows and not rows[0].strip():
|
||||||
|
rows.pop(0)
|
||||||
|
return rows if rows else [""]
|
||||||
|
|
||||||
|
|
||||||
|
def big_wrap(text, max_w, fnt=None):
|
||||||
|
"""Word-wrap text and render with OTF font."""
|
||||||
|
if fnt is None:
|
||||||
|
fnt = font()
|
||||||
|
words = text.split()
|
||||||
|
lines, cur = [], ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip() if cur else word
|
||||||
|
bbox = fnt.getbbox(test)
|
||||||
|
if bbox:
|
||||||
|
img_h = bbox[3] - bbox[1] + 8
|
||||||
|
pix_h = config.RENDER_H * 2
|
||||||
|
scale = pix_h / max(img_h, 1)
|
||||||
|
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
||||||
|
else:
|
||||||
|
term_w = 0
|
||||||
|
if term_w > max_w - 4 and cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
out = []
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
out.extend(render_line(ln, fnt))
|
||||||
|
if i < len(lines) - 1:
|
||||||
|
out.append("")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lr_gradient(rows, offset=0.0, cols=None):
|
||||||
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
if config.ACTIVE_THEME:
|
||||||
|
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
||||||
|
else:
|
||||||
|
cols = _default_green_gradient()
|
||||||
|
n = len(cols)
|
||||||
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
if not row.strip():
|
||||||
|
out.append(row)
|
||||||
|
continue
|
||||||
|
buf = []
|
||||||
|
for x, ch in enumerate(row):
|
||||||
|
if ch == " ":
|
||||||
|
buf.append(" ")
|
||||||
|
else:
|
||||||
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||||
|
idx = min(round(shifted * (n - 1)), n - 1)
|
||||||
|
buf.append(f"{cols[idx]}{ch}{RST}")
|
||||||
|
out.append("".join(buf))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
|
return lr_gradient(rows, offset, _default_magenta_gradient())
|
||||||
|
|
||||||
|
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||||
|
|
||||||
|
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||||
|
falling back to default magenta if no theme is set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text strings to colorize
|
||||||
|
offset: Gradient offset (0.0-1.0) for animation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows with ANSI color codes applied
|
||||||
|
"""
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
cols = (
|
||||||
|
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
def make_block(title, src, ts, w):
|
||||||
|
"""Render a headline into a content block with color."""
|
||||||
|
target_lang = (
|
||||||
|
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
||||||
|
if config.MODE == "news"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
lang_font = font_for_lang(target_lang)
|
||||||
|
if target_lang:
|
||||||
|
title = translate_headline(title, target_lang)
|
||||||
|
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
|
||||||
|
if target_lang and target_lang in NO_UPPER:
|
||||||
|
title_up = re.sub(r"\s+", " ", title)
|
||||||
|
else:
|
||||||
|
title_up = re.sub(r"\s+", " ", title.upper())
|
||||||
|
for old, new in [
|
||||||
|
("\u2019", "'"),
|
||||||
|
("\u2018", "'"),
|
||||||
|
("\u201c", '"'),
|
||||||
|
("\u201d", '"'),
|
||||||
|
("\u2013", "-"),
|
||||||
|
("\u2014", "-"),
|
||||||
|
]:
|
||||||
|
title_up = title_up.replace(old, new)
|
||||||
|
big_rows = big_wrap(title_up, w - 4, lang_font)
|
||||||
|
hc = random.choice(
|
||||||
|
[
|
||||||
|
"\033[38;5;46m", # matrix green
|
||||||
|
"\033[38;5;34m", # dark green
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;48m", # sea green
|
||||||
|
"\033[38;5;37m", # teal
|
||||||
|
"\033[38;5;44m", # cyan
|
||||||
|
"\033[38;5;87m", # sky
|
||||||
|
"\033[38;5;117m", # ice blue
|
||||||
|
"\033[38;5;250m", # cool white
|
||||||
|
"\033[38;5;156m", # pale green
|
||||||
|
"\033[38;5;120m", # mint
|
||||||
|
"\033[38;5;80m", # dark cyan
|
||||||
|
"\033[38;5;108m", # grey-green
|
||||||
|
"\033[38;5;115m", # sage
|
||||||
|
"\033[1;38;5;46m", # bold green
|
||||||
|
"\033[1;38;5;250m", # bold white
|
||||||
|
]
|
||||||
|
)
|
||||||
|
content = [" " + r for r in big_rows]
|
||||||
|
content.append("")
|
||||||
|
meta = f"\u2591 {src} \u00b7 {ts}"
|
||||||
|
content.append(" " * max(2, w - len(meta) - 2) + meta)
|
||||||
|
return content, hc, len(content) - 1 # (rows, color, meta_row_index)
|
||||||
141
engine/scroll.py
Normal file
141
engine/scroll.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||||
|
Orchestrates viewport, frame timing, and layers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.display import (
|
||||||
|
Display,
|
||||||
|
TerminalDisplay,
|
||||||
|
)
|
||||||
|
from engine.display import (
|
||||||
|
get_monitor as _get_display_monitor,
|
||||||
|
)
|
||||||
|
from engine.frame import calculate_scroll_step
|
||||||
|
from engine.layers import (
|
||||||
|
apply_glitch,
|
||||||
|
process_effects,
|
||||||
|
render_firehose,
|
||||||
|
render_message_overlay,
|
||||||
|
render_ticker_zone,
|
||||||
|
)
|
||||||
|
from engine.viewport import th, tw
|
||||||
|
|
||||||
|
USE_EFFECT_CHAIN = True
|
||||||
|
|
||||||
|
|
||||||
|
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||||
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
|
if display is None:
|
||||||
|
display = TerminalDisplay()
|
||||||
|
random.shuffle(items)
|
||||||
|
pool = list(items)
|
||||||
|
seen = set()
|
||||||
|
queued = 0
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
w, h = tw(), th()
|
||||||
|
display.init(w, h)
|
||||||
|
display.clear()
|
||||||
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
ticker_view_h = h - fh
|
||||||
|
GAP = 3
|
||||||
|
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||||
|
|
||||||
|
active = []
|
||||||
|
scroll_cam = 0
|
||||||
|
ticker_next_y = ticker_view_h
|
||||||
|
noise_cache = {}
|
||||||
|
scroll_motion_accum = 0.0
|
||||||
|
msg_cache = (None, None)
|
||||||
|
frame_number = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if queued >= config.HEADLINE_LIMIT and not active:
|
||||||
|
break
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
w, h = tw(), th()
|
||||||
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
ticker_view_h = h - fh
|
||||||
|
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||||
|
|
||||||
|
msg = ntfy_poller.get_active_message()
|
||||||
|
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||||
|
|
||||||
|
buf = []
|
||||||
|
ticker_h = ticker_view_h
|
||||||
|
|
||||||
|
scroll_motion_accum += config.FRAME_DT
|
||||||
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
|
scroll_motion_accum -= scroll_step_interval
|
||||||
|
scroll_cam += 1
|
||||||
|
|
||||||
|
while (
|
||||||
|
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||||
|
and queued < config.HEADLINE_LIMIT
|
||||||
|
):
|
||||||
|
from engine.effects import next_headline
|
||||||
|
from engine.render import make_block
|
||||||
|
|
||||||
|
t, src, ts = next_headline(pool, items, seen)
|
||||||
|
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||||
|
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||||
|
ticker_next_y += len(ticker_content) + GAP
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
active = [
|
||||||
|
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||||
|
]
|
||||||
|
for k in list(noise_cache):
|
||||||
|
if k < scroll_cam:
|
||||||
|
del noise_cache[k]
|
||||||
|
|
||||||
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
|
ticker_buf_start = len(buf)
|
||||||
|
|
||||||
|
ticker_buf, noise_cache = render_ticker_zone(
|
||||||
|
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||||
|
)
|
||||||
|
buf.extend(ticker_buf)
|
||||||
|
|
||||||
|
mic_excess = mic_monitor.excess
|
||||||
|
render_start = time.perf_counter()
|
||||||
|
|
||||||
|
if USE_EFFECT_CHAIN:
|
||||||
|
buf = process_effects(
|
||||||
|
buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
scroll_cam,
|
||||||
|
ticker_h,
|
||||||
|
mic_excess,
|
||||||
|
grad_offset,
|
||||||
|
frame_number,
|
||||||
|
msg is not None,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||||
|
firehose_buf = render_firehose(items, w, fh, h)
|
||||||
|
buf.extend(firehose_buf)
|
||||||
|
|
||||||
|
if msg_overlay:
|
||||||
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
|
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||||
|
monitor = _get_display_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars = sum(len(line) for line in buf)
|
||||||
|
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||||
|
|
||||||
|
display.show(buf)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
|
frame_number += 1
|
||||||
|
|
||||||
|
display.cleanup()
|
||||||
115
engine/sources.py
Normal file
115
engine/sources.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Data sources: feed URLs, poetry sources, language mappings, script fonts.
|
||||||
|
Pure data — no logic, no dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ─── RSS FEEDS ────────────────────────────────────────────
|
||||||
|
FEEDS = {
|
||||||
|
# Science & Technology
|
||||||
|
"Nature": "https://www.nature.com/nature.rss",
|
||||||
|
"Science Daily": "https://www.sciencedaily.com/rss/all.xml",
|
||||||
|
"Phys.org": "https://phys.org/rss-feed/",
|
||||||
|
"NASA": "https://www.nasa.gov/news-release/feed/",
|
||||||
|
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
|
||||||
|
"New Scientist": "https://www.newscientist.com/section/news/feed/",
|
||||||
|
"Quanta": "https://api.quantamagazine.org/feed/",
|
||||||
|
"BBC Science": "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
|
||||||
|
"MIT Tech Review": "https://www.technologyreview.com/feed/",
|
||||||
|
# Economics & Business
|
||||||
|
"BBC Business": "http://feeds.bbci.co.uk/news/business/rss.xml",
|
||||||
|
"MarketWatch": "https://feeds.marketwatch.com/marketwatch/topstories/",
|
||||||
|
"Economist": "https://www.economist.com/finance-and-economics/rss.xml",
|
||||||
|
# World & Politics
|
||||||
|
"BBC World": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||||
|
"NPR": "https://feeds.npr.org/1001/rss.xml",
|
||||||
|
"Al Jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||||
|
"Guardian World": "https://www.theguardian.com/world/rss",
|
||||||
|
"DW": "https://rss.dw.com/rdf/rss-en-all",
|
||||||
|
"France24": "https://www.france24.com/en/rss",
|
||||||
|
"ABC Australia": "https://www.abc.net.au/news/feed/2942460/rss.xml",
|
||||||
|
"Japan Times": "https://www.japantimes.co.jp/feed/",
|
||||||
|
"The Hindu": "https://www.thehindu.com/news/national/feeder/default.rss",
|
||||||
|
"SCMP": "https://www.scmp.com/rss/91/feed",
|
||||||
|
"Der Spiegel": "https://www.spiegel.de/international/index.rss",
|
||||||
|
# Culture & Ideas
|
||||||
|
"Guardian Culture": "https://www.theguardian.com/culture/rss",
|
||||||
|
"Aeon": "https://aeon.co/feed.rss",
|
||||||
|
"Smithsonian": "https://www.smithsonianmag.com/rss/latest_articles/",
|
||||||
|
"The Marginalian": "https://www.themarginalian.org/feed/",
|
||||||
|
"Nautilus": "https://nautil.us/feed/",
|
||||||
|
"Wired": "https://www.wired.com/feed/rss",
|
||||||
|
"The Conversation": "https://theconversation.com/us/articles.atom",
|
||||||
|
"Longreads": "https://longreads.com/feed/",
|
||||||
|
"Literary Hub": "https://lithub.com/feed/",
|
||||||
|
"Atlas Obscura": "https://www.atlasobscura.com/feeds/latest",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── POETRY / LITERATURE ─────────────────────────────────
|
||||||
|
# Public domain via Project Gutenberg
|
||||||
|
POETRY_SOURCES = {
|
||||||
|
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
||||||
|
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
|
||||||
|
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
|
||||||
|
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
|
||||||
|
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
|
||||||
|
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
|
||||||
|
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
|
||||||
|
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
|
||||||
|
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
|
||||||
|
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
|
||||||
|
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
|
||||||
|
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
|
||||||
|
# Headlines from these outlets render in their cultural home language
|
||||||
|
SOURCE_LANGS = {
|
||||||
|
"Der Spiegel": "de",
|
||||||
|
"DW": "de",
|
||||||
|
"France24": "fr",
|
||||||
|
"Japan Times": "ja",
|
||||||
|
"The Hindu": "hi",
|
||||||
|
"SCMP": "zh-cn",
|
||||||
|
"Al Jazeera": "ar",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── LOCATION → LANGUAGE ─────────────────────────────────
|
||||||
|
LOCATION_LANGS = {
|
||||||
|
r"\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b": "zh-cn",
|
||||||
|
r"\b(?:japan|japanese|tokyo|osaka|kishida)\b": "ja",
|
||||||
|
r"\b(?:korea|korean|seoul|pyongyang)\b": "ko",
|
||||||
|
r"\b(?:russia|russian|moscow|kremlin|putin)\b": "ru",
|
||||||
|
r"\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b": "ar",
|
||||||
|
r"\b(?:india|indian|delhi|mumbai|modi)\b": "hi",
|
||||||
|
r"\b(?:germany|german|berlin|munich|scholz)\b": "de",
|
||||||
|
r"\b(?:france|french|paris|lyon|macron)\b": "fr",
|
||||||
|
r"\b(?:spain|spanish|madrid)\b": "es",
|
||||||
|
r"\b(?:italy|italian|rome|milan|meloni)\b": "it",
|
||||||
|
r"\b(?:portugal|portuguese|lisbon)\b": "pt",
|
||||||
|
r"\b(?:brazil|brazilian|são paulo|lula)\b": "pt",
|
||||||
|
r"\b(?:greece|greek|athens)\b": "el",
|
||||||
|
r"\b(?:turkey|turkish|istanbul|ankara|erdogan)\b": "tr",
|
||||||
|
r"\b(?:iran|iranian|tehran)\b": "fa",
|
||||||
|
r"\b(?:thailand|thai|bangkok)\b": "th",
|
||||||
|
r"\b(?:vietnam|vietnamese|hanoi)\b": "vi",
|
||||||
|
r"\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b": "uk",
|
||||||
|
r"\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b": "he",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
|
||||||
|
SCRIPT_FONTS = {
|
||||||
|
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||||
|
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||||
|
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||||
|
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||||
|
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||||
|
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||||
|
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scripts that have no uppercase
|
||||||
|
NO_UPPER = {"zh-cn", "ja", "ko", "ar", "fa", "hi", "th", "he"}
|
||||||
78
engine/terminal.py
Normal file
78
engine/terminal.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
ANSI escape codes, terminal size helpers, and text output primitives.
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ─── ANSI ─────────────────────────────────────────────────
|
||||||
|
RST = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
# Matrix greens
|
||||||
|
G_HI = "\033[38;5;46m"
|
||||||
|
G_MID = "\033[38;5;34m"
|
||||||
|
G_LO = "\033[38;5;22m"
|
||||||
|
G_DIM = "\033[2;38;5;34m"
|
||||||
|
# THX-1138 sterile tones
|
||||||
|
W_COOL = "\033[38;5;250m"
|
||||||
|
W_DIM = "\033[2;38;5;245m"
|
||||||
|
W_GHOST = "\033[2;38;5;238m"
|
||||||
|
C_DIM = "\033[2;38;5;37m"
|
||||||
|
# Terminal control
|
||||||
|
CLR = "\033[2J\033[H"
|
||||||
|
CURSOR_OFF = "\033[?25l"
|
||||||
|
CURSOR_ON = "\033[?25h"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── TERMINAL SIZE ────────────────────────────────────────
|
||||||
|
def tw():
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().columns
|
||||||
|
except Exception:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
|
||||||
|
def th():
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
return 24
|
||||||
|
|
||||||
|
|
||||||
|
# ─── TEXT OUTPUT ──────────────────────────────────────────
|
||||||
|
def type_out(text, color=G_HI):
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
if random.random() < 0.3:
|
||||||
|
b = random.randint(2, 5)
|
||||||
|
sys.stdout.write(f"{color}{text[i : i + b]}{RST}")
|
||||||
|
i += b
|
||||||
|
else:
|
||||||
|
sys.stdout.write(f"{color}{text[i]}{RST}")
|
||||||
|
i += 1
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(random.uniform(0.004, 0.018))
|
||||||
|
|
||||||
|
|
||||||
|
def slow_print(text, color=G_DIM, delay=0.015):
|
||||||
|
for ch in text:
|
||||||
|
sys.stdout.write(f"{color}{ch}{RST}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
|
||||||
|
def boot_ln(label, status, ok=True):
|
||||||
|
dots = max(3, min(30, tw() - len(label) - len(status) - 8))
|
||||||
|
sys.stdout.write(f" {G_DIM}>{RST} {W_DIM}{label} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
for _ in range(dots):
|
||||||
|
sys.stdout.write(f"{G_LO}.")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(random.uniform(0.006, 0.025))
|
||||||
|
c = G_MID if ok else "\033[2;38;5;196m"
|
||||||
|
print(f" {c}{status}{RST}")
|
||||||
|
time.sleep(random.uniform(0.02, 0.1))
|
||||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Theme definitions with color gradients for terminal rendering.
|
||||||
|
|
||||||
|
This module is data-only and does not import config or render
|
||||||
|
to prevent circular dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Represents a color theme with two gradients."""
|
||||||
|
|
||||||
|
def __init__(self, name, main_gradient, message_gradient):
|
||||||
|
"""Initialize a theme with name and color gradients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Theme identifier string
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||||
|
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||||
|
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||||
|
|
||||||
|
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||||
|
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||||
|
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id):
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object matching the ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in registry
|
||||||
|
"""
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
46
engine/translate.py
Normal file
46
engine/translate.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Google Translate wrapper and location→language detection.
|
||||||
|
Depends on: sources (for LOCATION_LANGS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from engine.sources import LOCATION_LANGS
|
||||||
|
|
||||||
|
TRANSLATE_CACHE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
||||||
|
def _translate_cached(title: str, target_lang: str) -> str:
|
||||||
|
"""Cached translation implementation."""
|
||||||
|
try:
|
||||||
|
q = urllib.parse.quote(title)
|
||||||
|
url = (
|
||||||
|
"https://translate.googleapis.com/translate_a/single"
|
||||||
|
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||||
|
except Exception:
|
||||||
|
result = title
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def detect_location_language(title):
|
||||||
|
"""Detect if headline mentions a location, return target language."""
|
||||||
|
title_lower = title.lower()
|
||||||
|
for pattern, lang in LOCATION_LANGS.items():
|
||||||
|
if re.search(pattern, title_lower):
|
||||||
|
return lang
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def translate_headline(title: str, target_lang: str) -> str:
|
||||||
|
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||||
|
return _translate_cached(title, target_lang)
|
||||||
60
engine/types.py
Normal file
60
engine/types.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Shared dataclasses for the mainline application.
|
||||||
|
Provides named types for tuple returns across modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadlineItem:
|
||||||
|
"""A single headline item: title, source, and timestamp."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
def to_tuple(self) -> tuple[str, str, str]:
|
||||||
|
"""Convert to tuple for backward compatibility."""
|
||||||
|
return (self.title, self.source, self.timestamp)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tuple(cls, t: tuple[str, str, str]) -> "HeadlineItem":
|
||||||
|
"""Create from tuple for backward compatibility."""
|
||||||
|
return cls(title=t[0], source=t[1], timestamp=t[2])
|
||||||
|
|
||||||
|
|
||||||
|
def items_to_tuples(items: list[HeadlineItem]) -> list[tuple[str, str, str]]:
|
||||||
|
"""Convert list of HeadlineItem to list of tuples."""
|
||||||
|
return [item.to_tuple() for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
def tuples_to_items(tuples: list[tuple[str, str, str]]) -> list[HeadlineItem]:
|
||||||
|
"""Convert list of tuples to list of HeadlineItem."""
|
||||||
|
return [HeadlineItem.from_tuple(t) for t in tuples]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FetchResult:
|
||||||
|
"""Result from fetch_all() or fetch_poetry()."""
|
||||||
|
|
||||||
|
items: list[HeadlineItem]
|
||||||
|
linked: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
def to_legacy_tuple(self) -> tuple[list[tuple], int, int]:
|
||||||
|
"""Convert to legacy tuple format for backward compatibility."""
|
||||||
|
return ([item.to_tuple() for item in self.items], self.linked, self.failed)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Block:
|
||||||
|
"""Rendered headline block from make_block()."""
|
||||||
|
|
||||||
|
content: list[str]
|
||||||
|
color: str
|
||||||
|
meta_row_index: int
|
||||||
|
|
||||||
|
def to_legacy_tuple(self) -> tuple[list[str], str, int]:
|
||||||
|
"""Convert to legacy tuple format for backward compatibility."""
|
||||||
|
return (self.content, self.color, self.meta_row_index)
|
||||||
37
engine/viewport.py
Normal file
37
engine/viewport.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Viewport utilities — terminal dimensions and ANSI positioning helpers.
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def tw() -> int:
|
||||||
|
"""Get terminal width (columns)."""
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().columns
|
||||||
|
except Exception:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
|
||||||
|
def th() -> int:
|
||||||
|
"""Get terminal height (lines)."""
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
return 24
|
||||||
|
|
||||||
|
|
||||||
|
def move_to(row: int, col: int = 1) -> str:
|
||||||
|
"""Generate ANSI escape to move cursor to row, col (1-indexed)."""
|
||||||
|
return f"\033[{row};{col}H"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_screen() -> str:
|
||||||
|
"""Clear screen and move cursor to home."""
|
||||||
|
return "\033[2J\033[H"
|
||||||
|
|
||||||
|
|
||||||
|
def clear_line() -> str:
|
||||||
|
"""Clear current line."""
|
||||||
|
return "\033[K"
|
||||||
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/AlphatronDemo-Display.otf
Normal file
BIN
fonts/AlphatronDemo-Display.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/Corptic DEMO.otf
Normal file
BIN
fonts/Corptic DEMO.otf
Normal file
Binary file not shown.
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo.otf
Normal file
BIN
fonts/CyberformDemo.otf
Normal file
Binary file not shown.
BIN
fonts/Eyekons.otf
Normal file
BIN
fonts/Eyekons.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.otf
Normal file
BIN
fonts/KATA Mac.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.ttf
Normal file
BIN
fonts/KATA Mac.ttf
Normal file
Binary file not shown.
BIN
fonts/KATA.otf
Normal file
BIN
fonts/KATA.otf
Normal file
Binary file not shown.
BIN
fonts/KATA.ttf
Normal file
BIN
fonts/KATA.ttf
Normal file
Binary file not shown.
BIN
fonts/Kapiler.otf
Normal file
BIN
fonts/Kapiler.otf
Normal file
Binary file not shown.
BIN
fonts/Kapiler.ttf
Normal file
BIN
fonts/Kapiler.ttf
Normal file
Binary file not shown.
BIN
fonts/Microbots Demo.otf
Normal file
BIN
fonts/Microbots Demo.otf
Normal file
Binary file not shown.
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Neoform-Demo.otf
Normal file
BIN
fonts/Neoform-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Pixel Sparta.otf
Normal file
BIN
fonts/Pixel Sparta.otf
Normal file
Binary file not shown.
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Resond-Regular.otf
Normal file
BIN
fonts/Resond-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Robocops-Demo.otf
Normal file
BIN
fonts/Robocops-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Synthetix.otf
Normal file
BIN
fonts/Synthetix.otf
Normal file
Binary file not shown.
BIN
fonts/Xeonic.ttf
Normal file
BIN
fonts/Xeonic.ttf
Normal file
Binary file not shown.
27
hk.pkl
Normal file
27
hk.pkl
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
amends "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Config.pkl"
|
||||||
|
import "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Builtins.pkl"
|
||||||
|
|
||||||
|
hooks {
|
||||||
|
["pre-commit"] {
|
||||||
|
fix = true
|
||||||
|
stash = "git"
|
||||||
|
steps {
|
||||||
|
["ruff-format"] = (Builtins.ruff_format) {
|
||||||
|
prefix = "uv run"
|
||||||
|
}
|
||||||
|
["ruff"] = (Builtins.ruff) {
|
||||||
|
prefix = "uv run"
|
||||||
|
check = "ruff check engine/ tests/"
|
||||||
|
fix = "ruff check --fix --unsafe-fixes engine/ tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["pre-push"] {
|
||||||
|
steps {
|
||||||
|
["ruff"] = (Builtins.ruff) {
|
||||||
|
prefix = "uv run"
|
||||||
|
check = "ruff check engine/ tests/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1075
mainline.py
1075
mainline.py
File diff suppressed because it is too large
Load Diff
52
mise.toml
Normal file
52
mise.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[tools]
|
||||||
|
python = "3.12"
|
||||||
|
hk = "latest"
|
||||||
|
pkl = "latest"
|
||||||
|
|
||||||
|
[tasks]
|
||||||
|
# =====================
|
||||||
|
# Development
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
test = "uv run pytest"
|
||||||
|
test-v = "uv run pytest -v"
|
||||||
|
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||||
|
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||||
|
|
||||||
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Runtime
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
run = "uv run mainline.py"
|
||||||
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Environment
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
sync = "uv sync"
|
||||||
|
sync-all = "uv sync --all-extras"
|
||||||
|
install = "uv sync"
|
||||||
|
install-dev = "uv sync --group dev"
|
||||||
|
|
||||||
|
bootstrap = "uv sync && uv run mainline.py --help"
|
||||||
|
|
||||||
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# CI/CD
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||||
|
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Git Hooks (via hk)
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
pre-commit = "hk run pre-commit"
|
||||||
89
pyproject.toml
Normal file
89
pyproject.toml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
[project]
|
||||||
|
name = "mainline"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Terminal news ticker with Matrix aesthetic"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{ name = "Mainline", email = "mainline@example.com" }
|
||||||
|
]
|
||||||
|
license = { text = "MIT" }
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Environment :: Console",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Terminals",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"feedparser>=6.0.0",
|
||||||
|
"Pillow>=10.0.0",
|
||||||
|
"pyright>=1.1.408",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
mic = [
|
||||||
|
"sounddevice>=0.4.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mainline = "engine.app:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = [
|
||||||
|
"--strict-markers",
|
||||||
|
"--tb=short",
|
||||||
|
"-v",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["engine"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
"if __name__ == .__main__.:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"@abstractmethod",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
|
||||||
|
ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pytest>=8.0.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
pytest-mock>=3.12.0
|
||||||
|
ruff>=0.1.0
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
feedparser>=6.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
sounddevice>=0.4.0
|
||||||
|
numpy>=1.24.0
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
236
tests/fixtures/__init__.py
vendored
Normal file
236
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""
|
||||||
|
Pytest fixtures for mocking external dependencies (network, filesystem).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feed_response():
|
||||||
|
"""Mock RSS feed response data."""
|
||||||
|
return b"""<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Test Feed</title>
|
||||||
|
<link>https://example.com</link>
|
||||||
|
<item>
|
||||||
|
<title>Test Headline One</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 12:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Test Headline Two</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 11:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Sports: Team Wins Championship</title>
|
||||||
|
<pubDate>Sat, 15 Mar 2025 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_gutenberg_response():
|
||||||
|
"""Mock Project Gutenberg text response."""
|
||||||
|
return """Project Gutenberg's Collection, by Various
|
||||||
|
|
||||||
|
*** START OF SOME TEXT ***
|
||||||
|
This is a test poem with multiple lines
|
||||||
|
that should be parsed as stanzas.
|
||||||
|
|
||||||
|
Another stanza here with different content
|
||||||
|
and more lines to test the parsing logic.
|
||||||
|
|
||||||
|
Yet another stanza for variety
|
||||||
|
in the test data.
|
||||||
|
|
||||||
|
*** END OF SOME TEXT ***"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_gutenberg_empty():
|
||||||
|
"""Mock Gutenberg response with no valid stanzas."""
|
||||||
|
return """Project Gutenberg's Collection
|
||||||
|
|
||||||
|
*** START OF TEXT ***
|
||||||
|
THIS IS ALL CAPS AND SHOULD BE SKIPPED
|
||||||
|
|
||||||
|
I.
|
||||||
|
|
||||||
|
*** END OF TEXT ***"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ntfy_message():
|
||||||
|
"""Mock ntfy.sh SSE message."""
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"id": "test123",
|
||||||
|
"event": "message",
|
||||||
|
"title": "Test Title",
|
||||||
|
"message": "Test message body",
|
||||||
|
"time": 1234567890,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ntfy_keepalive():
|
||||||
|
"""Mock ntfy.sh keepalive message."""
|
||||||
|
return b'data: {"event":"keepalive"}\n\n'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_google_translate_response():
|
||||||
|
"""Mock Google Translate API response."""
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
[["Translated text", "Original text", None, 0.8], None, "en"],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_feedparser():
|
||||||
|
"""Create a mock feedparser.parse function."""
|
||||||
|
|
||||||
|
def _mock(data):
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.bozo = False
|
||||||
|
mock_result.entries = [
|
||||||
|
{
|
||||||
|
"title": "Test Headline",
|
||||||
|
"published_parsed": (2025, 3, 15, 12, 0, 0, 0, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Another Headline",
|
||||||
|
"updated_parsed": (2025, 3, 15, 11, 0, 0, 0, 0, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return mock_result
|
||||||
|
|
||||||
|
return _mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_urllib_open(mock_feed_response):
|
||||||
|
"""Create a mock urllib.request.urlopen that returns feed data."""
|
||||||
|
|
||||||
|
def _mock(url):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = mock_feed_response
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
return _mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_items():
|
||||||
|
"""Sample items as returned by fetch module (title, source, timestamp)."""
|
||||||
|
return [
|
||||||
|
("Headline One", "Test Source", "12:00"),
|
||||||
|
("Headline Two", "Another Source", "11:30"),
|
||||||
|
("Headline Three", "Third Source", "10:45"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_config():
|
||||||
|
"""Sample config for testing."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="news",
|
||||||
|
firehose=False,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def poetry_config():
|
||||||
|
"""Sample config for poetry mode."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="poetry",
|
||||||
|
firehose=False,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def firehose_config():
|
||||||
|
"""Sample config with firehose enabled."""
|
||||||
|
from engine.config import Config
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
headline_limit=100,
|
||||||
|
feed_timeout=10,
|
||||||
|
mic_threshold_db=50,
|
||||||
|
mode="news",
|
||||||
|
firehose=True,
|
||||||
|
ntfy_topic="https://ntfy.sh/test/json",
|
||||||
|
ntfy_reconnect_delay=5,
|
||||||
|
message_display_secs=30,
|
||||||
|
font_dir="fonts",
|
||||||
|
font_path="",
|
||||||
|
font_index=0,
|
||||||
|
font_picker=False,
|
||||||
|
font_sz=60,
|
||||||
|
render_h=8,
|
||||||
|
ssaa=4,
|
||||||
|
scroll_dur=5.625,
|
||||||
|
frame_dt=0.05,
|
||||||
|
firehose_h=12,
|
||||||
|
grad_speed=0.08,
|
||||||
|
glitch_glyphs="░▒▓█▌▐",
|
||||||
|
kata_glyphs="ハミヒーウ",
|
||||||
|
script_fonts={},
|
||||||
|
)
|
||||||
301
tests/test_config.py
Normal file
301
tests/test_config.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.config module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
|
||||||
|
class TestArgValue:
|
||||||
|
"""Tests for _arg_value helper."""
|
||||||
|
|
||||||
|
def test_returns_value_when_flag_present(self):
|
||||||
|
"""Returns the value following the flag."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-file", "test.otf"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result == "test.otf"
|
||||||
|
|
||||||
|
def test_returns_none_when_flag_missing(self):
|
||||||
|
"""Returns None when flag is not present."""
|
||||||
|
with patch.object(sys, "argv", ["prog"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_when_no_value(self):
|
||||||
|
"""Returns None when flag is last."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-file"]):
|
||||||
|
result = config._arg_value("--font-file")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestArgInt:
|
||||||
|
"""Tests for _arg_int helper."""
|
||||||
|
|
||||||
|
def test_parses_valid_int(self):
|
||||||
|
"""Parses valid integer."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-index", "5"]):
|
||||||
|
result = config._arg_int("--font-index", 0)
|
||||||
|
assert result == 5
|
||||||
|
|
||||||
|
def test_returns_default_on_invalid(self):
|
||||||
|
"""Returns default on invalid input."""
|
||||||
|
with patch.object(sys, "argv", ["prog", "--font-index", "abc"]):
|
||||||
|
result = config._arg_int("--font-index", 0)
|
||||||
|
assert result == 0
|
||||||
|
|
||||||
|
def test_returns_default_when_missing(self):
|
||||||
|
"""Returns default when flag missing."""
|
||||||
|
with patch.object(sys, "argv", ["prog"]):
|
||||||
|
result = config._arg_int("--font-index", 10)
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveFontPath:
|
||||||
|
"""Tests for _resolve_font_path helper."""
|
||||||
|
|
||||||
|
def test_returns_absolute_paths(self):
|
||||||
|
"""Absolute paths are returned as-is."""
|
||||||
|
result = config._resolve_font_path("/absolute/path.otf")
|
||||||
|
assert result == "/absolute/path.otf"
|
||||||
|
|
||||||
|
def test_resolves_relative_paths(self):
|
||||||
|
"""Relative paths are resolved to repo root."""
|
||||||
|
result = config._resolve_font_path("fonts/test.otf")
|
||||||
|
assert str(config._REPO_ROOT) in result
|
||||||
|
|
||||||
|
def test_expands_user_home(self):
|
||||||
|
"""Tilde paths are expanded."""
|
||||||
|
with patch("pathlib.Path.expanduser", return_value=Path("/home/user/fonts")):
|
||||||
|
result = config._resolve_font_path("~/fonts/test.otf")
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListFontFiles:
|
||||||
|
"""Tests for _list_font_files helper."""
|
||||||
|
|
||||||
|
def test_returns_empty_for_missing_dir(self):
|
||||||
|
"""Returns empty list for missing directory."""
|
||||||
|
result = config._list_font_files("/nonexistent/directory")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_filters_by_extension(self):
|
||||||
|
"""Only returns valid font extensions."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
Path(tmpdir, "valid.otf").touch()
|
||||||
|
Path(tmpdir, "valid.ttf").touch()
|
||||||
|
Path(tmpdir, "invalid.txt").touch()
|
||||||
|
Path(tmpdir, "image.png").touch()
|
||||||
|
|
||||||
|
result = config._list_font_files(tmpdir)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert all(f.endswith((".otf", ".ttf")) for f in result)
|
||||||
|
|
||||||
|
def test_sorts_alphabetically(self):
|
||||||
|
"""Results are sorted alphabetically."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
Path(tmpdir, "zfont.otf").touch()
|
||||||
|
Path(tmpdir, "afont.otf").touch()
|
||||||
|
|
||||||
|
result = config._list_font_files(tmpdir)
|
||||||
|
filenames = [Path(f).name for f in result]
|
||||||
|
assert filenames == ["afont.otf", "zfont.otf"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""Tests for default configuration values."""
|
||||||
|
|
||||||
|
def test_headline_limit(self):
|
||||||
|
"""HEADLINE_LIMIT has sensible default."""
|
||||||
|
assert config.HEADLINE_LIMIT > 0
|
||||||
|
|
||||||
|
def test_feed_timeout(self):
|
||||||
|
"""FEED_TIMEOUT has sensible default."""
|
||||||
|
assert config.FEED_TIMEOUT > 0
|
||||||
|
|
||||||
|
def test_font_extensions(self):
|
||||||
|
"""Font extensions are defined."""
|
||||||
|
assert ".otf" in config._FONT_EXTENSIONS
|
||||||
|
assert ".ttf" in config._FONT_EXTENSIONS
|
||||||
|
assert ".ttc" in config._FONT_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlyphs:
|
||||||
|
"""Tests for glyph constants."""
|
||||||
|
|
||||||
|
def test_glitch_glyphs_defined(self):
|
||||||
|
"""GLITCH glyphs are defined."""
|
||||||
|
assert len(config.GLITCH) > 0
|
||||||
|
|
||||||
|
def test_kata_glyphs_defined(self):
|
||||||
|
"""KATA glyphs are defined."""
|
||||||
|
assert len(config.KATA) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFontSelection:
|
||||||
|
"""Tests for set_font_selection function."""
|
||||||
|
|
||||||
|
def test_updates_font_path(self):
|
||||||
|
"""Updates FONT_PATH globally."""
|
||||||
|
original = config.FONT_PATH
|
||||||
|
config.set_font_selection(font_path="/new/path.otf")
|
||||||
|
assert config.FONT_PATH == "/new/path.otf"
|
||||||
|
config.FONT_PATH = original
|
||||||
|
|
||||||
|
def test_updates_font_index(self):
|
||||||
|
"""Updates FONT_INDEX globally."""
|
||||||
|
original = config.FONT_INDEX
|
||||||
|
config.set_font_selection(font_index=5)
|
||||||
|
assert config.FONT_INDEX == 5
|
||||||
|
config.FONT_INDEX = original
|
||||||
|
|
||||||
|
def test_handles_none_values(self):
|
||||||
|
"""Handles None values gracefully."""
|
||||||
|
original_path = config.FONT_PATH
|
||||||
|
original_index = config.FONT_INDEX
|
||||||
|
|
||||||
|
config.set_font_selection(font_path=None, font_index=None)
|
||||||
|
assert original_path == config.FONT_PATH
|
||||||
|
assert original_index == config.FONT_INDEX
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDataclass:
|
||||||
|
"""Tests for Config dataclass."""
|
||||||
|
|
||||||
|
def test_config_has_required_fields(self):
|
||||||
|
"""Config has all required fields."""
|
||||||
|
c = config.Config()
|
||||||
|
assert hasattr(c, "headline_limit")
|
||||||
|
assert hasattr(c, "feed_timeout")
|
||||||
|
assert hasattr(c, "mic_threshold_db")
|
||||||
|
assert hasattr(c, "mode")
|
||||||
|
assert hasattr(c, "firehose")
|
||||||
|
assert hasattr(c, "ntfy_topic")
|
||||||
|
assert hasattr(c, "ntfy_reconnect_delay")
|
||||||
|
assert hasattr(c, "message_display_secs")
|
||||||
|
assert hasattr(c, "font_dir")
|
||||||
|
assert hasattr(c, "font_path")
|
||||||
|
assert hasattr(c, "font_index")
|
||||||
|
assert hasattr(c, "font_picker")
|
||||||
|
assert hasattr(c, "font_sz")
|
||||||
|
assert hasattr(c, "render_h")
|
||||||
|
assert hasattr(c, "ssaa")
|
||||||
|
assert hasattr(c, "scroll_dur")
|
||||||
|
assert hasattr(c, "frame_dt")
|
||||||
|
assert hasattr(c, "firehose_h")
|
||||||
|
assert hasattr(c, "grad_speed")
|
||||||
|
assert hasattr(c, "glitch_glyphs")
|
||||||
|
assert hasattr(c, "kata_glyphs")
|
||||||
|
assert hasattr(c, "script_fonts")
|
||||||
|
|
||||||
|
def test_config_defaults(self):
|
||||||
|
"""Config has sensible defaults."""
|
||||||
|
c = config.Config()
|
||||||
|
assert c.headline_limit == 1000
|
||||||
|
assert c.feed_timeout == 10
|
||||||
|
assert c.mic_threshold_db == 50
|
||||||
|
assert c.mode == "news"
|
||||||
|
assert c.firehose is False
|
||||||
|
assert c.ntfy_reconnect_delay == 5
|
||||||
|
assert c.message_display_secs == 30
|
||||||
|
|
||||||
|
def test_config_is_immutable(self):
|
||||||
|
"""Config is frozen (immutable)."""
|
||||||
|
c = config.Config()
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
c.headline_limit = 500 # type: ignore
|
||||||
|
|
||||||
|
def test_config_custom_values(self):
|
||||||
|
"""Config accepts custom values."""
|
||||||
|
c = config.Config(
|
||||||
|
headline_limit=500,
|
||||||
|
mode="poetry",
|
||||||
|
firehose=True,
|
||||||
|
ntfy_topic="https://ntfy.sh/test",
|
||||||
|
)
|
||||||
|
assert c.headline_limit == 500
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
assert c.firehose is True
|
||||||
|
assert c.ntfy_topic == "https://ntfy.sh/test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigFromArgs:
|
||||||
|
"""Tests for Config.from_args method."""
|
||||||
|
|
||||||
|
def test_from_args_defaults(self):
|
||||||
|
"""from_args creates config with defaults from empty argv."""
|
||||||
|
c = config.Config.from_args(["prog"])
|
||||||
|
assert c.mode == "news"
|
||||||
|
assert c.firehose is False
|
||||||
|
assert c.font_picker is True
|
||||||
|
|
||||||
|
def test_from_args_poetry_mode(self):
|
||||||
|
"""from_args detects --poetry flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--poetry"])
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
|
||||||
|
def test_from_args_poetry_short_flag(self):
|
||||||
|
"""from_args detects -p short flag."""
|
||||||
|
c = config.Config.from_args(["prog", "-p"])
|
||||||
|
assert c.mode == "poetry"
|
||||||
|
|
||||||
|
def test_from_args_firehose(self):
|
||||||
|
"""from_args detects --firehose flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--firehose"])
|
||||||
|
assert c.firehose is True
|
||||||
|
|
||||||
|
def test_from_args_no_font_picker(self):
|
||||||
|
"""from_args detects --no-font-picker flag."""
|
||||||
|
c = config.Config.from_args(["prog", "--no-font-picker"])
|
||||||
|
assert c.font_picker is False
|
||||||
|
|
||||||
|
def test_from_args_font_index(self):
|
||||||
|
"""from_args parses --font-index."""
|
||||||
|
c = config.Config.from_args(["prog", "--font-index", "3"])
|
||||||
|
assert c.font_index == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSetConfig:
|
||||||
|
"""Tests for get_config and set_config functions."""
|
||||||
|
|
||||||
|
def test_get_config_returns_config(self):
|
||||||
|
"""get_config returns a Config instance."""
|
||||||
|
c = config.get_config()
|
||||||
|
assert isinstance(c, config.Config)
|
||||||
|
|
||||||
|
def test_set_config_allows_injection(self):
|
||||||
|
"""set_config allows injecting a custom config."""
|
||||||
|
custom = config.Config(mode="poetry", headline_limit=100)
|
||||||
|
config.set_config(custom)
|
||||||
|
assert config.get_config().mode == "poetry"
|
||||||
|
assert config.get_config().headline_limit == 100
|
||||||
|
|
||||||
|
def test_set_config_then_get_config(self):
|
||||||
|
"""set_config followed by get_config returns the set config."""
|
||||||
|
original = config.get_config()
|
||||||
|
test_config = config.Config(headline_limit=42)
|
||||||
|
config.set_config(test_config)
|
||||||
|
result = config.get_config()
|
||||||
|
assert result.headline_limit == 42
|
||||||
|
config.set_config(original)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlatformFontPaths:
|
||||||
|
"""Tests for platform font path detection."""
|
||||||
|
|
||||||
|
def test_get_platform_font_paths_returns_dict(self):
|
||||||
|
"""_get_platform_font_paths returns a dictionary."""
|
||||||
|
fonts = config._get_platform_font_paths()
|
||||||
|
assert isinstance(fonts, dict)
|
||||||
|
|
||||||
|
def test_platform_font_paths_common_languages(self):
|
||||||
|
"""Common language font mappings exist."""
|
||||||
|
fonts = config._get_platform_font_paths()
|
||||||
|
common = {"ja", "zh-cn", "ko", "ru", "ar", "hi"}
|
||||||
|
found = set(fonts.keys()) & common
|
||||||
|
assert len(found) > 0
|
||||||
117
tests/test_controller.py
Normal file
117
tests/test_controller.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.controller module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.controller import StreamController
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamController:
|
||||||
|
"""Tests for StreamController class."""
|
||||||
|
|
||||||
|
def test_init_default_config(self):
|
||||||
|
"""StreamController initializes with default config."""
|
||||||
|
controller = StreamController()
|
||||||
|
assert controller.config is not None
|
||||||
|
assert isinstance(controller.config, config.Config)
|
||||||
|
|
||||||
|
def test_init_custom_config(self):
|
||||||
|
"""StreamController accepts custom config."""
|
||||||
|
custom_config = config.Config(headline_limit=500)
|
||||||
|
controller = StreamController(config=custom_config)
|
||||||
|
assert controller.config.headline_limit == 500
|
||||||
|
|
||||||
|
def test_init_sources_none_by_default(self):
|
||||||
|
"""Sources are None until initialized."""
|
||||||
|
controller = StreamController()
|
||||||
|
assert controller.mic is None
|
||||||
|
assert controller.ntfy is None
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
@patch("engine.controller.NtfyPoller")
|
||||||
|
def test_initialize_sources(self, mock_ntfy, mock_mic):
|
||||||
|
"""initialize_sources creates mic and ntfy instances."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic_instance.available = True
|
||||||
|
mock_mic_instance.start.return_value = True
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
mock_ntfy_instance = MagicMock()
|
||||||
|
mock_ntfy_instance.start.return_value = True
|
||||||
|
mock_ntfy.return_value = mock_ntfy_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
|
|
||||||
|
assert mic_ok is True
|
||||||
|
assert ntfy_ok is True
|
||||||
|
assert controller.mic is not None
|
||||||
|
assert controller.ntfy is not None
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
@patch("engine.controller.NtfyPoller")
|
||||||
|
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
|
||||||
|
"""initialize_sources handles unavailable mic."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic_instance.available = False
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
mock_ntfy_instance = MagicMock()
|
||||||
|
mock_ntfy_instance.start.return_value = True
|
||||||
|
mock_ntfy.return_value = mock_ntfy_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
|
|
||||||
|
assert mic_ok is False
|
||||||
|
assert ntfy_ok is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerCleanup:
|
||||||
|
"""Tests for StreamController cleanup."""
|
||||||
|
|
||||||
|
@patch("engine.controller.MicMonitor")
|
||||||
|
def test_cleanup_stops_mic(self, mock_mic):
|
||||||
|
"""cleanup stops the microphone if running."""
|
||||||
|
mock_mic_instance = MagicMock()
|
||||||
|
mock_mic.return_value = mock_mic_instance
|
||||||
|
|
||||||
|
controller = StreamController()
|
||||||
|
controller.mic = mock_mic_instance
|
||||||
|
controller.cleanup()
|
||||||
|
|
||||||
|
mock_mic_instance.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamControllerWarmup:
|
||||||
|
"""Tests for StreamController topic warmup."""
|
||||||
|
|
||||||
|
def test_warmup_topics_idempotent(self):
|
||||||
|
"""warmup_topics can be called multiple times."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert mock_urlopen.call_count >= 3
|
||||||
|
|
||||||
|
def test_warmup_topics_sets_flag(self):
|
||||||
|
"""warmup_topics sets the warmed flag."""
|
||||||
|
StreamController._topics_warmed = False
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen"):
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
assert StreamController._topics_warmed is True
|
||||||
|
|
||||||
|
def test_warmup_topics_skips_after_first(self):
|
||||||
|
"""warmup_topics skips after first call."""
|
||||||
|
StreamController._topics_warmed = True
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
|
mock_urlopen.assert_not_called()
|
||||||
79
tests/test_display.py
Normal file
79
tests/test_display.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.display import NullDisplay, TerminalDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestDisplayProtocol:
|
||||||
|
"""Test that display backends satisfy the Display protocol."""
|
||||||
|
|
||||||
|
def test_terminal_display_is_display(self):
|
||||||
|
"""TerminalDisplay satisfies Display protocol."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
def test_null_display_is_display(self):
|
||||||
|
"""NullDisplay satisfies Display protocol."""
|
||||||
|
display = NullDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDisplay:
|
||||||
|
"""Tests for TerminalDisplay class."""
|
||||||
|
|
||||||
|
def test_init_sets_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_show_returns_none(self):
|
||||||
|
"""show returns None after writing to stdout."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.width = 80
|
||||||
|
display.height = 24
|
||||||
|
display.show(["line1", "line2"])
|
||||||
|
|
||||||
|
def test_clear_does_not_error(self):
|
||||||
|
"""clear works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_not_error(self):
|
||||||
|
"""cleanup works without error."""
|
||||||
|
display = TerminalDisplay()
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNullDisplay:
|
||||||
|
"""Tests for NullDisplay class."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores dimensions."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.init(100, 50)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 50
|
||||||
|
|
||||||
|
def test_show_does_nothing(self):
|
||||||
|
"""show discards buffer without error."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.show(["line1", "line2", "line3"])
|
||||||
|
|
||||||
|
def test_clear_does_nothing(self):
|
||||||
|
"""clear does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.clear()
|
||||||
|
|
||||||
|
def test_cleanup_does_nothing(self):
|
||||||
|
"""cleanup does nothing."""
|
||||||
|
display = NullDisplay()
|
||||||
|
display.cleanup()
|
||||||
427
tests/test_effects.py
Normal file
427
tests/test_effects.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class MockEffect:
|
||||||
|
name = "mock"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processed = False
|
||||||
|
self.last_ctx = None
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
self.processed = True
|
||||||
|
self.last_ctx = ctx
|
||||||
|
return buf + ["processed"]
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectConfig:
|
||||||
|
def test_defaults(self):
|
||||||
|
cfg = EffectConfig()
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.intensity == 1.0
|
||||||
|
assert cfg.params == {}
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.intensity == 0.5
|
||||||
|
assert cfg.params == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectContext:
|
||||||
|
def test_defaults(self):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
assert ctx.terminal_width == 80
|
||||||
|
assert ctx.terminal_height == 24
|
||||||
|
assert ctx.ticker_height == 20
|
||||||
|
assert ctx.items == []
|
||||||
|
|
||||||
|
def test_with_items(self):
|
||||||
|
items = [("Title", "Source", "12:00")]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
assert ctx.items == items
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectRegistry:
|
||||||
|
def test_init_empty(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert len(registry.list_all()) == 0
|
||||||
|
|
||||||
|
def test_register(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
assert "mock" in registry.list_all()
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
retrieved = registry.get("mock")
|
||||||
|
assert retrieved is effect
|
||||||
|
|
||||||
|
def test_get_nonexistent(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_enable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
registry.enable("mock")
|
||||||
|
assert effect.config.enabled is True
|
||||||
|
|
||||||
|
def test_disable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
registry.disable("mock")
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
|
||||||
|
def test_list_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
|
||||||
|
class EnabledEffect:
|
||||||
|
name = "enabled_effect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
class DisabledEffect:
|
||||||
|
name = "disabled_effect"
|
||||||
|
config = EffectConfig(enabled=False, intensity=1.0)
|
||||||
|
|
||||||
|
registry.register(EnabledEffect())
|
||||||
|
registry.register(DisabledEffect())
|
||||||
|
enabled = registry.list_enabled()
|
||||||
|
assert len(enabled) == 1
|
||||||
|
assert enabled[0].name == "enabled_effect"
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||||
|
registry.configure("mock", new_config)
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
assert effect.config.intensity == 0.3
|
||||||
|
|
||||||
|
def test_is_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
assert registry.is_enabled("mock") is True
|
||||||
|
assert registry.is_enabled("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectChain:
|
||||||
|
def test_init(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
assert chain.get_order() == []
|
||||||
|
|
||||||
|
def test_set_order(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2"])
|
||||||
|
assert chain.get_order() == ["effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_add_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.add_effect("test_effect")
|
||||||
|
assert "test_effect" in chain.get_order()
|
||||||
|
|
||||||
|
def test_add_effect_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.add_effect("nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_remove_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
chain.remove_effect("test_effect")
|
||||||
|
assert "test_effect" not in chain.get_order()
|
||||||
|
|
||||||
|
def test_reorder(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
effect3 = MockEffect()
|
||||||
|
effect3.name = "effect3"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
registry.register(effect3)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2", "effect3"])
|
||||||
|
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||||
|
assert result is True
|
||||||
|
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_reorder_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "effect1"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.reorder(["effect1", "nonexistent"])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_process_empty_chain(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == buf
|
||||||
|
|
||||||
|
def test_process_with_effects(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1", "line2", "processed"]
|
||||||
|
assert effect.processed is True
|
||||||
|
assert effect.last_ctx is ctx
|
||||||
|
|
||||||
|
def test_process_disabled_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1"]
|
||||||
|
assert effect.processed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectsExports:
|
||||||
|
def test_all_exports_are_importable(self):
|
||||||
|
"""Verify all exports in __all__ can actually be imported."""
|
||||||
|
import engine.effects as effects_module
|
||||||
|
|
||||||
|
for name in effects_module.__all__:
|
||||||
|
getattr(effects_module, name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPerformanceMonitor:
|
||||||
|
def test_empty_stats(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
def test_record_and_retrieve(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||||
|
monitor.end_frame(1, 2.0)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["frame_count"] == 1
|
||||||
|
assert "test_effect" in stats["effects"]
|
||||||
|
|
||||||
|
def test_multiple_frames(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=3)
|
||||||
|
for i in range(5):
|
||||||
|
monitor.start_frame(i)
|
||||||
|
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||||
|
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||||
|
monitor.end_frame(i, 1.5)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert stats["frame_count"] == 3
|
||||||
|
assert "effect1" in stats["effects"]
|
||||||
|
assert "effect2" in stats["effects"]
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
monitor.start_frame(1)
|
||||||
|
monitor.record_effect("test", 1.0, 100, 100)
|
||||||
|
monitor.end_frame(1, 1.0)
|
||||||
|
|
||||||
|
monitor.reset()
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" in stats
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectPipelinePerformance:
|
||||||
|
def test_pipeline_stays_within_frame_budget(self):
|
||||||
|
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DummyEffect:
|
||||||
|
name = "dummy"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
return [line * 2 for line in buf]
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(DummyEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=10)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["dummy"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 20
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["pipeline"]["max_ms"] < 33.0
|
||||||
|
|
||||||
|
def test_individual_effects_performance(self):
|
||||||
|
"""Verify individual effects don't exceed 10ms per frame."""
|
||||||
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SlowEffect:
|
||||||
|
name = "slow"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
result = []
|
||||||
|
for line in buf:
|
||||||
|
result.append(line)
|
||||||
|
result.append(line + line)
|
||||||
|
return result
|
||||||
|
|
||||||
|
registry = EffectRegistry()
|
||||||
|
registry.register(SlowEffect())
|
||||||
|
|
||||||
|
from engine.effects.performance import PerformanceMonitor
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor(max_frames=5)
|
||||||
|
chain = EffectChain(registry, monitor)
|
||||||
|
chain.set_order(["slow"])
|
||||||
|
|
||||||
|
buf = ["x" * 80] * 10
|
||||||
|
|
||||||
|
for i in range(5):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=i,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
chain.process(buf, ctx)
|
||||||
|
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
assert "error" not in stats
|
||||||
|
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||||
117
tests/test_effects_controller.py
Normal file
117
tests/test_effects_controller.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects.controller module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.effects.controller import (
|
||||||
|
handle_effects_command,
|
||||||
|
set_effect_chain_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleEffectsCommand:
|
||||||
|
"""Tests for handle_effects_command function."""
|
||||||
|
|
||||||
|
def test_list_effects(self):
|
||||||
|
"""list command returns formatted effects list."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.enabled = True
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain.return_value.get_order.return_value = ["noise"]
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects list")
|
||||||
|
|
||||||
|
assert "noise: ON" in result
|
||||||
|
assert "intensity=0.5" in result
|
||||||
|
|
||||||
|
def test_enable_effect(self):
|
||||||
|
"""enable command calls registry.enable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise on")
|
||||||
|
|
||||||
|
assert "Enabled: noise" in result
|
||||||
|
mock_registry.return_value.enable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_disable_effect(self):
|
||||||
|
"""disable command calls registry.disable."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise off")
|
||||||
|
|
||||||
|
assert "Disabled: noise" in result
|
||||||
|
mock_registry.return_value.disable.assert_called_once_with("noise")
|
||||||
|
|
||||||
|
def test_set_intensity(self):
|
||||||
|
"""intensity command sets plugin intensity."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_plugin.config.intensity = 0.5
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 0.8")
|
||||||
|
|
||||||
|
assert "intensity to 0.8" in result
|
||||||
|
assert mock_plugin.config.intensity == 0.8
|
||||||
|
|
||||||
|
def test_invalid_intensity_range(self):
|
||||||
|
"""intensity outside 0.0-1.0 returns error."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_plugin = MagicMock()
|
||||||
|
mock_registry.return_value.get.return_value = mock_plugin
|
||||||
|
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects noise intensity 1.5")
|
||||||
|
|
||||||
|
assert "between 0.0 and 1.0" in result
|
||||||
|
|
||||||
|
def test_reorder_pipeline(self):
|
||||||
|
"""reorder command calls chain.reorder."""
|
||||||
|
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||||
|
mock_registry.return_value.list_all.return_value = {}
|
||||||
|
|
||||||
|
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||||
|
mock_chain_instance = MagicMock()
|
||||||
|
mock_chain_instance.reorder.return_value = True
|
||||||
|
mock_chain.return_value = mock_chain_instance
|
||||||
|
|
||||||
|
result = handle_effects_command("/effects reorder noise,fade")
|
||||||
|
|
||||||
|
assert "Reordered pipeline" in result
|
||||||
|
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
||||||
|
|
||||||
|
def test_unknown_command(self):
|
||||||
|
"""unknown command returns error."""
|
||||||
|
result = handle_effects_command("/unknown")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
def test_non_effects_command(self):
|
||||||
|
"""non-effects command returns error."""
|
||||||
|
result = handle_effects_command("not a command")
|
||||||
|
assert "Unknown command" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetEffectChainRef:
|
||||||
|
"""Tests for set_effect_chain_ref function."""
|
||||||
|
|
||||||
|
def test_sets_global_ref(self):
|
||||||
|
"""set_effect_chain_ref updates global reference."""
|
||||||
|
mock_chain = MagicMock()
|
||||||
|
set_effect_chain_ref(mock_chain)
|
||||||
|
|
||||||
|
from engine.effects.controller import _get_effect_chain
|
||||||
|
|
||||||
|
result = _get_effect_chain()
|
||||||
|
assert result == mock_chain
|
||||||
69
tests/test_emitters.py
Normal file
69
tests/test_emitters.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.emitters module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.emitters import EventEmitter, Startable, Stoppable
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventEmitterProtocol:
|
||||||
|
"""Tests for EventEmitter protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""EventEmitter protocol is defined."""
|
||||||
|
assert EventEmitter is not None
|
||||||
|
|
||||||
|
def test_protocol_has_subscribe_method(self):
|
||||||
|
"""EventEmitter has subscribe method in protocol."""
|
||||||
|
assert hasattr(EventEmitter, "subscribe")
|
||||||
|
|
||||||
|
def test_protocol_has_unsubscribe_method(self):
|
||||||
|
"""EventEmitter has unsubscribe method in protocol."""
|
||||||
|
assert hasattr(EventEmitter, "unsubscribe")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartableProtocol:
|
||||||
|
"""Tests for Startable protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""Startable protocol is defined."""
|
||||||
|
assert Startable is not None
|
||||||
|
|
||||||
|
def test_protocol_has_start_method(self):
|
||||||
|
"""Startable has start method in protocol."""
|
||||||
|
assert hasattr(Startable, "start")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoppableProtocol:
|
||||||
|
"""Tests for Stoppable protocol."""
|
||||||
|
|
||||||
|
def test_protocol_exists(self):
|
||||||
|
"""Stoppable protocol is defined."""
|
||||||
|
assert Stoppable is not None
|
||||||
|
|
||||||
|
def test_protocol_has_stop_method(self):
|
||||||
|
"""Stoppable has stop method in protocol."""
|
||||||
|
assert hasattr(Stoppable, "stop")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtocolCompliance:
|
||||||
|
"""Tests that existing classes comply with protocols."""
|
||||||
|
|
||||||
|
def test_ntfy_poller_complies_with_protocol(self):
|
||||||
|
"""NtfyPoller implements EventEmitter protocol."""
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
assert hasattr(poller, "subscribe")
|
||||||
|
assert hasattr(poller, "unsubscribe")
|
||||||
|
assert callable(poller.subscribe)
|
||||||
|
assert callable(poller.unsubscribe)
|
||||||
|
|
||||||
|
def test_mic_monitor_complies_with_protocol(self):
|
||||||
|
"""MicMonitor implements EventEmitter and Startable protocols."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert hasattr(monitor, "subscribe")
|
||||||
|
assert hasattr(monitor, "unsubscribe")
|
||||||
|
assert hasattr(monitor, "start")
|
||||||
|
assert hasattr(monitor, "stop")
|
||||||
202
tests/test_eventbus.py
Normal file
202
tests/test_eventbus.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.eventbus module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from engine.eventbus import EventBus, get_event_bus, set_event_bus
|
||||||
|
from engine.events import EventType, NtfyMessageEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusInit:
|
||||||
|
"""Tests for EventBus initialization."""
|
||||||
|
|
||||||
|
def test_init_creates_empty_subscribers(self):
|
||||||
|
"""EventBus starts with no subscribers."""
|
||||||
|
bus = EventBus()
|
||||||
|
assert bus.subscriber_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusSubscribe:
|
||||||
|
"""Tests for EventBus.subscribe method."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback for an event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||||
|
|
||||||
|
def test_subscribe_multiple_callbacks_same_event(self):
|
||||||
|
"""Multiple callbacks can be subscribed to the same event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb2)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 2
|
||||||
|
|
||||||
|
def test_subscribe_different_event_types(self):
|
||||||
|
"""Callbacks can be subscribed to different event types."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||||
|
assert bus.subscriber_count(EventType.MIC_LEVEL) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusUnsubscribe:
|
||||||
|
"""Tests for EventBus.unsubscribe method."""
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 0
|
||||||
|
|
||||||
|
def test_unsubscribe_nonexistent_callback_no_error(self):
|
||||||
|
"""unsubscribe() handles non-existent callback gracefully."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusPublish:
|
||||||
|
"""Tests for EventBus.publish method."""
|
||||||
|
|
||||||
|
def test_publish_calls_subscriber(self):
|
||||||
|
"""publish() calls the subscriber callback."""
|
||||||
|
bus = EventBus()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].title == "Test"
|
||||||
|
|
||||||
|
def test_publish_multiple_subscribers(self):
|
||||||
|
"""publish() calls all subscribers for an event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
received1 = []
|
||||||
|
received2 = []
|
||||||
|
|
||||||
|
def callback1(event):
|
||||||
|
received1.append(event)
|
||||||
|
|
||||||
|
def callback2(event):
|
||||||
|
received2.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback1)
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback2)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(received1) == 1
|
||||||
|
assert len(received2) == 1
|
||||||
|
|
||||||
|
def test_publish_different_event_types(self):
|
||||||
|
"""publish() only calls subscribers for the specific event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
ntfy_received = []
|
||||||
|
mic_received = []
|
||||||
|
|
||||||
|
def ntfy_callback(event):
|
||||||
|
ntfy_received.append(event)
|
||||||
|
|
||||||
|
def mic_callback(event):
|
||||||
|
mic_received.append(event)
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, ntfy_callback)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, mic_callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||||
|
|
||||||
|
assert len(ntfy_received) == 1
|
||||||
|
assert len(mic_received) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusClear:
|
||||||
|
"""Tests for EventBus.clear method."""
|
||||||
|
|
||||||
|
def test_clear_removes_all_subscribers(self):
|
||||||
|
"""clear() removes all subscribers."""
|
||||||
|
bus = EventBus()
|
||||||
|
def cb1(e):
|
||||||
|
return None
|
||||||
|
def cb2(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||||
|
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||||
|
bus.clear()
|
||||||
|
|
||||||
|
assert bus.subscriber_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventBusThreadSafety:
|
||||||
|
"""Tests for EventBus thread safety."""
|
||||||
|
|
||||||
|
def test_concurrent_subscribe_unsubscribe(self):
|
||||||
|
"""subscribe and unsubscribe can be called concurrently."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
bus = EventBus()
|
||||||
|
callbacks = [lambda e: None for _ in range(10)]
|
||||||
|
|
||||||
|
def subscribe():
|
||||||
|
for cb in callbacks:
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, cb)
|
||||||
|
|
||||||
|
def unsubscribe():
|
||||||
|
for cb in callbacks:
|
||||||
|
bus.unsubscribe(EventType.NTFY_MESSAGE, cb)
|
||||||
|
|
||||||
|
t1 = threading.Thread(target=subscribe)
|
||||||
|
t2 = threading.Thread(target=unsubscribe)
|
||||||
|
t1.start()
|
||||||
|
t2.start()
|
||||||
|
t1.join()
|
||||||
|
t2.join()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalEventBus:
|
||||||
|
"""Tests for global event bus functions."""
|
||||||
|
|
||||||
|
def test_get_event_bus_returns_singleton(self):
|
||||||
|
"""get_event_bus() returns the same instance."""
|
||||||
|
bus1 = get_event_bus()
|
||||||
|
bus2 = get_event_bus()
|
||||||
|
assert bus1 is bus2
|
||||||
|
|
||||||
|
def test_set_event_bus_replaces_singleton(self):
|
||||||
|
"""set_event_bus() replaces the global event bus."""
|
||||||
|
new_bus = EventBus()
|
||||||
|
set_event_bus(new_bus)
|
||||||
|
try:
|
||||||
|
assert get_event_bus() is new_bus
|
||||||
|
finally:
|
||||||
|
set_event_bus(None)
|
||||||
112
tests/test_events.py
Normal file
112
tests/test_events.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.events module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import events
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventType:
|
||||||
|
"""Tests for EventType enum."""
|
||||||
|
|
||||||
|
def test_event_types_exist(self):
|
||||||
|
"""All expected event types exist."""
|
||||||
|
assert hasattr(events.EventType, "NEW_HEADLINE")
|
||||||
|
assert hasattr(events.EventType, "FRAME_TICK")
|
||||||
|
assert hasattr(events.EventType, "MIC_LEVEL")
|
||||||
|
assert hasattr(events.EventType, "NTFY_MESSAGE")
|
||||||
|
assert hasattr(events.EventType, "STREAM_START")
|
||||||
|
assert hasattr(events.EventType, "STREAM_END")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadlineEvent:
|
||||||
|
"""Tests for HeadlineEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_headline_event(self):
|
||||||
|
"""HeadlineEvent can be created with required fields."""
|
||||||
|
e = events.HeadlineEvent(
|
||||||
|
title="Test Headline",
|
||||||
|
source="Test Source",
|
||||||
|
timestamp="12:00",
|
||||||
|
)
|
||||||
|
assert e.title == "Test Headline"
|
||||||
|
assert e.source == "Test Source"
|
||||||
|
assert e.timestamp == "12:00"
|
||||||
|
|
||||||
|
def test_headline_event_optional_language(self):
|
||||||
|
"""HeadlineEvent supports optional language field."""
|
||||||
|
e = events.HeadlineEvent(
|
||||||
|
title="Test",
|
||||||
|
source="Test",
|
||||||
|
timestamp="12:00",
|
||||||
|
language="ja",
|
||||||
|
)
|
||||||
|
assert e.language == "ja"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameTickEvent:
|
||||||
|
"""Tests for FrameTickEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_frame_tick_event(self):
|
||||||
|
"""FrameTickEvent can be created."""
|
||||||
|
now = datetime.now()
|
||||||
|
e = events.FrameTickEvent(
|
||||||
|
frame_number=100,
|
||||||
|
timestamp=now,
|
||||||
|
delta_seconds=0.05,
|
||||||
|
)
|
||||||
|
assert e.frame_number == 100
|
||||||
|
assert e.timestamp == now
|
||||||
|
assert e.delta_seconds == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicLevelEvent:
|
||||||
|
"""Tests for MicLevelEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_mic_level_event(self):
|
||||||
|
"""MicLevelEvent can be created."""
|
||||||
|
now = datetime.now()
|
||||||
|
e = events.MicLevelEvent(
|
||||||
|
db_level=60.0,
|
||||||
|
excess_above_threshold=10.0,
|
||||||
|
timestamp=now,
|
||||||
|
)
|
||||||
|
assert e.db_level == 60.0
|
||||||
|
assert e.excess_above_threshold == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyMessageEvent:
|
||||||
|
"""Tests for NtfyMessageEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_ntfy_message_event(self):
|
||||||
|
"""NtfyMessageEvent can be created with required fields."""
|
||||||
|
e = events.NtfyMessageEvent(
|
||||||
|
title="Test Title",
|
||||||
|
body="Test Body",
|
||||||
|
)
|
||||||
|
assert e.title == "Test Title"
|
||||||
|
assert e.body == "Test Body"
|
||||||
|
assert e.message_id is None
|
||||||
|
|
||||||
|
def test_ntfy_message_event_with_id(self):
|
||||||
|
"""NtfyMessageEvent supports optional message_id."""
|
||||||
|
e = events.NtfyMessageEvent(
|
||||||
|
title="Test",
|
||||||
|
body="Test",
|
||||||
|
message_id="abc123",
|
||||||
|
)
|
||||||
|
assert e.message_id == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamEvent:
|
||||||
|
"""Tests for StreamEvent dataclass."""
|
||||||
|
|
||||||
|
def test_create_stream_event(self):
|
||||||
|
"""StreamEvent can be created."""
|
||||||
|
e = events.StreamEvent(
|
||||||
|
event_type=events.EventType.STREAM_START,
|
||||||
|
headline_count=100,
|
||||||
|
)
|
||||||
|
assert e.event_type == events.EventType.STREAM_START
|
||||||
|
assert e.headline_count == 100
|
||||||
35
tests/test_fetch_code.py
Normal file
35
tests/test_fetch_code.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_shape():
|
||||||
|
items, line_count, ignored = fetch_code()
|
||||||
|
assert isinstance(items, list)
|
||||||
|
assert line_count == len(items)
|
||||||
|
assert ignored == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_items_are_tuples():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
assert items, "expected at least one code line"
|
||||||
|
for item in items:
|
||||||
|
assert isinstance(item, tuple) and len(item) == 3
|
||||||
|
text, src, ts = item
|
||||||
|
assert isinstance(text, str)
|
||||||
|
assert isinstance(src, str)
|
||||||
|
assert isinstance(ts, str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_and_comment_lines_excluded():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
for text, _, _ in items:
|
||||||
|
assert text.strip(), "blank line should have been filtered"
|
||||||
|
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_path_format():
|
||||||
|
items, _, _ = fetch_code()
|
||||||
|
pattern = re.compile(r"^engine\.\w+$")
|
||||||
|
for _, _, ts in items:
|
||||||
|
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
||||||
93
tests/test_filter.py
Normal file
93
tests/test_filter.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.filter module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.filter import skip, strip_tags
|
||||||
|
|
||||||
|
|
||||||
|
class TestStripTags:
|
||||||
|
"""Tests for strip_tags function."""
|
||||||
|
|
||||||
|
def test_strips_simple_html(self):
|
||||||
|
"""Basic HTML tags are removed."""
|
||||||
|
assert strip_tags("<p>Hello</p>") == "Hello"
|
||||||
|
assert strip_tags("<b>Bold</b>") == "Bold"
|
||||||
|
assert strip_tags("<em>Italic</em>") == "Italic"
|
||||||
|
|
||||||
|
def test_strips_nested_html(self):
|
||||||
|
"""Nested HTML tags are handled."""
|
||||||
|
assert strip_tags("<div><p>Nested</p></div>") == "Nested"
|
||||||
|
assert strip_tags("<span><strong>Deep</strong></span>") == "Deep"
|
||||||
|
|
||||||
|
def test_strips_html_with_attributes(self):
|
||||||
|
"""HTML with attributes is handled."""
|
||||||
|
assert strip_tags('<a href="http://example.com">Link</a>') == "Link"
|
||||||
|
assert strip_tags('<img src="test.jpg" alt="test">') == ""
|
||||||
|
|
||||||
|
def test_handles_empty_string(self):
|
||||||
|
"""Empty string returns empty string."""
|
||||||
|
assert strip_tags("") == ""
|
||||||
|
assert strip_tags(None) == ""
|
||||||
|
|
||||||
|
def test_handles_plain_text(self):
|
||||||
|
"""Plain text without tags passes through."""
|
||||||
|
assert strip_tags("Plain text") == "Plain text"
|
||||||
|
|
||||||
|
def test_unescapes_html_entities(self):
|
||||||
|
"""HTML entities are decoded and tags are stripped."""
|
||||||
|
assert strip_tags(" test") == "test"
|
||||||
|
assert strip_tags("Hello & World") == "Hello & World"
|
||||||
|
|
||||||
|
def test_handles_malformed_html(self):
|
||||||
|
"""Malformed HTML is handled gracefully."""
|
||||||
|
assert strip_tags("<p>Unclosed") == "Unclosed"
|
||||||
|
assert strip_tags("</p>No start") == "No start"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkip:
|
||||||
|
"""Tests for skip function - content filtering."""
|
||||||
|
|
||||||
|
def test_skips_sports_content(self):
|
||||||
|
"""Sports-related headlines are skipped."""
|
||||||
|
assert skip("Football: Team wins championship") is True
|
||||||
|
assert skip("NBA Finals Game 7 results") is True
|
||||||
|
assert skip("Soccer match ends in draw") is True
|
||||||
|
assert skip("Premier League transfer news") is True
|
||||||
|
assert skip("Super Bowl halftime show") is True
|
||||||
|
|
||||||
|
def test_skips_vapid_content(self):
|
||||||
|
"""Vapid/celebrity content is skipped."""
|
||||||
|
assert skip("Kim Kardashian's new look") is True
|
||||||
|
assert skip("Influencer goes viral") is True
|
||||||
|
assert skip("Red carpet best dressed") is True
|
||||||
|
assert skip("Celebrity couple splits") is True
|
||||||
|
|
||||||
|
def test_allows_real_news(self):
|
||||||
|
"""Legitimate news headlines are allowed."""
|
||||||
|
assert skip("Scientists discover new planet") is False
|
||||||
|
assert skip("Economy grows by 3%") is False
|
||||||
|
assert skip("World leaders meet for summit") is False
|
||||||
|
assert skip("New technology breakthrough") is False
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Filter is case insensitive."""
|
||||||
|
assert skip("FOOTBALL scores") is True
|
||||||
|
assert skip("Football SCORES") is True
|
||||||
|
assert skip("Kardashian") is True
|
||||||
|
|
||||||
|
def test_word_boundary_matching(self):
|
||||||
|
"""Word boundary matching works correctly."""
|
||||||
|
assert skip("The football stadium") is True
|
||||||
|
assert skip("Footballer scores") is False
|
||||||
|
assert skip("Footballs on sale") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntegration:
|
||||||
|
"""Integration tests combining filter functions."""
|
||||||
|
|
||||||
|
def test_full_pipeline(self):
|
||||||
|
"""Test strip_tags followed by skip."""
|
||||||
|
html = '<p><a href="#">Breaking: Football championship final</a></p>'
|
||||||
|
text = strip_tags(html)
|
||||||
|
assert text == "Breaking: Football championship final"
|
||||||
|
assert skip(text) is True
|
||||||
63
tests/test_frame.py
Normal file
63
tests/test_frame.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.frame module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine.frame import FrameTimer, calculate_scroll_step
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameTimer:
|
||||||
|
"""Tests for FrameTimer class."""
|
||||||
|
|
||||||
|
def test_init_default(self):
|
||||||
|
"""FrameTimer initializes with default values."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
assert timer.target_frame_dt == 0.05
|
||||||
|
assert timer.fps >= 0
|
||||||
|
|
||||||
|
def test_init_custom(self):
|
||||||
|
"""FrameTimer accepts custom frame duration."""
|
||||||
|
timer = FrameTimer(target_frame_dt=0.1)
|
||||||
|
assert timer.target_frame_dt == 0.1
|
||||||
|
|
||||||
|
def test_fps_calculation(self):
|
||||||
|
"""FrameTimer calculates FPS correctly."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
timer._frame_count = 10
|
||||||
|
timer._start_time = time.monotonic() - 1.0
|
||||||
|
assert timer.fps >= 9.0
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
"""FrameTimer.reset() clears frame count."""
|
||||||
|
timer = FrameTimer()
|
||||||
|
timer._frame_count = 100
|
||||||
|
timer.reset()
|
||||||
|
assert timer._frame_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCalculateScrollStep:
|
||||||
|
"""Tests for calculate_scroll_step function."""
|
||||||
|
|
||||||
|
def test_basic_calculation(self):
|
||||||
|
"""calculate_scroll_step returns positive value."""
|
||||||
|
result = calculate_scroll_step(5.0, 24)
|
||||||
|
assert result > 0
|
||||||
|
|
||||||
|
def test_with_padding(self):
|
||||||
|
"""calculate_scroll_step respects padding parameter."""
|
||||||
|
without_padding = calculate_scroll_step(5.0, 24, padding=0)
|
||||||
|
with_padding = calculate_scroll_step(5.0, 24, padding=15)
|
||||||
|
assert with_padding < without_padding
|
||||||
|
|
||||||
|
def test_larger_view_slower_scroll(self):
|
||||||
|
"""Larger view height results in slower scroll steps."""
|
||||||
|
small = calculate_scroll_step(5.0, 10)
|
||||||
|
large = calculate_scroll_step(5.0, 50)
|
||||||
|
assert large < small
|
||||||
|
|
||||||
|
def test_longer_duration_slower_scroll(self):
|
||||||
|
"""Longer scroll duration results in slower scroll steps."""
|
||||||
|
fast = calculate_scroll_step(2.0, 24)
|
||||||
|
slow = calculate_scroll_step(10.0, 24)
|
||||||
|
assert slow > fast
|
||||||
96
tests/test_layers.py
Normal file
96
tests/test_layers.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.layers module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine import layers
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderMessageOverlay:
|
||||||
|
"""Tests for render_message_overlay function."""
|
||||||
|
|
||||||
|
def test_no_message_returns_empty(self):
|
||||||
|
"""Returns empty list when msg is None."""
|
||||||
|
result, cache = layers.render_message_overlay(None, 80, 24, (None, None))
|
||||||
|
assert result == []
|
||||||
|
assert cache[0] is None
|
||||||
|
|
||||||
|
def test_message_returns_overlay_lines(self):
|
||||||
|
"""Returns non-empty list when message is present."""
|
||||||
|
msg = ("Test Title", "Test Body", time.monotonic())
|
||||||
|
result, cache = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||||
|
assert len(result) > 0
|
||||||
|
assert cache[0] is not None
|
||||||
|
|
||||||
|
def test_cache_key_changes_with_text(self):
|
||||||
|
"""Cache key changes when message text changes."""
|
||||||
|
msg1 = ("Title1", "Body1", time.monotonic())
|
||||||
|
msg2 = ("Title2", "Body2", time.monotonic())
|
||||||
|
|
||||||
|
_, cache1 = layers.render_message_overlay(msg1, 80, 24, (None, None))
|
||||||
|
_, cache2 = layers.render_message_overlay(msg2, 80, 24, cache1)
|
||||||
|
|
||||||
|
assert cache1[0] != cache2[0]
|
||||||
|
|
||||||
|
def test_cache_reuse_avoids_recomputation(self):
|
||||||
|
"""Cache is returned when same message is passed (interface test)."""
|
||||||
|
msg = ("Same Title", "Same Body", time.monotonic())
|
||||||
|
|
||||||
|
result1, cache1 = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||||
|
result2, cache2 = layers.render_message_overlay(msg, 80, 24, cache1)
|
||||||
|
|
||||||
|
assert len(result1) > 0
|
||||||
|
assert len(result2) > 0
|
||||||
|
assert cache1[0] == cache2[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFirehose:
|
||||||
|
"""Tests for render_firehose function."""
|
||||||
|
|
||||||
|
def test_no_firehose_returns_empty(self):
|
||||||
|
"""Returns empty list when firehose height is 0."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 0, 24)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_firehose_returns_lines(self):
|
||||||
|
"""Returns lines when firehose height > 0."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 4, 24)
|
||||||
|
assert len(result) == 4
|
||||||
|
|
||||||
|
def test_firehose_includes_ansi_escapes(self):
|
||||||
|
"""Returns lines containing ANSI escape sequences."""
|
||||||
|
items = [("Headline", "Source", "12:00")]
|
||||||
|
result = layers.render_firehose(items, 80, 1, 24)
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyGlitch:
|
||||||
|
"""Tests for apply_glitch function."""
|
||||||
|
|
||||||
|
def test_empty_buffer_unchanged(self):
|
||||||
|
"""Empty buffer is returned unchanged."""
|
||||||
|
result = layers.apply_glitch([], 0, 0.0, 80)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_buffer_length_preserved(self):
|
||||||
|
"""Buffer length is preserved after glitch application."""
|
||||||
|
buf = [f"\033[{i + 1};1Htest\033[K" for i in range(10)]
|
||||||
|
result = layers.apply_glitch(buf, 0, 0.5, 80)
|
||||||
|
assert len(result) == len(buf)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTickerZone:
|
||||||
|
"""Tests for render_ticker_zone function - focusing on interface."""
|
||||||
|
|
||||||
|
def test_returns_list(self):
|
||||||
|
"""Returns a list of strings."""
|
||||||
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
def test_returns_dict_for_cache(self):
|
||||||
|
"""Returns a dict for the noise cache."""
|
||||||
|
result, cache = layers.render_ticker_zone([], 0, 10, 80, {}, 0.0)
|
||||||
|
assert isinstance(cache, dict)
|
||||||
149
tests/test_mic.py
Normal file
149
tests/test_mic.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.mic module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine.events import MicLevelEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorImport:
|
||||||
|
"""Tests for module import behavior."""
|
||||||
|
|
||||||
|
def test_mic_monitor_imports_without_error(self):
|
||||||
|
"""MicMonitor can be imported even without sounddevice."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
assert MicMonitor is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorInit:
|
||||||
|
"""Tests for MicMonitor initialization."""
|
||||||
|
|
||||||
|
def test_init_sets_threshold(self):
|
||||||
|
"""Threshold is set correctly."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=60)
|
||||||
|
assert monitor.threshold_db == 60
|
||||||
|
|
||||||
|
def test_init_defaults(self):
|
||||||
|
"""Default values are set correctly."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert monitor.threshold_db == 50
|
||||||
|
|
||||||
|
def test_init_db_starts_at_negative(self):
|
||||||
|
"""_db starts at negative value."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert monitor.db == -99.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorProperties:
|
||||||
|
"""Tests for MicMonitor properties."""
|
||||||
|
|
||||||
|
def test_excess_returns_positive_when_above_threshold(self):
|
||||||
|
"""excess returns positive value when above threshold."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=50)
|
||||||
|
with patch.object(monitor, "_db", 60.0):
|
||||||
|
assert monitor.excess == 10.0
|
||||||
|
|
||||||
|
def test_excess_returns_zero_when_below_threshold(self):
|
||||||
|
"""excess returns zero when below threshold."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor(threshold_db=50)
|
||||||
|
with patch.object(monitor, "_db", 40.0):
|
||||||
|
assert monitor.excess == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorAvailable:
|
||||||
|
"""Tests for MicMonitor.available property."""
|
||||||
|
|
||||||
|
def test_available_is_bool(self):
|
||||||
|
"""available returns a boolean."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
assert isinstance(monitor.available, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorStop:
|
||||||
|
"""Tests for MicMonitor.stop method."""
|
||||||
|
|
||||||
|
def test_stop_does_nothing_when_no_stream(self):
|
||||||
|
"""stop() does nothing if no stream exists."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
monitor.stop()
|
||||||
|
assert monitor._stream is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMicMonitorEventEmission:
|
||||||
|
"""Tests for MicMonitor event emission."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
|
||||||
|
assert callback in monitor._subscribers
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
|
||||||
|
monitor.unsubscribe(callback)
|
||||||
|
|
||||||
|
assert callback not in monitor._subscribers
|
||||||
|
|
||||||
|
def test_emit_calls_subscribers(self):
|
||||||
|
"""_emit() calls all subscribers."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
monitor.subscribe(callback)
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
monitor._emit(event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].db_level == 60.0
|
||||||
|
|
||||||
|
def test_emit_handles_subscriber_exception(self):
|
||||||
|
"""_emit() handles exceptions in subscribers gracefully."""
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
|
||||||
|
monitor = MicMonitor()
|
||||||
|
|
||||||
|
def bad_callback(event):
|
||||||
|
raise RuntimeError("test")
|
||||||
|
|
||||||
|
monitor.subscribe(bad_callback)
|
||||||
|
event = MicLevelEvent(
|
||||||
|
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
monitor._emit(event)
|
||||||
122
tests/test_ntfy.py
Normal file
122
tests/test_ntfy.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.ntfy module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.events import NtfyMessageEvent
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerInit:
|
||||||
|
"""Tests for NtfyPoller initialization."""
|
||||||
|
|
||||||
|
def test_init_sets_defaults(self):
|
||||||
|
"""Default values are set correctly."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
assert poller.topic_url == "http://example.com/topic"
|
||||||
|
assert poller.reconnect_delay == 5
|
||||||
|
assert poller.display_secs == 30
|
||||||
|
|
||||||
|
def test_init_custom_values(self):
|
||||||
|
"""Custom values are set correctly."""
|
||||||
|
poller = NtfyPoller(
|
||||||
|
"http://example.com/topic", reconnect_delay=10, display_secs=60
|
||||||
|
)
|
||||||
|
assert poller.reconnect_delay == 10
|
||||||
|
assert poller.display_secs == 60
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerStart:
|
||||||
|
"""Tests for NtfyPoller.start method."""
|
||||||
|
|
||||||
|
@patch("engine.ntfy.threading.Thread")
|
||||||
|
def test_start_creates_daemon_thread(self, mock_thread):
|
||||||
|
"""start() creates and starts a daemon thread."""
|
||||||
|
mock_thread_instance = MagicMock()
|
||||||
|
mock_thread.return_value = mock_thread_instance
|
||||||
|
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
result = poller.start()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
args, kwargs = mock_thread.call_args
|
||||||
|
assert kwargs.get("daemon") is True
|
||||||
|
mock_thread_instance.start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerGetActiveMessage:
|
||||||
|
"""Tests for NtfyPoller.get_active_message method."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_message(self):
|
||||||
|
"""Returns None when no message has been received."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
result = poller.get_active_message()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerDismiss:
|
||||||
|
"""Tests for NtfyPoller.dismiss method."""
|
||||||
|
|
||||||
|
def test_dismiss_clears_message(self):
|
||||||
|
"""dismiss() clears the current message."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
|
||||||
|
with patch.object(poller, "_lock"):
|
||||||
|
poller._message = ("Title", "Body", time.monotonic())
|
||||||
|
poller.dismiss()
|
||||||
|
|
||||||
|
assert poller._message is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyPollerEventEmission:
|
||||||
|
"""Tests for NtfyPoller event emission."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
|
||||||
|
poller.subscribe(callback)
|
||||||
|
|
||||||
|
assert callback in poller._subscribers
|
||||||
|
|
||||||
|
def test_unsubscribe_removes_callback(self):
|
||||||
|
"""unsubscribe() removes a callback."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
poller.subscribe(callback)
|
||||||
|
|
||||||
|
poller.unsubscribe(callback)
|
||||||
|
|
||||||
|
assert callback not in poller._subscribers
|
||||||
|
|
||||||
|
def test_emit_calls_subscribers(self):
|
||||||
|
"""_emit() calls all subscribers."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
received = []
|
||||||
|
|
||||||
|
def callback(event):
|
||||||
|
received.append(event)
|
||||||
|
|
||||||
|
poller.subscribe(callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
poller._emit(event)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0].title == "Test"
|
||||||
|
|
||||||
|
def test_emit_handles_subscriber_exception(self):
|
||||||
|
"""_emit() handles exceptions in subscribers gracefully."""
|
||||||
|
poller = NtfyPoller("http://example.com/topic")
|
||||||
|
|
||||||
|
def bad_callback(event):
|
||||||
|
raise RuntimeError("test")
|
||||||
|
|
||||||
|
poller.subscribe(bad_callback)
|
||||||
|
event = NtfyMessageEvent(title="Test", body="Body")
|
||||||
|
poller._emit(event)
|
||||||
301
tests/test_render.py
Normal file
301
tests/test_render.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.render module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultGradients:
|
||||||
|
"""Tests for default gradient fallback functions."""
|
||||||
|
|
||||||
|
def test_default_green_gradient_length(self):
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_green_gradient_is_list(self):
|
||||||
|
"""_default_green_gradient returns a list."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_green_gradient_all_strings(self):
|
||||||
|
"""_default_green_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_length(self):
|
||||||
|
"""_default_magenta_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_is_list(self):
|
||||||
|
"""_default_magenta_gradient returns a list."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_all_strings(self):
|
||||||
|
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientUsesActiveTheme:
|
||||||
|
"""Tests for lr_gradient using active theme."""
|
||||||
|
|
||||||
|
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||||
|
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (cols=None)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(self):
|
||||||
|
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (should use fallback)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||||
|
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||||
|
# Custom gradient
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
# Should use the provided cols
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||||
|
"""lr_gradient accepts cols as keyword argument."""
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
# Call with cols as keyword
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientBasicFunctionality:
|
||||||
|
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||||
|
|
||||||
|
def test_lr_gradient_colors_non_space_chars(self):
|
||||||
|
"""lr_gradient colors non-space characters."""
|
||||||
|
rows = ["hello"]
|
||||||
|
|
||||||
|
# Set a theme for the test
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Result should have ANSI codes
|
||||||
|
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_preserves_spaces(self):
|
||||||
|
"""lr_gradient preserves spaces in output."""
|
||||||
|
rows = ["a b c"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved (not colored)
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_empty_rows(self):
|
||||||
|
"""lr_gradient handles empty rows correctly."""
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_multiple_rows(self):
|
||||||
|
"""lr_gradient handles multiple rows."""
|
||||||
|
rows = ["row1", "row2", "row3"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
|
||||||
|
class TestMsgGradient:
|
||||||
|
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||||
|
|
||||||
|
def test_msg_gradient_uses_active_theme(self):
|
||||||
|
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using theme's message_gradient
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes from the message gradient
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_fallback_when_no_theme(self):
|
||||||
|
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using default magenta
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_returns_colored_rows(self):
|
||||||
|
"""msg_gradient returns properly colored rows with animation offset."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
rows = ["NTFY", "ALERT"]
|
||||||
|
|
||||||
|
# Call with offset
|
||||||
|
result = render.msg_gradient(rows, offset=0.5)
|
||||||
|
|
||||||
|
# Should return same number of rows
|
||||||
|
assert len(result) == 2
|
||||||
|
# Both should be colored
|
||||||
|
assert all("\033[" in r for r in result)
|
||||||
|
# Should not be the original text
|
||||||
|
assert result != rows
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||||
|
"""msg_gradient produces different colors for different themes."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = ["TEST"]
|
||||||
|
|
||||||
|
# Get result with green theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result_green = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Get result with orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Results should be different (different message gradients)
|
||||||
|
assert result_green != result_orange
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_preserves_spacing(self):
|
||||||
|
"""msg_gradient preserves spaces in rows."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("purple")
|
||||||
|
rows = ["M E S S A G E"]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_empty_rows(self):
|
||||||
|
"""msg_gradient handles empty rows correctly."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Empty row should stay empty
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
93
tests/test_sources.py
Normal file
93
tests/test_sources.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.sources module - data validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine import sources
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeeds:
|
||||||
|
"""Tests for FEEDS data."""
|
||||||
|
|
||||||
|
def test_feeds_is_dict(self):
|
||||||
|
"""FEEDS is a dictionary."""
|
||||||
|
assert isinstance(sources.FEEDS, dict)
|
||||||
|
|
||||||
|
def test_feeds_has_entries(self):
|
||||||
|
"""FEEDS has feed entries."""
|
||||||
|
assert len(sources.FEEDS) > 0
|
||||||
|
|
||||||
|
def test_feeds_have_valid_urls(self):
|
||||||
|
"""All feeds have valid URL format."""
|
||||||
|
for name, url in sources.FEEDS.items():
|
||||||
|
assert name
|
||||||
|
assert url.startswith("http://") or url.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoetrySources:
|
||||||
|
"""Tests for POETRY_SOURCES data."""
|
||||||
|
|
||||||
|
def test_poetry_is_dict(self):
|
||||||
|
"""POETRY_SOURCES is a dictionary."""
|
||||||
|
assert isinstance(sources.POETRY_SOURCES, dict)
|
||||||
|
|
||||||
|
def test_poetry_has_entries(self):
|
||||||
|
"""POETRY_SOURCES has entries."""
|
||||||
|
assert len(sources.POETRY_SOURCES) > 0
|
||||||
|
|
||||||
|
def test_poetry_have_gutenberg_urls(self):
|
||||||
|
"""All poetry sources are from Gutenberg."""
|
||||||
|
for _name, url in sources.POETRY_SOURCES.items():
|
||||||
|
assert "gutenberg.org" in url
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceLangs:
|
||||||
|
"""Tests for SOURCE_LANGS mapping."""
|
||||||
|
|
||||||
|
def test_source_langs_is_dict(self):
|
||||||
|
"""SOURCE_LANGS is a dictionary."""
|
||||||
|
assert isinstance(sources.SOURCE_LANGS, dict)
|
||||||
|
|
||||||
|
def test_source_langs_valid_language_codes(self):
|
||||||
|
"""Language codes are valid ISO codes."""
|
||||||
|
valid_codes = {"de", "fr", "ja", "zh-cn", "ar", "hi"}
|
||||||
|
for code in sources.SOURCE_LANGS.values():
|
||||||
|
assert code in valid_codes
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocationLangs:
|
||||||
|
"""Tests for LOCATION_LANGS mapping."""
|
||||||
|
|
||||||
|
def test_location_langs_is_dict(self):
|
||||||
|
"""LOCATION_LANGS is a dictionary."""
|
||||||
|
assert isinstance(sources.LOCATION_LANGS, dict)
|
||||||
|
|
||||||
|
def test_location_langs_has_patterns(self):
|
||||||
|
"""LOCATION_LANGS has regex patterns."""
|
||||||
|
assert len(sources.LOCATION_LANGS) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestScriptFonts:
|
||||||
|
"""Tests for SCRIPT_FONTS mapping."""
|
||||||
|
|
||||||
|
def test_script_fonts_is_dict(self):
|
||||||
|
"""SCRIPT_FONTS is a dictionary."""
|
||||||
|
assert isinstance(sources.SCRIPT_FONTS, dict)
|
||||||
|
|
||||||
|
def test_script_fonts_has_paths(self):
|
||||||
|
"""All script fonts have paths."""
|
||||||
|
for _lang, path in sources.SCRIPT_FONTS.items():
|
||||||
|
assert path
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoUpper:
|
||||||
|
"""Tests for NO_UPPER set."""
|
||||||
|
|
||||||
|
def test_no_upper_is_set(self):
|
||||||
|
"""NO_UPPER is a set."""
|
||||||
|
assert isinstance(sources.NO_UPPER, set)
|
||||||
|
|
||||||
|
def test_no_upper_contains_scripts(self):
|
||||||
|
"""NO_UPPER contains non-Latin scripts."""
|
||||||
|
assert "zh-cn" in sources.NO_UPPER
|
||||||
|
assert "ja" in sources.NO_UPPER
|
||||||
|
assert "ar" in sources.NO_UPPER
|
||||||
130
tests/test_terminal.py
Normal file
130
tests/test_terminal.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.terminal module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from engine import terminal
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalDimensions:
|
||||||
|
"""Tests for terminal width/height functions."""
|
||||||
|
|
||||||
|
def test_tw_returns_columns(self):
|
||||||
|
"""tw() returns terminal width."""
|
||||||
|
with (
|
||||||
|
patch.object(sys.stdout, "isatty", return_value=True),
|
||||||
|
patch("os.get_terminal_size") as mock_size,
|
||||||
|
):
|
||||||
|
mock_size.return_value = io.StringIO("columns=120")
|
||||||
|
mock_size.columns = 120
|
||||||
|
result = terminal.tw()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_th_returns_lines(self):
|
||||||
|
"""th() returns terminal height."""
|
||||||
|
with (
|
||||||
|
patch.object(sys.stdout, "isatty", return_value=True),
|
||||||
|
patch("os.get_terminal_size") as mock_size,
|
||||||
|
):
|
||||||
|
mock_size.return_value = io.StringIO("lines=30")
|
||||||
|
mock_size.lines = 30
|
||||||
|
result = terminal.th()
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_tw_fallback_on_error(self):
|
||||||
|
"""tw() falls back to 80 on error."""
|
||||||
|
with patch("os.get_terminal_size", side_effect=OSError):
|
||||||
|
result = terminal.tw()
|
||||||
|
assert result == 80
|
||||||
|
|
||||||
|
def test_th_fallback_on_error(self):
|
||||||
|
"""th() falls back to 24 on error."""
|
||||||
|
with patch("os.get_terminal_size", side_effect=OSError):
|
||||||
|
result = terminal.th()
|
||||||
|
assert result == 24
|
||||||
|
|
||||||
|
|
||||||
|
class TestANSICodes:
|
||||||
|
"""Tests for ANSI escape code constants."""
|
||||||
|
|
||||||
|
def test_ansi_constants_exist(self):
|
||||||
|
"""All ANSI constants are defined."""
|
||||||
|
assert terminal.RST == "\033[0m"
|
||||||
|
assert terminal.BOLD == "\033[1m"
|
||||||
|
assert terminal.DIM == "\033[2m"
|
||||||
|
|
||||||
|
def test_green_shades_defined(self):
|
||||||
|
"""Green gradient colors are defined."""
|
||||||
|
assert terminal.G_HI == "\033[38;5;46m"
|
||||||
|
assert terminal.G_MID == "\033[38;5;34m"
|
||||||
|
assert terminal.G_LO == "\033[38;5;22m"
|
||||||
|
|
||||||
|
def test_white_shades_defined(self):
|
||||||
|
"""White/gray tones are defined."""
|
||||||
|
assert terminal.W_COOL == "\033[38;5;250m"
|
||||||
|
assert terminal.W_DIM == "\033[2;38;5;245m"
|
||||||
|
|
||||||
|
def test_cursor_controls_defined(self):
|
||||||
|
"""Cursor control codes are defined."""
|
||||||
|
assert "?" in terminal.CURSOR_OFF
|
||||||
|
assert "?" in terminal.CURSOR_ON
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeOut:
|
||||||
|
"""Tests for type_out function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_type_out_writes_text(self, mock_sleep, mock_stdout):
|
||||||
|
"""type_out writes text to stdout."""
|
||||||
|
with patch("random.random", return_value=0.5):
|
||||||
|
terminal.type_out("Hi", color=terminal.G_HI)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert len(output) > 0
|
||||||
|
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_type_out_uses_color(self, mock_sleep):
|
||||||
|
"""type_out applies color codes."""
|
||||||
|
with (
|
||||||
|
patch("sys.stdout", new_callable=io.StringIO),
|
||||||
|
patch("random.random", return_value=0.5),
|
||||||
|
):
|
||||||
|
terminal.type_out("Test", color=terminal.G_HI)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlowPrint:
|
||||||
|
"""Tests for slow_print function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_slow_print_writes_text(self, mock_sleep, mock_stdout):
|
||||||
|
"""slow_print writes text to stdout."""
|
||||||
|
terminal.slow_print("Hi", color=terminal.G_DIM, delay=0)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert len(output) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBootLn:
|
||||||
|
"""Tests for boot_ln function."""
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_boot_ln_writes_label_and_status(self, mock_sleep, mock_stdout):
|
||||||
|
"""boot_ln shows label and status."""
|
||||||
|
with patch("random.uniform", return_value=0):
|
||||||
|
terminal.boot_ln("Loading", "OK", ok=True)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert "Loading" in output
|
||||||
|
assert "OK" in output
|
||||||
|
|
||||||
|
@patch("sys.stdout", new_callable=io.StringIO)
|
||||||
|
@patch("time.sleep")
|
||||||
|
def test_boot_ln_error_status(self, mock_sleep, mock_stdout):
|
||||||
|
"""boot_ln shows red for error status."""
|
||||||
|
with patch("random.uniform", return_value=0):
|
||||||
|
terminal.boot_ln("Loading", "FAIL", ok=False)
|
||||||
|
output = mock_stdout.getvalue()
|
||||||
|
assert "FAIL" in output
|
||||||
169
tests/test_themes.py
Normal file
169
tests/test_themes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.themes module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeConstruction:
|
||||||
|
"""Tests for Theme class initialization."""
|
||||||
|
|
||||||
|
def test_theme_construction(self):
|
||||||
|
"""Theme stores name and gradients correctly."""
|
||||||
|
main_grad = ["color1", "color2", "color3"]
|
||||||
|
msg_grad = ["msg1", "msg2", "msg3"]
|
||||||
|
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||||
|
|
||||||
|
assert theme.name == "test_theme"
|
||||||
|
assert theme.main_gradient == main_grad
|
||||||
|
assert theme.message_gradient == msg_grad
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradientLength:
|
||||||
|
"""Tests for gradient length validation."""
|
||||||
|
|
||||||
|
def test_gradient_length_green(self):
|
||||||
|
"""Green theme has exactly 12 colors in each gradient."""
|
||||||
|
green = themes.THEME_REGISTRY["green"]
|
||||||
|
assert len(green.main_gradient) == 12
|
||||||
|
assert len(green.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_orange(self):
|
||||||
|
"""Orange theme has exactly 12 colors in each gradient."""
|
||||||
|
orange = themes.THEME_REGISTRY["orange"]
|
||||||
|
assert len(orange.main_gradient) == 12
|
||||||
|
assert len(orange.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_purple(self):
|
||||||
|
"""Purple theme has exactly 12 colors in each gradient."""
|
||||||
|
purple = themes.THEME_REGISTRY["purple"]
|
||||||
|
assert len(purple.main_gradient) == 12
|
||||||
|
assert len(purple.message_gradient) == 12
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeRegistry:
|
||||||
|
"""Tests for THEME_REGISTRY dictionary."""
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes(self):
|
||||||
|
"""Registry contains exactly three themes: green, orange, purple."""
|
||||||
|
assert len(themes.THEME_REGISTRY) == 3
|
||||||
|
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||||
|
|
||||||
|
def test_registry_values_are_themes(self):
|
||||||
|
"""All registry values are Theme instances."""
|
||||||
|
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||||
|
assert isinstance(theme, themes.Theme)
|
||||||
|
assert theme.name == theme_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTheme:
|
||||||
|
"""Tests for get_theme function."""
|
||||||
|
|
||||||
|
def test_get_theme_valid_green(self):
|
||||||
|
"""get_theme('green') returns correct green Theme."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert isinstance(green, themes.Theme)
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
def test_get_theme_valid_orange(self):
|
||||||
|
"""get_theme('orange') returns correct orange Theme."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert isinstance(orange, themes.Theme)
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
def test_get_theme_valid_purple(self):
|
||||||
|
"""get_theme('purple') returns correct purple Theme."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert isinstance(purple, themes.Theme)
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
def test_get_theme_invalid(self):
|
||||||
|
"""get_theme with invalid ID raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme("invalid_theme")
|
||||||
|
|
||||||
|
def test_get_theme_invalid_none(self):
|
||||||
|
"""get_theme with None raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreenTheme:
|
||||||
|
"""Tests for green theme specific values."""
|
||||||
|
|
||||||
|
def test_green_theme_unchanged(self):
|
||||||
|
"""Green theme maintains original color sequence."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||||
|
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||||
|
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
assert green.main_gradient == expected_main
|
||||||
|
assert green.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_green_theme_name(self):
|
||||||
|
"""Green theme has correct name."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrangeTheme:
|
||||||
|
"""Tests for orange theme specific values."""
|
||||||
|
|
||||||
|
def test_orange_theme_unchanged(self):
|
||||||
|
"""Orange theme maintains original color sequence."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||||
|
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||||
|
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
assert orange.main_gradient == expected_main
|
||||||
|
assert orange.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_orange_theme_name(self):
|
||||||
|
"""Orange theme has correct name."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurpleTheme:
|
||||||
|
"""Tests for purple theme specific values."""
|
||||||
|
|
||||||
|
def test_purple_theme_unchanged(self):
|
||||||
|
"""Purple theme maintains original color sequence."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||||
|
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||||
|
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
assert purple.main_gradient == expected_main
|
||||||
|
assert purple.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_purple_theme_name(self):
|
||||||
|
"""Purple theme has correct name."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeDataOnly:
|
||||||
|
"""Tests to ensure themes module has no problematic imports."""
|
||||||
|
|
||||||
|
def test_themes_module_imports(self):
|
||||||
|
"""themes module should be data-only without config/render imports."""
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(themes)
|
||||||
|
# Verify no imports of config or render (look for actual import statements)
|
||||||
|
lines = source.split('\n')
|
||||||
|
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||||
|
# Filter out empty and comment lines
|
||||||
|
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||||
|
# Should have no import lines
|
||||||
|
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||||
95
tests/test_types.py
Normal file
95
tests/test_types.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.types module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.types import (
|
||||||
|
Block,
|
||||||
|
FetchResult,
|
||||||
|
HeadlineItem,
|
||||||
|
items_to_tuples,
|
||||||
|
tuples_to_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeadlineItem:
|
||||||
|
"""Tests for HeadlineItem dataclass."""
|
||||||
|
|
||||||
|
def test_create_headline_item(self):
|
||||||
|
"""Can create HeadlineItem with required fields."""
|
||||||
|
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||||
|
assert item.title == "Test"
|
||||||
|
assert item.source == "Source"
|
||||||
|
assert item.timestamp == "12:00"
|
||||||
|
|
||||||
|
def test_to_tuple(self):
|
||||||
|
"""to_tuple returns correct tuple."""
|
||||||
|
item = HeadlineItem(title="Test", source="Source", timestamp="12:00")
|
||||||
|
assert item.to_tuple() == ("Test", "Source", "12:00")
|
||||||
|
|
||||||
|
def test_from_tuple(self):
|
||||||
|
"""from_tuple creates HeadlineItem from tuple."""
|
||||||
|
item = HeadlineItem.from_tuple(("Test", "Source", "12:00"))
|
||||||
|
assert item.title == "Test"
|
||||||
|
assert item.source == "Source"
|
||||||
|
assert item.timestamp == "12:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestItemsConversion:
|
||||||
|
"""Tests for list conversion functions."""
|
||||||
|
|
||||||
|
def test_items_to_tuples(self):
|
||||||
|
"""Converts list of HeadlineItem to list of tuples."""
|
||||||
|
items = [
|
||||||
|
HeadlineItem(title="A", source="S", timestamp="10:00"),
|
||||||
|
HeadlineItem(title="B", source="T", timestamp="11:00"),
|
||||||
|
]
|
||||||
|
result = items_to_tuples(items)
|
||||||
|
assert result == [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||||
|
|
||||||
|
def test_tuples_to_items(self):
|
||||||
|
"""Converts list of tuples to list of HeadlineItem."""
|
||||||
|
tuples = [("A", "S", "10:00"), ("B", "T", "11:00")]
|
||||||
|
result = tuples_to_items(tuples)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].title == "A"
|
||||||
|
assert result[1].title == "B"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchResult:
|
||||||
|
"""Tests for FetchResult dataclass."""
|
||||||
|
|
||||||
|
def test_create_fetch_result(self):
|
||||||
|
"""Can create FetchResult."""
|
||||||
|
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||||
|
result = FetchResult(items=items, linked=1, failed=0)
|
||||||
|
assert len(result.items) == 1
|
||||||
|
assert result.linked == 1
|
||||||
|
assert result.failed == 0
|
||||||
|
|
||||||
|
def test_to_legacy_tuple(self):
|
||||||
|
"""to_legacy_tuple returns correct format."""
|
||||||
|
items = [HeadlineItem(title="Test", source="Source", timestamp="12:00")]
|
||||||
|
result = FetchResult(items=items, linked=1, failed=0)
|
||||||
|
legacy = result.to_legacy_tuple()
|
||||||
|
assert legacy[0] == [("Test", "Source", "12:00")]
|
||||||
|
assert legacy[1] == 1
|
||||||
|
assert legacy[2] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestBlock:
|
||||||
|
"""Tests for Block dataclass."""
|
||||||
|
|
||||||
|
def test_create_block(self):
|
||||||
|
"""Can create Block."""
|
||||||
|
block = Block(
|
||||||
|
content=["line1", "line2"], color="\033[38;5;46m", meta_row_index=1
|
||||||
|
)
|
||||||
|
assert len(block.content) == 2
|
||||||
|
assert block.color == "\033[38;5;46m"
|
||||||
|
assert block.meta_row_index == 1
|
||||||
|
|
||||||
|
def test_to_legacy_tuple(self):
|
||||||
|
"""to_legacy_tuple returns correct format."""
|
||||||
|
block = Block(content=["line1"], color="green", meta_row_index=0)
|
||||||
|
legacy = block.to_legacy_tuple()
|
||||||
|
assert legacy == (["line1"], "green", 0)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user