Compare commits
11 Commits
feat/code-
...
bc20a35ea9
| Author | SHA1 | Date | |
|---|---|---|---|
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee |
110
AGENTS.md
110
AGENTS.md
@@ -1,110 +0,0 @@
|
|||||||
# 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
|
|
||||||
36
README.md
36
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,6 +68,21 @@ Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The select
|
|||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (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
|
### ntfy.sh
|
||||||
|
|
||||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
@@ -101,8 +116,6 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
|||||||
|
|
||||||
```
|
```
|
||||||
engine/
|
engine/
|
||||||
__init__.py package marker
|
|
||||||
app.py main(), font picker TUI, boot sequence, signal handler
|
|
||||||
config.py constants, CLI flags, glyph tables
|
config.py constants, CLI flags, glyph tables
|
||||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||||
@@ -114,14 +127,15 @@ engine/
|
|||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
scroll.py stream() frame loop + message rendering
|
scroll.py stream() frame loop + message rendering
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
frame.py scroll step calculation, timing
|
|
||||||
layers.py ticker zone, firehose, message overlay rendering
|
tests/
|
||||||
eventbus.py thread-safe event publishing for decoupled communication
|
test_config.py
|
||||||
events.py event types and definitions
|
test_filter.py
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
test_mic.py
|
||||||
emitters.py background emitters for ntfy and mic
|
test_ntfy.py
|
||||||
types.py type definitions and dataclasses
|
test_sources.py
|
||||||
|
test_terminal.py
|
||||||
```
|
```
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
|
|||||||
250
cmdline.py
250
cmdline.py
@@ -1,250 +0,0 @@
|
|||||||
#!/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
|
||||||
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
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -10,7 +10,7 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render, themes
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
@@ -65,6 +65,30 @@ def _read_picker_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
||||||
|
selected: Index of currently selected theme (0-2)
|
||||||
|
"""
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
||||||
|
)
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
def _normalize_preview_rows(rows):
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
non_empty = [r for r in rows if r.strip()]
|
non_empty = [r for r in rows if r.strip()]
|
||||||
@@ -131,6 +155,50 @@ def _draw_font_picker(faces, selected):
|
|||||||
print(f" {shown}")
|
print(f" {shown}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
||||||
|
|
||||||
|
Displays a menu of available themes and lets user select with arrow keys.
|
||||||
|
Non-interactive environments (piped stdin, CI) silently default to green.
|
||||||
|
"""
|
||||||
|
# Non-interactive fallback
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
def pick_font_face():
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
if not config.FONT_PICKER:
|
if not config.FONT_PICKER:
|
||||||
@@ -262,6 +330,7 @@ def main():
|
|||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_color_theme()
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
@@ -298,6 +367,7 @@ def main():
|
|||||||
save_cache(items)
|
save_cache(items)
|
||||||
elif config.MODE == "code":
|
elif config.MODE == "code":
|
||||||
from engine.fetch_code import fetch_code
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
print()
|
print()
|
||||||
|
|||||||
172
engine/config.py
172
engine/config.py
@@ -1,28 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Configuration constants, CLI flags, and glyph tables.
|
Configuration constants, CLI flags, and glyph tables.
|
||||||
Supports both global constants (backward compatible) and injected config for testing.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||||
|
|
||||||
|
|
||||||
def _arg_value(flag, argv: list[str] | None = None):
|
def _arg_value(flag):
|
||||||
"""Get value following a CLI flag, if present."""
|
"""Get value following a CLI flag, if present."""
|
||||||
argv = argv or sys.argv
|
if flag not in sys.argv:
|
||||||
if flag not in argv:
|
|
||||||
return None
|
return None
|
||||||
i = argv.index(flag)
|
i = sys.argv.index(flag)
|
||||||
return argv[i + 1] if i + 1 < len(argv) else None
|
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||||
|
|
||||||
|
|
||||||
def _arg_int(flag, default, argv: list[str] | None = None):
|
def _arg_int(flag, default):
|
||||||
"""Get int CLI argument with safe fallback."""
|
"""Get int CLI argument with safe fallback."""
|
||||||
raw = _arg_value(flag, argv)
|
raw = _arg_value(flag)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
@@ -56,141 +53,15 @@ def list_repo_font_files():
|
|||||||
return _list_font_files(FONT_DIR)
|
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 ──────────────────────────────────────────────
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = (
|
MODE = (
|
||||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
"poetry"
|
||||||
else "code" if "--code" in sys.argv
|
if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code"
|
||||||
|
if "--code" in sys.argv
|
||||||
else "news"
|
else "news"
|
||||||
)
|
)
|
||||||
FIREHOSE = "--firehose" in sys.argv
|
FIREHOSE = "--firehose" in sys.argv
|
||||||
@@ -235,3 +106,26 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||||
|
Defaults to "green"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""
|
|
||||||
Display output abstraction - allows swapping output backends.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
- init(width, height): Initialize display with terminal dimensions
|
|
||||||
- show(buffer): Render buffer (list of strings) to display
|
|
||||||
- clear(): Clear the display
|
|
||||||
- cleanup(): Shutdown display
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
|
||||||
"""Protocol for display backends."""
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
"""Initialize display with dimensions."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
"""Show buffer on display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Shutdown display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def get_monitor():
|
|
||||||
"""Get the performance monitor."""
|
|
||||||
try:
|
|
||||||
from engine.effects.performance import get_monitor as _get_monitor
|
|
||||||
|
|
||||||
return _get_monitor()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
|
||||||
"""ANSI terminal display backend."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
from engine.terminal import CURSOR_OFF
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
print(CURSOR_OFF, end="", flush=True)
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
sys.stdout.buffer.write("".join(buffer).encode())
|
|
||||||
sys.stdout.flush()
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
from engine.terminal import CLR
|
|
||||||
|
|
||||||
print(CLR, end="", flush=True)
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
from engine.terminal import CURSOR_ON
|
|
||||||
|
|
||||||
print(CURSOR_ON, end="", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
class NullDisplay:
|
|
||||||
"""Headless/null display - discards all output."""
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from engine.effects.performance import get_monitor
|
|
||||||
from engine.effects.registry import get_registry
|
|
||||||
|
|
||||||
_effect_chain_ref = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_effect_chain():
|
|
||||||
global _effect_chain_ref
|
|
||||||
if _effect_chain_ref is not None:
|
|
||||||
return _effect_chain_ref
|
|
||||||
try:
|
|
||||||
from engine.layers import get_effect_chain as _chain
|
|
||||||
|
|
||||||
return _chain()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_effect_chain_ref(chain) -> None:
|
|
||||||
global _effect_chain_ref
|
|
||||||
_effect_chain_ref = chain
|
|
||||||
|
|
||||||
|
|
||||||
def handle_effects_command(cmd: str) -> str:
|
|
||||||
"""Handle /effects command from NTFY message.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
/effects list - list all effects and their status
|
|
||||||
/effects <name> on - enable an effect
|
|
||||||
/effects <name> off - disable an effect
|
|
||||||
/effects <name> intensity <0.0-1.0> - set intensity
|
|
||||||
/effects reorder <name1>,<name2>,... - reorder pipeline
|
|
||||||
/effects stats - show performance statistics
|
|
||||||
"""
|
|
||||||
parts = cmd.strip().split()
|
|
||||||
if not parts or parts[0] != "/effects":
|
|
||||||
return "Unknown command"
|
|
||||||
|
|
||||||
registry = get_registry()
|
|
||||||
chain = _get_effect_chain()
|
|
||||||
|
|
||||||
if len(parts) == 1 or parts[1] == "list":
|
|
||||||
result = ["Effects:"]
|
|
||||||
for name, plugin in registry.list_all().items():
|
|
||||||
status = "ON" if plugin.config.enabled else "OFF"
|
|
||||||
intensity = plugin.config.intensity
|
|
||||||
result.append(f" {name}: {status} (intensity={intensity})")
|
|
||||||
if chain:
|
|
||||||
result.append(f"Order: {chain.get_order()}")
|
|
||||||
return "\n".join(result)
|
|
||||||
|
|
||||||
if parts[1] == "stats":
|
|
||||||
return _format_stats()
|
|
||||||
|
|
||||||
if parts[1] == "reorder" and len(parts) >= 3:
|
|
||||||
new_order = parts[2].split(",")
|
|
||||||
if chain and chain.reorder(new_order):
|
|
||||||
return f"Reordered pipeline: {new_order}"
|
|
||||||
return "Failed to reorder pipeline"
|
|
||||||
|
|
||||||
if len(parts) < 3:
|
|
||||||
return "Usage: /effects <name> on|off|intensity <value>"
|
|
||||||
|
|
||||||
effect_name = parts[1]
|
|
||||||
action = parts[2]
|
|
||||||
|
|
||||||
if effect_name not in registry.list_all():
|
|
||||||
return f"Unknown effect: {effect_name}"
|
|
||||||
|
|
||||||
if action == "on":
|
|
||||||
registry.enable(effect_name)
|
|
||||||
return f"Enabled: {effect_name}"
|
|
||||||
|
|
||||||
if action == "off":
|
|
||||||
registry.disable(effect_name)
|
|
||||||
return f"Disabled: {effect_name}"
|
|
||||||
|
|
||||||
if action == "intensity" and len(parts) >= 4:
|
|
||||||
try:
|
|
||||||
value = float(parts[3])
|
|
||||||
if not 0.0 <= value <= 1.0:
|
|
||||||
return "Intensity must be between 0.0 and 1.0"
|
|
||||||
plugin = registry.get(effect_name)
|
|
||||||
if plugin:
|
|
||||||
plugin.config.intensity = value
|
|
||||||
return f"Set {effect_name} intensity to {value}"
|
|
||||||
except ValueError:
|
|
||||||
return "Invalid intensity value"
|
|
||||||
|
|
||||||
return f"Unknown action: {action}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_stats() -> str:
|
|
||||||
monitor = get_monitor()
|
|
||||||
stats = monitor.get_stats()
|
|
||||||
|
|
||||||
if "error" in stats:
|
|
||||||
return stats["error"]
|
|
||||||
|
|
||||||
lines = ["Performance Stats:"]
|
|
||||||
|
|
||||||
pipeline = stats["pipeline"]
|
|
||||||
lines.append(
|
|
||||||
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if stats["effects"]:
|
|
||||||
lines.append(" Per-effect (avg ms):")
|
|
||||||
for name, effect_stats in stats["effects"].items():
|
|
||||||
lines.append(
|
|
||||||
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def show_effects_menu() -> str:
|
|
||||||
"""Generate effects menu text for display."""
|
|
||||||
registry = get_registry()
|
|
||||||
chain = _get_effect_chain()
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
|
||||||
"",
|
|
||||||
"Effects:",
|
|
||||||
]
|
|
||||||
|
|
||||||
for name, plugin in registry.list_all().items():
|
|
||||||
status = "ON" if plugin.config.enabled else "OFF"
|
|
||||||
intensity = plugin.config.intensity
|
|
||||||
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
|
||||||
|
|
||||||
if chain:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Controls:")
|
|
||||||
lines.append(" /effects <name> on|off")
|
|
||||||
lines.append(" /effects <name> intensity <0.0-1.0>")
|
|
||||||
lines.append(" /effects reorder name1,name2,...")
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""
|
|
||||||
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: ...
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -8,7 +8,6 @@ import pathlib
|
|||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
|
|
||||||
@@ -17,13 +16,9 @@ from engine.filter import skip, strip_tags
|
|||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
from engine.terminal import boot_ln
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
# Type alias for headline items
|
|
||||||
HeadlineTuple = tuple[str, str, str]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||||
def fetch_feed(url: str) -> Any | None:
|
def fetch_feed(url):
|
||||||
"""Fetch and parse a single RSS feed URL."""
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
@@ -33,9 +28,8 @@ def fetch_feed(url: str) -> Any | None:
|
|||||||
|
|
||||||
|
|
||||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
def fetch_all():
|
||||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
items = []
|
||||||
items: list[HeadlineTuple] = []
|
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for src, url in FEEDS.items():
|
for src, url in FEEDS.items():
|
||||||
feed = fetch_feed(url)
|
feed = fetch_feed(url)
|
||||||
@@ -65,7 +59,7 @@ def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
|||||||
|
|
||||||
|
|
||||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
def _fetch_gutenberg(url, label):
|
||||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""
|
|
||||||
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
260
engine/layers.py
@@ -1,260 +0,0 @@
|
|||||||
"""
|
|
||||||
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, lr_gradient_opposite
|
|
||||||
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 = lr_gradient_opposite(
|
|
||||||
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
|
|
||||||
@@ -4,8 +4,6 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import numpy as _np
|
import numpy as _np
|
||||||
@@ -16,9 +14,6 @@ except Exception:
|
|||||||
_HAS_MIC = False
|
_HAS_MIC = False
|
||||||
|
|
||||||
|
|
||||||
from engine.events import MicLevelEvent
|
|
||||||
|
|
||||||
|
|
||||||
class MicMonitor:
|
class MicMonitor:
|
||||||
"""Background mic stream that exposes current RMS dB level."""
|
"""Background mic stream that exposes current RMS dB level."""
|
||||||
|
|
||||||
@@ -26,7 +21,6 @@ class MicMonitor:
|
|||||||
self.threshold_db = threshold_db
|
self.threshold_db = threshold_db
|
||||||
self._db = -99.0
|
self._db = -99.0
|
||||||
self._stream = None
|
self._stream = None
|
||||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
@@ -43,23 +37,6 @@ class MicMonitor:
|
|||||||
"""dB above threshold (clamped to 0)."""
|
"""dB above threshold (clamped to 0)."""
|
||||||
return max(0.0, self._db - self.threshold_db)
|
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):
|
def start(self):
|
||||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||||
if not _HAS_MIC:
|
if not _HAS_MIC:
|
||||||
@@ -68,13 +45,6 @@ class MicMonitor:
|
|||||||
def _cb(indata, frames, t, status):
|
def _cb(indata, frames, t, status):
|
||||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
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:
|
try:
|
||||||
self._stream = _sd.InputStream(
|
self._stream = _sd.InputStream(
|
||||||
|
|||||||
@@ -16,12 +16,8 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
from engine.events import NtfyMessageEvent
|
|
||||||
|
|
||||||
|
|
||||||
class NtfyPoller:
|
class NtfyPoller:
|
||||||
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||||
@@ -32,24 +28,6 @@ class NtfyPoller:
|
|||||||
self.display_secs = display_secs
|
self.display_secs = display_secs
|
||||||
self._message = None # (title, body, monotonic_timestamp) or None
|
self._message = None # (title, body, monotonic_timestamp) or None
|
||||||
self._lock = threading.Lock()
|
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):
|
def start(self):
|
||||||
"""Start background stream thread. Returns True."""
|
"""Start background stream thread. Returns True."""
|
||||||
@@ -110,13 +88,6 @@ class NtfyPoller:
|
|||||||
data.get("message", ""),
|
data.get("message", ""),
|
||||||
time.monotonic(),
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(self.reconnect_delay)
|
time.sleep(self.reconnect_delay)
|
||||||
|
|||||||
@@ -15,9 +15,40 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
|||||||
from engine.terminal import RST
|
from engine.terminal import RST
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
# Left → right: white-hot leading edge fades to near-black
|
def _color_codes_to_ansi(color_codes):
|
||||||
GRAD_COLS = [
|
"""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;231m", # white
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
"\033[38;5;123m", # bright cyan
|
"\033[38;5;123m", # bright cyan
|
||||||
@@ -30,10 +61,12 @@ GRAD_COLS = [
|
|||||||
"\033[38;5;22m", # deep green
|
"\033[38;5;22m", # deep green
|
||||||
"\033[2;38;5;22m", # dim deep green
|
"\033[2;38;5;22m", # dim deep green
|
||||||
"\033[2;38;5;235m", # near black
|
"\033[2;38;5;235m", # near black
|
||||||
]
|
]
|
||||||
|
|
||||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
|
||||||
MSG_GRAD_COLS = [
|
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;231m", # white
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
"\033[38;5;219m", # bright pink
|
"\033[38;5;219m", # bright pink
|
||||||
@@ -46,7 +79,8 @@ MSG_GRAD_COLS = [
|
|||||||
"\033[38;5;89m", # deep maroon-magenta
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
"\033[2;38;5;235m", # near black
|
"\033[2;38;5;235m", # near black
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
@@ -189,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
def lr_gradient(rows, offset=0.0, cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
cols = grad_cols or GRAD_COLS
|
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)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -213,7 +253,30 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
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 ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
|||||||
231
engine/scroll.py
231
engine/scroll.py
@@ -1,92 +1,148 @@
|
|||||||
"""
|
"""
|
||||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||||
Orchestrates viewport, frame timing, and layers.
|
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.display import (
|
from engine.effects import (
|
||||||
Display,
|
fade_line,
|
||||||
TerminalDisplay,
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.display import (
|
from engine.render import big_wrap, lr_gradient, make_block, msg_gradient
|
||||||
get_monitor as _get_display_monitor,
|
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||||
)
|
|
||||||
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):
|
def stream(items, ntfy_poller, mic_monitor):
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
if display is None:
|
|
||||||
display = TerminalDisplay()
|
|
||||||
random.shuffle(items)
|
random.shuffle(items)
|
||||||
pool = list(items)
|
pool = list(items)
|
||||||
seen = set()
|
seen = set()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
w, h = tw(), th()
|
sys.stdout.write(CLR)
|
||||||
display.init(w, h)
|
sys.stdout.flush()
|
||||||
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)
|
|
||||||
|
|
||||||
|
w, h = tw(), th()
|
||||||
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||||
|
GAP = 3 # blank rows between headlines
|
||||||
|
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||||
|
|
||||||
|
# Taxonomy:
|
||||||
|
# - message: centered ntfy overlay panel
|
||||||
|
# - ticker: large headline text content
|
||||||
|
# - scroll: upward camera motion applied to ticker content
|
||||||
|
# - firehose: fixed carriage-return style strip pinned at bottom
|
||||||
|
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
|
||||||
active = []
|
active = []
|
||||||
scroll_cam = 0
|
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||||
ticker_next_y = ticker_view_h
|
ticker_next_y = (
|
||||||
|
ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||||
|
)
|
||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
msg_cache = (None, None)
|
|
||||||
frame_number = 0
|
|
||||||
|
|
||||||
while True:
|
def _noise_at(cy):
|
||||||
if queued >= config.HEADLINE_LIMIT and not active:
|
if cy not in noise_cache:
|
||||||
break
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
# Message color: bright cyan/white — distinct from headline greens
|
||||||
|
MSG_META = "\033[38;5;245m" # cool grey
|
||||||
|
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||||
|
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||||
|
|
||||||
|
while queued < config.HEADLINE_LIMIT or active:
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
w, h = tw(), th()
|
w, h = tw(), th()
|
||||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
ticker_view_h = h - fh
|
ticker_view_h = h - fh
|
||||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
|
||||||
|
|
||||||
|
# ── Check for ntfy message ────────────────────────
|
||||||
|
msg_h = 0
|
||||||
|
msg_overlay = []
|
||||||
msg = ntfy_poller.get_active_message()
|
msg = ntfy_poller.get_active_message()
|
||||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
|
||||||
|
|
||||||
buf = []
|
buf = []
|
||||||
ticker_h = ticker_view_h
|
if msg is not None:
|
||||||
|
m_title, m_body, m_ts = msg
|
||||||
|
# ── Message overlay: centered in the viewport ──
|
||||||
|
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
|
||||||
|
)
|
||||||
|
# Layout: rendered text + meta + border
|
||||||
|
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 # meta + border
|
||||||
|
panel_top = max(0, (h - panel_h) // 2)
|
||||||
|
row_idx = 0
|
||||||
|
for mr in msg_rows:
|
||||||
|
ln = vis_trunc(mr, w)
|
||||||
|
msg_overlay.append(
|
||||||
|
f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K"
|
||||||
|
)
|
||||||
|
row_idx += 1
|
||||||
|
# Meta line: title (if distinct) + source + countdown
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
msg_overlay.append(
|
||||||
|
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
|
||||||
|
)
|
||||||
|
row_idx += 1
|
||||||
|
# Border — constant boundary under message panel
|
||||||
|
bar = "\u2500" * (w - 4)
|
||||||
|
msg_overlay.append(
|
||||||
|
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||||
|
ticker_h = ticker_view_h - msg_h
|
||||||
|
|
||||||
|
# ── Ticker content + scroll motion (always runs) ──
|
||||||
scroll_motion_accum += config.FRAME_DT
|
scroll_motion_accum += config.FRAME_DT
|
||||||
while scroll_motion_accum >= scroll_step_interval:
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
scroll_motion_accum -= scroll_step_interval
|
scroll_motion_accum -= scroll_step_interval
|
||||||
scroll_cam += 1
|
scroll_cam += 1
|
||||||
|
|
||||||
|
# Enqueue new headlines when room at the bottom
|
||||||
while (
|
while (
|
||||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||||
and queued < config.HEADLINE_LIMIT
|
and queued < config.HEADLINE_LIMIT
|
||||||
):
|
):
|
||||||
from engine.effects import next_headline
|
|
||||||
from engine.render import make_block
|
|
||||||
|
|
||||||
t, src, ts = next_headline(pool, items, seen)
|
t, src, ts = next_headline(pool, items, seen)
|
||||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||||
ticker_next_y += len(ticker_content) + GAP
|
ticker_next_y += len(ticker_content) + GAP
|
||||||
queued += 1
|
queued += 1
|
||||||
|
|
||||||
|
# Prune off-screen blocks and stale noise
|
||||||
active = [
|
active = [
|
||||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||||
]
|
]
|
||||||
@@ -94,48 +150,71 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
|||||||
if k < scroll_cam:
|
if k < scroll_cam:
|
||||||
del noise_cache[k]
|
del noise_cache[k]
|
||||||
|
|
||||||
|
# Draw ticker zone (above fixed firehose strip)
|
||||||
|
top_zone = max(1, int(ticker_h * 0.25))
|
||||||
|
bot_zone = max(1, int(ticker_h * 0.10))
|
||||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
ticker_buf_start = len(buf)
|
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
||||||
|
for r in range(ticker_h):
|
||||||
ticker_buf, noise_cache = render_ticker_zone(
|
scr_row = r + 1 # 1-indexed ANSI screen row
|
||||||
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
cy = scroll_cam + r
|
||||||
)
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
buf.extend(ticker_buf)
|
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)
|
||||||
mic_excess = mic_monitor.excess
|
drawn = False
|
||||||
render_start = time.perf_counter()
|
for content, hc, by, midx in active:
|
||||||
|
cr = cy - by
|
||||||
if USE_EFFECT_CHAIN:
|
if 0 <= cr < len(content):
|
||||||
buf = process_effects(
|
raw = content[cr]
|
||||||
buf,
|
if cr != midx:
|
||||||
w,
|
colored = lr_gradient([raw], grad_offset)[0]
|
||||||
h,
|
|
||||||
scroll_cam,
|
|
||||||
ticker_h,
|
|
||||||
mic_excess,
|
|
||||||
grad_offset,
|
|
||||||
frame_number,
|
|
||||||
msg is not None,
|
|
||||||
items,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
colored = raw
|
||||||
firehose_buf = render_firehose(items, w, fh, h)
|
ln = vis_trunc(colored, w)
|
||||||
buf.extend(firehose_buf)
|
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")
|
||||||
|
|
||||||
|
# Glitch — base rate + mic-reactive spikes (ticker zone only)
|
||||||
|
mic_excess = mic_monitor.excess
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
if config.FIREHOSE and 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")
|
||||||
if msg_overlay:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
|
|
||||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
sys.stdout.buffer.write("".join(buf).encode())
|
||||||
monitor = _get_display_monitor()
|
sys.stdout.flush()
|
||||||
if monitor:
|
|
||||||
chars = sum(len(line) for line in buf)
|
|
||||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
|
||||||
|
|
||||||
display.show(buf)
|
|
||||||
|
|
||||||
|
# Precise frame timing
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
frame_number += 1
|
|
||||||
|
|
||||||
display.cleanup()
|
sys.stdout.write(CLR)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|||||||
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]
|
||||||
@@ -7,16 +7,26 @@ import json
|
|||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from engine.sources import LOCATION_LANGS
|
from engine.sources import LOCATION_LANGS
|
||||||
|
|
||||||
TRANSLATE_CACHE_SIZE = 500
|
_TRANSLATE_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
def detect_location_language(title):
|
||||||
def _translate_cached(title: str, target_lang: str) -> str:
|
"""Detect if headline mentions a location, return target language."""
|
||||||
"""Cached translation implementation."""
|
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, target_lang):
|
||||||
|
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||||
|
key = (title, target_lang)
|
||||||
|
if key in _TRANSLATE_CACHE:
|
||||||
|
return _TRANSLATE_CACHE[key]
|
||||||
try:
|
try:
|
||||||
q = urllib.parse.quote(title)
|
q = urllib.parse.quote(title)
|
||||||
url = (
|
url = (
|
||||||
@@ -29,18 +39,5 @@ def _translate_cached(title: str, target_lang: str) -> str:
|
|||||||
result = "".join(p[0] for p in data[0] if p[0]) or title
|
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||||
except Exception:
|
except Exception:
|
||||||
result = title
|
result = title
|
||||||
|
_TRANSLATE_CACHE[key] = result
|
||||||
return result
|
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)
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -22,7 +22,6 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"feedparser>=6.0.0",
|
"feedparser>=6.0.0",
|
||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"pyright>=1.1.408",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
236
tests/fixtures/__init__.py
vendored
236
tests/fixtures/__init__.py
vendored
@@ -1,236 +0,0 @@
|
|||||||
"""
|
|
||||||
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={},
|
|
||||||
)
|
|
||||||
@@ -164,138 +164,53 @@ class TestSetFontSelection:
|
|||||||
assert original_index == config.FONT_INDEX
|
assert original_index == config.FONT_INDEX
|
||||||
|
|
||||||
|
|
||||||
class TestConfigDataclass:
|
class TestActiveTheme:
|
||||||
"""Tests for Config dataclass."""
|
"""Tests for ACTIVE_THEME global and set_active_theme function."""
|
||||||
|
|
||||||
def test_config_has_required_fields(self):
|
def test_active_theme_initially_none(self):
|
||||||
"""Config has all required fields."""
|
"""ACTIVE_THEME should be None at module start."""
|
||||||
c = config.Config()
|
# Reset to None to test initial state
|
||||||
assert hasattr(c, "headline_limit")
|
original = config.ACTIVE_THEME
|
||||||
assert hasattr(c, "feed_timeout")
|
config.ACTIVE_THEME = None
|
||||||
assert hasattr(c, "mic_threshold_db")
|
try:
|
||||||
assert hasattr(c, "mode")
|
assert config.ACTIVE_THEME is None
|
||||||
assert hasattr(c, "firehose")
|
finally:
|
||||||
assert hasattr(c, "ntfy_topic")
|
config.ACTIVE_THEME = original
|
||||||
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):
|
def test_set_active_theme_green(self):
|
||||||
"""Config has sensible defaults."""
|
"""Setting green theme works correctly."""
|
||||||
c = config.Config()
|
config.set_active_theme("green")
|
||||||
assert c.headline_limit == 1000
|
assert config.ACTIVE_THEME is not None
|
||||||
assert c.feed_timeout == 10
|
assert config.ACTIVE_THEME.name == "green"
|
||||||
assert c.mic_threshold_db == 50
|
assert len(config.ACTIVE_THEME.main_gradient) == 12
|
||||||
assert c.mode == "news"
|
assert len(config.ACTIVE_THEME.message_gradient) == 12
|
||||||
assert c.firehose is False
|
|
||||||
assert c.ntfy_reconnect_delay == 5
|
|
||||||
assert c.message_display_secs == 30
|
|
||||||
|
|
||||||
def test_config_is_immutable(self):
|
def test_set_active_theme_default(self):
|
||||||
"""Config is frozen (immutable)."""
|
"""Default theme is green when not specified."""
|
||||||
c = config.Config()
|
config.set_active_theme()
|
||||||
with pytest.raises(AttributeError):
|
assert config.ACTIVE_THEME is not None
|
||||||
c.headline_limit = 500 # type: ignore
|
assert config.ACTIVE_THEME.name == "green"
|
||||||
|
|
||||||
def test_config_custom_values(self):
|
def test_set_active_theme_invalid(self):
|
||||||
"""Config accepts custom values."""
|
"""Invalid theme_id raises KeyError."""
|
||||||
c = config.Config(
|
with pytest.raises(KeyError):
|
||||||
headline_limit=500,
|
config.set_active_theme("nonexistent")
|
||||||
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"
|
|
||||||
|
|
||||||
|
def test_set_active_theme_all_themes(self):
|
||||||
|
"""Verify orange and purple themes work."""
|
||||||
|
# Test orange
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
assert config.ACTIVE_THEME.name == "orange"
|
||||||
|
|
||||||
class TestConfigFromArgs:
|
# Test purple
|
||||||
"""Tests for Config.from_args method."""
|
config.set_active_theme("purple")
|
||||||
|
assert config.ACTIVE_THEME.name == "purple"
|
||||||
|
|
||||||
def test_from_args_defaults(self):
|
def test_set_active_theme_idempotent(self):
|
||||||
"""from_args creates config with defaults from empty argv."""
|
"""Calling set_active_theme multiple times works."""
|
||||||
c = config.Config.from_args(["prog"])
|
config.set_active_theme("green")
|
||||||
assert c.mode == "news"
|
first_theme = config.ACTIVE_THEME
|
||||||
assert c.firehose is False
|
config.set_active_theme("green")
|
||||||
assert c.font_picker is True
|
second_theme = config.ACTIVE_THEME
|
||||||
|
assert first_theme.name == second_theme.name
|
||||||
def test_from_args_poetry_mode(self):
|
assert first_theme.name == "green"
|
||||||
"""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
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -2,11 +2,8 @@
|
|||||||
Tests for engine.mic module.
|
Tests for engine.mic module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from engine.events import MicLevelEvent
|
|
||||||
|
|
||||||
|
|
||||||
class TestMicMonitorImport:
|
class TestMicMonitorImport:
|
||||||
"""Tests for module import behavior."""
|
"""Tests for module import behavior."""
|
||||||
@@ -84,66 +81,3 @@ class TestMicMonitorStop:
|
|||||||
monitor = MicMonitor()
|
monitor = MicMonitor()
|
||||||
monitor.stop()
|
monitor.stop()
|
||||||
assert monitor._stream is None
|
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)
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Tests for engine.ntfy module.
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from engine.events import NtfyMessageEvent
|
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
|
||||||
@@ -69,54 +68,3 @@ class TestNtfyPollerDismiss:
|
|||||||
poller.dismiss()
|
poller.dismiss()
|
||||||
|
|
||||||
assert poller._message is None
|
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
|
||||||
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}"
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.viewport module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine import viewport
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportTw:
|
|
||||||
"""Tests for tw() function."""
|
|
||||||
|
|
||||||
def test_tw_returns_int(self):
|
|
||||||
"""tw() returns an integer."""
|
|
||||||
result = viewport.tw()
|
|
||||||
assert isinstance(result, int)
|
|
||||||
|
|
||||||
def test_tw_positive(self):
|
|
||||||
"""tw() returns a positive value."""
|
|
||||||
assert viewport.tw() > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportTh:
|
|
||||||
"""Tests for th() function."""
|
|
||||||
|
|
||||||
def test_th_returns_int(self):
|
|
||||||
"""th() returns an integer."""
|
|
||||||
result = viewport.th()
|
|
||||||
assert isinstance(result, int)
|
|
||||||
|
|
||||||
def test_th_positive(self):
|
|
||||||
"""th() returns a positive value."""
|
|
||||||
assert viewport.th() > 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportMoveTo:
|
|
||||||
"""Tests for move_to() function."""
|
|
||||||
|
|
||||||
def test_move_to_format(self):
|
|
||||||
"""move_to() returns correctly formatted ANSI escape."""
|
|
||||||
result = viewport.move_to(5, 10)
|
|
||||||
assert result == "\033[5;10H"
|
|
||||||
|
|
||||||
def test_move_to_default_col(self):
|
|
||||||
"""move_to() defaults to column 1."""
|
|
||||||
result = viewport.move_to(5)
|
|
||||||
assert result == "\033[5;1H"
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportClearScreen:
|
|
||||||
"""Tests for clear_screen() function."""
|
|
||||||
|
|
||||||
def test_clear_screen_format(self):
|
|
||||||
"""clear_screen() returns clear screen ANSI escape."""
|
|
||||||
result = viewport.clear_screen()
|
|
||||||
assert "\033[2J" in result
|
|
||||||
assert "\033[H" in result
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportClearLine:
|
|
||||||
"""Tests for clear_line() function."""
|
|
||||||
|
|
||||||
def test_clear_line_format(self):
|
|
||||||
"""clear_line() returns clear line ANSI escape."""
|
|
||||||
result = viewport.clear_line()
|
|
||||||
assert result == "\033[K"
|
|
||||||
Reference in New Issue
Block a user