Compare commits
12 Commits
docs/updat
...
dcd31469a5
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd31469a5 | |||
| 829c4ab63d | |||
| 22dd063baa | |||
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
coverage.xml
|
||||||
|
|||||||
92
AGENTS.md
92
AGENTS.md
@@ -16,7 +16,7 @@ This project uses:
|
|||||||
mise run install
|
mise run install
|
||||||
|
|
||||||
# Or equivalently:
|
# Or equivalently:
|
||||||
uv sync
|
uv sync --all-extras # includes mic, websocket, sixel support
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
@@ -25,10 +25,24 @@ uv sync
|
|||||||
mise run test # Run tests
|
mise run test # Run tests
|
||||||
mise run test-v # Run tests verbose
|
mise run test-v # Run tests verbose
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-cov # Run tests with coverage report
|
||||||
|
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||||
mise run lint # Run ruff linter
|
mise run lint # Run ruff linter
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run format # Run ruff formatter
|
mise run format # Run ruff formatter
|
||||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run run # Run mainline (terminal)
|
||||||
|
mise run run-poetry # Run with poetry feed
|
||||||
|
mise run run-firehose # Run in firehose mode
|
||||||
|
mise run run-websocket # Run with WebSocket display only
|
||||||
|
mise run run-sixel # Run with Sixel graphics display
|
||||||
|
mise run run-both # Run with both terminal and WebSocket
|
||||||
|
mise run run-client # Run both + open browser
|
||||||
|
mise run cmd # Run C&C command interface
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
## Git Hooks
|
||||||
@@ -46,9 +60,52 @@ hk init --mise
|
|||||||
mise run pre-commit
|
mise run pre-commit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: Always review the hk documentation before modifying `hk.pkl`:
|
||||||
|
- [hk Configuration Guide](https://hk.jdx.dev/configuration.html)
|
||||||
|
- [hk Hooks Reference](https://hk.jdx.dev/hooks.html)
|
||||||
|
- [hk Builtins](https://hk.jdx.dev/builtins.html)
|
||||||
|
|
||||||
The project uses hk configured in `hk.pkl`:
|
The project uses hk configured in `hk.pkl`:
|
||||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||||
- **pre-push**: runs ruff check
|
- **pre-push**: runs ruff check + benchmark hook
|
||||||
|
|
||||||
|
## Benchmark Runner
|
||||||
|
|
||||||
|
Run performance benchmarks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run benchmark # Run all benchmarks (text output)
|
||||||
|
mise run benchmark-json # Run benchmarks (JSON output)
|
||||||
|
mise run benchmark-report # Run benchmarks (Markdown report)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benchmark Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run benchmarks
|
||||||
|
uv run python -m engine.benchmark
|
||||||
|
|
||||||
|
# Run with specific displays/effects
|
||||||
|
uv run python -m engine.benchmark --displays null,terminal --effects fade,glitch
|
||||||
|
|
||||||
|
# Save baseline for hook comparisons
|
||||||
|
uv run python -m engine.benchmark --baseline
|
||||||
|
|
||||||
|
# Run in hook mode (compares against baseline)
|
||||||
|
uv run python -m engine.benchmark --hook
|
||||||
|
|
||||||
|
# Hook mode with custom threshold (default: 20% degradation)
|
||||||
|
uv run python -m engine.benchmark --hook --threshold 0.3
|
||||||
|
|
||||||
|
# Custom baseline location
|
||||||
|
uv run python -m engine.benchmark --hook --cache /path/to/cache.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hook Mode
|
||||||
|
|
||||||
|
The `--hook` mode compares current benchmarks against a saved baseline. If performance degrades beyond the threshold (default 20%), it exits with code 1. This is useful for preventing performance regressions in feature branches.
|
||||||
|
|
||||||
|
The pre-push hook runs benchmark in hook mode to catch performance regressions before pushing.
|
||||||
|
|
||||||
## Workflow Rules
|
## Workflow Rules
|
||||||
|
|
||||||
@@ -106,5 +163,32 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
|
|
||||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
||||||
|
- **effects/** - plugin architecture with performance monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
|
||||||
|
### Display System
|
||||||
|
|
||||||
|
- **Display abstraction** (`engine/display.py`): swap display backends via the Display protocol
|
||||||
|
- `TerminalDisplay` - ANSI terminal output
|
||||||
|
- `WebSocketDisplay` - broadcasts to web clients via WebSocket
|
||||||
|
- `SixelDisplay` - renders to Sixel graphics (pure Python, no C dependency)
|
||||||
|
- `MultiDisplay` - forwards to multiple displays simultaneously
|
||||||
|
|
||||||
|
- **WebSocket display** (`engine/websocket_display.py`): real-time frame broadcasting to web browsers
|
||||||
|
- WebSocket server on port 8765
|
||||||
|
- HTTP server on port 8766 (serves HTML client)
|
||||||
|
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||||
|
|
||||||
|
- **Display modes** (`--display` flag):
|
||||||
|
- `terminal` - Default ANSI terminal output
|
||||||
|
- `websocket` - Web browser display (requires websockets package)
|
||||||
|
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
||||||
|
- `both` - Terminal + WebSocket simultaneously
|
||||||
|
|
||||||
|
### Command & Control
|
||||||
|
|
||||||
|
- C&C uses separate ntfy topics for commands and responses
|
||||||
|
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||||
|
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||||
|
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||||
42
README.md
42
README.md
@@ -75,7 +75,6 @@ Mainline supports multiple display backends:
|
|||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
|
||||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
||||||
|
|
||||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||||
@@ -132,17 +131,10 @@ engine/
|
|||||||
translate.py Google Translate wrapper + region detection
|
translate.py Google Translate wrapper + region detection
|
||||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||||
effects/ plugin architecture for visual effects
|
effects/ plugin architecture for visual effects
|
||||||
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
|
||||||
registry.py effect registration and lookup
|
|
||||||
chain.py effect pipeline chaining
|
|
||||||
controller.py handles /effects commands
|
controller.py handles /effects commands
|
||||||
|
chain.py effect pipeline chaining
|
||||||
|
registry.py effect registration and lookup
|
||||||
performance.py performance monitoring
|
performance.py performance monitoring
|
||||||
legacy.py legacy functional effects
|
|
||||||
effects_plugins/ effect plugin implementations
|
|
||||||
noise.py noise effect
|
|
||||||
fade.py fade effect
|
|
||||||
glitch.py glitch effect
|
|
||||||
firehose.py firehose effect
|
|
||||||
fetch.py RSS/Gutenberg fetching + cache
|
fetch.py RSS/Gutenberg fetching + cache
|
||||||
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
|
||||||
@@ -155,15 +147,8 @@ engine/
|
|||||||
controller.py coordinates ntfy/mic monitoring
|
controller.py coordinates ntfy/mic monitoring
|
||||||
emitters.py background emitters
|
emitters.py background emitters
|
||||||
types.py type definitions
|
types.py type definitions
|
||||||
display/ Display backend system
|
display.py Display protocol (Terminal, WebSocket, Multi)
|
||||||
__init__.py DisplayRegistry, get_monitor
|
websocket_display.py WebSocket server for browser clients
|
||||||
backends/
|
|
||||||
terminal.py ANSI terminal display
|
|
||||||
websocket.py WebSocket server for browser clients
|
|
||||||
sixel.py Sixel graphics (pure Python)
|
|
||||||
null.py headless display for testing
|
|
||||||
multi.py forwards to multiple displays
|
|
||||||
benchmark.py performance benchmarking tool
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -187,23 +172,17 @@ With [mise](https://mise.jdx.dev/):
|
|||||||
```bash
|
```bash
|
||||||
mise run test # run test suite
|
mise run test # run test suite
|
||||||
mise run test-cov # run with coverage report
|
mise run test-cov # run with coverage report
|
||||||
|
|
||||||
mise run lint # ruff check
|
mise run lint # ruff check
|
||||||
mise run lint-fix # ruff check --fix
|
mise run lint-fix # ruff check --fix
|
||||||
mise run format # ruff format
|
mise run format # ruff format
|
||||||
|
|
||||||
mise run run # terminal display
|
mise run run # terminal display
|
||||||
mise run run-websocket # web display only
|
mise run run-websocket # web display only
|
||||||
mise run run-sixel # sixel graphics
|
|
||||||
mise run run-both # terminal + web
|
mise run run-both # terminal + web
|
||||||
mise run run-client # both + open browser
|
mise run run-client # both + open browser
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
mise run cmd # C&C command interface
|
||||||
mise run cmd-stats # watch effects stats
|
mise run cmd-stats # watch effects stats
|
||||||
|
|
||||||
mise run benchmark # run performance benchmarks
|
|
||||||
mise run benchmark-json # save as JSON
|
|
||||||
|
|
||||||
mise run topics-init # initialize ntfy topics
|
mise run topics-init # initialize ntfy topics
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,21 +191,8 @@ mise run topics-init # initialize ntfy topics
|
|||||||
```bash
|
```bash
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run pytest --cov=engine --cov-report=term-missing
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
|
|
||||||
# Run with mise
|
|
||||||
mise run test
|
|
||||||
mise run test-cov
|
|
||||||
|
|
||||||
# Run performance benchmarks
|
|
||||||
mise run benchmark
|
|
||||||
mise run benchmark-json
|
|
||||||
|
|
||||||
# Run benchmark hook mode (for CI)
|
|
||||||
uv run python -m engine.benchmark --hook
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
|
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
366
client/index.html
Normal file
366
client/index.html
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Terminal</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
body.fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body.fullscreen #controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid #333;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
body.fullscreen canvas {
|
||||||
|
border: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
#controls input {
|
||||||
|
width: 60px;
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected {
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
#status.disconnected {
|
||||||
|
color: #f44;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<canvas id="terminal"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||||
|
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||||
|
<button id="apply">Apply</button>
|
||||||
|
<button id="fullscreen">Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="disconnected">Connecting...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('terminal');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const colsInput = document.getElementById('cols');
|
||||||
|
const rowsInput = document.getElementById('rows');
|
||||||
|
const applyBtn = document.getElementById('apply');
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreen');
|
||||||
|
|
||||||
|
const CHAR_WIDTH = 9;
|
||||||
|
const CHAR_HEIGHT = 16;
|
||||||
|
|
||||||
|
const ANSI_COLORS = {
|
||||||
|
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||||
|
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||||
|
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||||
|
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = cols * CHAR_WIDTH;
|
||||||
|
canvas.height = rows * CHAR_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsi(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
let currentText = '';
|
||||||
|
let fg = '#cccccc';
|
||||||
|
let bg = '#000000';
|
||||||
|
let bold = false;
|
||||||
|
let i = 0;
|
||||||
|
let inEscape = false;
|
||||||
|
let escapeCode = '';
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
if (inEscape) {
|
||||||
|
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||||
|
escapeCode += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === 'm') {
|
||||||
|
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const num = parseInt(code) || 0;
|
||||||
|
|
||||||
|
if (num === 0) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
bg = '#000000';
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 1) {
|
||||||
|
bold = true;
|
||||||
|
} else if (num === 22) {
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 39) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
} else if (num === 49) {
|
||||||
|
bg = '#000000';
|
||||||
|
} else if (num >= 30 && num <= 37) {
|
||||||
|
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||||
|
} else if (num >= 40 && num <= 47) {
|
||||||
|
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||||
|
} else if (num >= 90 && num <= 97) {
|
||||||
|
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||||
|
} else if (num >= 100 && num <= 107) {
|
||||||
|
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||||
|
} else if (num >= 1 && num <= 256) {
|
||||||
|
// 256 colors
|
||||||
|
if (num < 16) {
|
||||||
|
fg = ANSI_COLORS[num] || '#cccccc';
|
||||||
|
} else if (num < 232) {
|
||||||
|
const c = num - 16;
|
||||||
|
const r = Math.floor(c / 36) * 51;
|
||||||
|
const g = Math.floor((c % 36) / 6) * 51;
|
||||||
|
const b = (c % 6) * 51;
|
||||||
|
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||||
|
} else {
|
||||||
|
const gray = (num - 232) * 10 + 8;
|
||||||
|
fg = `#${gray.toString(16).repeat(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = false;
|
||||||
|
escapeCode = '';
|
||||||
|
}
|
||||||
|
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = true;
|
||||||
|
escapeCode = '';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
currentText += char;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(text, x, y, lineHeight) {
|
||||||
|
const tokens = parseAnsi(text);
|
||||||
|
let xOffset = x;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.text) {
|
||||||
|
if (token.bold) {
|
||||||
|
ctx.font = 'bold 16px monospace';
|
||||||
|
} else {
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(token.text);
|
||||||
|
|
||||||
|
if (token.bg !== '#000000') {
|
||||||
|
ctx.fillStyle = token.bg;
|
||||||
|
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = token.fg;
|
||||||
|
ctx.fillText(token.text, xOffset, y);
|
||||||
|
xOffset += metrics.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'connected';
|
||||||
|
sendSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
status.textContent = 'Disconnected - Reconnecting...';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
status.textContent = 'Connection error';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'frame') {
|
||||||
|
cols = data.width || 80;
|
||||||
|
rows = data.height || 24;
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
render(data.lines || []);
|
||||||
|
} else if (data.type === 'clear') {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSize() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
width: parseInt(colsInput.value),
|
||||||
|
height: parseInt(rowsInput.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(lines) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const lineHeight = CHAR_HEIGHT;
|
||||||
|
const maxLines = Math.min(lines.length, rows);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const line = lines[i] || '';
|
||||||
|
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateViewportSize() {
|
||||||
|
const isFullscreen = document.fullscreenElement !== null;
|
||||||
|
const padding = isFullscreen ? 0 : 40;
|
||||||
|
const controlsHeight = isFullscreen ? 0 : 60;
|
||||||
|
const availableWidth = window.innerWidth - padding;
|
||||||
|
const availableHeight = window.innerHeight - controlsHeight;
|
||||||
|
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||||
|
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||||
|
sendSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', () => {
|
||||||
|
cols = parseInt(colsInput.value);
|
||||||
|
rows = parseInt(rowsInput.value);
|
||||||
|
resizeCanvas();
|
||||||
|
sendSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
document.documentElement.requestFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
resizeCanvas();
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Command-line utility for interacting with mainline via ntfy.
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ C&C works like a serial port:
|
|||||||
3. Cmdline polls for response
|
3. Cmdline polls for response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["FORCE_COLOR"] = "1"
|
||||||
|
os.environ["TERM"] = "xterm-256color"
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,894 +0,0 @@
|
|||||||
# Color Scheme Switcher Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
|
||||||
|
|
||||||
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Purpose | Change Type |
|
|
||||||
|------|---------|------------|
|
|
||||||
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
|
||||||
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
|
||||||
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
|
||||||
| `engine/scroll.py` | Update message gradient call | Modify |
|
|
||||||
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
|
||||||
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: Theme Data Module
|
|
||||||
|
|
||||||
### Task 1: Create themes.py with Theme class and registry
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `engine/themes.py`
|
|
||||||
- Test: `tests/test_themes.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for Theme class**
|
|
||||||
|
|
||||||
Create `tests/test_themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Test color themes and registry."""
|
|
||||||
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_construction():
|
|
||||||
"""Theme stores name and gradient lists."""
|
|
||||||
main = ["\033[1;38;5;231m"] * 12
|
|
||||||
msg = ["\033[1;38;5;225m"] * 12
|
|
||||||
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
|
||||||
|
|
||||||
assert theme.name == "Test Green"
|
|
||||||
assert theme.main_gradient == main
|
|
||||||
assert theme.message_gradient == msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_gradient_length():
|
|
||||||
"""Each gradient must have exactly 12 ANSI codes."""
|
|
||||||
for theme_id, theme in THEME_REGISTRY.items():
|
|
||||||
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
|
||||||
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_registry_has_three_themes():
|
|
||||||
"""Registry contains green, orange, purple."""
|
|
||||||
assert len(THEME_REGISTRY) == 3
|
|
||||||
assert "green" in THEME_REGISTRY
|
|
||||||
assert "orange" in THEME_REGISTRY
|
|
||||||
assert "purple" in THEME_REGISTRY
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_valid():
|
|
||||||
"""get_theme returns Theme object for valid ID."""
|
|
||||||
theme = get_theme("green")
|
|
||||||
assert isinstance(theme, Theme)
|
|
||||||
assert theme.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_invalid():
|
|
||||||
"""get_theme raises KeyError for invalid ID."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
get_theme("invalid_theme")
|
|
||||||
|
|
||||||
|
|
||||||
def test_green_theme_unchanged():
|
|
||||||
"""Green theme uses original green → magenta colors."""
|
|
||||||
green_theme = get_theme("green")
|
|
||||||
# First color should be white (bold)
|
|
||||||
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
|
||||||
# Last deep green
|
|
||||||
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
|
||||||
# Message gradient is magenta
|
|
||||||
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: FAIL (module doesn't exist)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
|
||||||
|
|
||||||
Create `engine/themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Color theme definitions and registry."""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
"""Initialize theme with display name and gradient lists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Display name (e.g., "Verdant Green")
|
|
||||||
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
|
||||||
message_gradient: List of 12 ANSI codes (white → complementary color)
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient
|
|
||||||
self.message_gradient = message_gradient
|
|
||||||
|
|
||||||
|
|
||||||
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
|
||||||
# Each gradient: white → primary/complementary, 12 steps total
|
|
||||||
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
|
||||||
|
|
||||||
_GREEN_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_GREEN_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;215m", # pale orange-white
|
|
||||||
"\033[38;5;209m", # bright orange
|
|
||||||
"\033[38;5;208m", # vibrant orange
|
|
||||||
"\033[38;5;202m", # orange
|
|
||||||
"\033[38;5;166m", # dark orange
|
|
||||||
"\033[38;5;130m", # burnt orange
|
|
||||||
"\033[38;5;94m", # rust
|
|
||||||
"\033[38;5;58m", # dark rust
|
|
||||||
"\033[38;5;94m", # rust (hold)
|
|
||||||
"\033[2;38;5;94m", # dim rust
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;33m", # bright blue
|
|
||||||
"\033[38;5;27m", # blue
|
|
||||||
"\033[38;5;21m", # deep blue
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;18m", # navy
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[2;38;5;18m", # dim navy
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale purple-white
|
|
||||||
"\033[38;5;177m", # bright purple
|
|
||||||
"\033[38;5;171m", # vibrant purple
|
|
||||||
"\033[38;5;165m", # purple
|
|
||||||
"\033[38;5;135m", # medium purple
|
|
||||||
"\033[38;5;129m", # purple
|
|
||||||
"\033[38;5;93m", # dark purple
|
|
||||||
"\033[38;5;57m", # deep purple
|
|
||||||
"\033[38;5;57m", # deep purple (hold)
|
|
||||||
"\033[2;38;5;57m", # dim deep purple
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;226m", # pale yellow-white
|
|
||||||
"\033[38;5;226m", # bright yellow
|
|
||||||
"\033[38;5;220m", # yellow
|
|
||||||
"\033[38;5;220m", # yellow (hold)
|
|
||||||
"\033[38;5;184m", # dark yellow
|
|
||||||
"\033[38;5;184m", # dark yellow (hold)
|
|
||||||
"\033[38;5;178m", # olive-yellow
|
|
||||||
"\033[38;5;178m", # olive-yellow (hold)
|
|
||||||
"\033[38;5;172m", # golden
|
|
||||||
"\033[2;38;5;172m", # dim golden
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
THEME_REGISTRY = {
|
|
||||||
"green": Theme(
|
|
||||||
name="Verdant Green",
|
|
||||||
main_gradient=_GREEN_MAIN,
|
|
||||||
message_gradient=_GREEN_MESSAGE,
|
|
||||||
),
|
|
||||||
"orange": Theme(
|
|
||||||
name="Molten Orange",
|
|
||||||
main_gradient=_ORANGE_MAIN,
|
|
||||||
message_gradient=_ORANGE_MESSAGE,
|
|
||||||
),
|
|
||||||
"purple": Theme(
|
|
||||||
name="Violet Purple",
|
|
||||||
main_gradient=_PURPLE_MAIN,
|
|
||||||
message_gradient=_PURPLE_MESSAGE,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme(theme_id: str) -> Theme:
|
|
||||||
"""Retrieve a theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Theme object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id not found in registry
|
|
||||||
"""
|
|
||||||
if theme_id not in THEME_REGISTRY:
|
|
||||||
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
|
||||||
return THEME_REGISTRY[theme_id]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: PASS (all 6 tests)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/themes.py tests/test_themes.py
|
|
||||||
git commit -m "feat: create Theme class and registry with finalized color gradients
|
|
||||||
|
|
||||||
- Define Theme class to encapsulate name and main/message gradients
|
|
||||||
- Create THEME_REGISTRY with green, orange, purple themes
|
|
||||||
- Each gradient has 12 ANSI 256-color codes finalized
|
|
||||||
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
|
||||||
- Add get_theme() lookup with error handling
|
|
||||||
- Add comprehensive unit tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 2: Config Integration
|
|
||||||
|
|
||||||
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/config.py:1-30`
|
|
||||||
- Test: `tests/test_config.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for config changes**
|
|
||||||
|
|
||||||
Add to `tests/test_config.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_active_theme_initially_none():
|
|
||||||
"""ACTIVE_THEME is None before initialization."""
|
|
||||||
# This test may fail if config is already initialized
|
|
||||||
# We'll set it to None first for testing
|
|
||||||
import engine.config
|
|
||||||
engine.config.ACTIVE_THEME = None
|
|
||||||
assert engine.config.ACTIVE_THEME is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_green():
|
|
||||||
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
set_active_theme("green")
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME is not None
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
assert config.ACTIVE_THEME == get_theme("green")
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_default():
|
|
||||||
"""set_active_theme() with no args defaults to green."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
set_active_theme()
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_invalid():
|
|
||||||
"""set_active_theme() with invalid ID raises KeyError."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
set_active_theme("invalid")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
Expected: FAIL (functions don't exist yet)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
|
||||||
|
|
||||||
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active color theme. Defaults to 'green' if not specified.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is invalid
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# DELETED:
|
|
||||||
# GRAD_COLS = [...]
|
|
||||||
# MSG_GRAD_COLS = [...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 4 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify existing config tests still pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (all existing + new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/config.py tests/test_config.py
|
|
||||||
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
|
||||||
|
|
||||||
- Add ACTIVE_THEME global (initialized to None)
|
|
||||||
- Add set_active_theme(theme_id) function with green default
|
|
||||||
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
|
||||||
- Add comprehensive tests for theme setting"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 3: Render Pipeline Integration
|
|
||||||
|
|
||||||
### Task 3: Update render.py to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/render.py:15-220`
|
|
||||||
- Test: `tests/test_render.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
|
||||||
|
|
||||||
Add to `tests/test_render.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_lr_gradient_uses_active_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
|
||||||
from engine import config, render
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
# Set orange theme
|
|
||||||
config.set_active_theme("orange")
|
|
||||||
|
|
||||||
# Create simple rows
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Result should start with first color from orange main gradient
|
|
||||||
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
|
||||||
from engine import config, render
|
|
||||||
|
|
||||||
# Clear active theme
|
|
||||||
config.ACTIVE_THEME = None
|
|
||||||
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Should not crash and should return something
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_green_gradient_length():
|
|
||||||
"""_default_green_gradient returns 12 colors."""
|
|
||||||
from engine import render
|
|
||||||
|
|
||||||
colors = render._default_green_gradient()
|
|
||||||
assert len(colors) == 12
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Expected: FAIL (function signature doesn't match)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
"""
|
|
||||||
Render rows through a left-to-right color sweep.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text rows to colorize
|
|
||||||
offset: Gradient position offset (for animation)
|
|
||||||
cols: Optional list of color codes. If None, uses active theme.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of colorized rows
|
|
||||||
"""
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rest of function unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
|
||||||
|
|
||||||
Add to `engine/render.py` before `lr_gradient()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (original colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_magenta_gradient():
|
|
||||||
"""Fallback magenta gradient (original message colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 3 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run full render test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing tests may need adjustment for mocking)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/render.py tests/test_render.py
|
|
||||||
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
|
||||||
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
|
||||||
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
|
||||||
- Add tests for theme-aware and fallback gradient rendering"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4: Message Gradient Integration
|
|
||||||
|
|
||||||
### Task 4: Update scroll.py to use message gradient from config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/scroll.py:85-95`
|
|
||||||
- Test: existing `tests/test_scroll.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
|
||||||
|
|
||||||
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
|
||||||
|
|
||||||
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
|
||||||
|
|
||||||
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
|
||||||
|
|
||||||
Old code:
|
|
||||||
```python
|
|
||||||
# Some variation of:
|
|
||||||
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
```
|
|
||||||
|
|
||||||
New code:
|
|
||||||
```python
|
|
||||||
from engine import config
|
|
||||||
msg_cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else render._default_magenta_gradient()
|
|
||||||
)
|
|
||||||
rows = lr_gradient(rows, offset, msg_cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the helper approach (create `msg_gradient()` in render.py):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in scroll.py:
|
|
||||||
```python
|
|
||||||
rows = render.msg_gradient(rows, offset)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run existing scroll tests**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_scroll.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing functionality unchanged)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/scroll.py engine/render.py
|
|
||||||
git commit -m "feat: update scroll.py to use theme message gradient
|
|
||||||
|
|
||||||
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
|
||||||
- Use fallback magenta gradient when theme not initialized
|
|
||||||
- Ensure ntfy messages render in complementary color from selected theme"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 5: Color Picker UI
|
|
||||||
|
|
||||||
### Task 5: Create pick_color_theme() function in app.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/app.py:1-300`
|
|
||||||
- Test: manual/integration (interactive)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write helper functions for color picker UI**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add before `pick_font_face()` function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu."""
|
|
||||||
import sys
|
|
||||||
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
|
||||||
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create pick_color_theme() function**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add after helper function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use green
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... existing signal handler setup ...
|
|
||||||
|
|
||||||
pick_color_theme() # NEW LINE - before font picker
|
|
||||||
pick_font_face()
|
|
||||||
|
|
||||||
# ... rest of main unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual test - run in interactive terminal**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- See color theme picker menu before font picker
|
|
||||||
- Can navigate with ↑/↓ or j/k
|
|
||||||
- Can select with Enter or q
|
|
||||||
- Selected theme applies to scrolling headlines
|
|
||||||
- Can select different themes and see colors change
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
|
||||||
|
|
||||||
Run: `echo "" | python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- No color picker menu shown
|
|
||||||
- Defaults to green theme
|
|
||||||
- App runs without error
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/app.py
|
|
||||||
git commit -m "feat: add pick_color_theme() UI and integration
|
|
||||||
|
|
||||||
- Create _draw_color_picker() to render menu
|
|
||||||
- Create pick_color_theme() function mirroring font picker pattern
|
|
||||||
- Integrate into main() before font picker
|
|
||||||
- Fallback to green theme in non-interactive environments
|
|
||||||
- Support arrow keys and j/k navigation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6: Integration & Validation
|
|
||||||
|
|
||||||
### Task 6: End-to-end testing and cleanup
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: All modified files
|
|
||||||
- Verify: App functionality
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/ -v`
|
|
||||||
|
|
||||||
Expected: PASS (all tests, including new ones)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run linter**
|
|
||||||
|
|
||||||
Run: `ruff check engine/ mainline.py`
|
|
||||||
|
|
||||||
Expected: No errors (fix any style issues)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual integration test - green theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Verdant Green" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in green → deep green
|
|
||||||
- ntfy messages render in magenta gradient
|
|
||||||
- Both work correctly during streaming
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual integration test - orange theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Molten Orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in orange → deep orange
|
|
||||||
- ntfy messages render in blue gradient
|
|
||||||
- Colors are visually distinct from green
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual integration test - purple theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Violet Purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in purple → deep purple
|
|
||||||
- ntfy messages render in yellow gradient
|
|
||||||
- Colors are visually distinct from green and orange
|
|
||||||
|
|
||||||
- [ ] **Step 6: Test poetry mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --poetry`
|
|
||||||
|
|
||||||
Then select "orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Poetry mode works with color picker
|
|
||||||
- Colors apply to poetry rendering
|
|
||||||
|
|
||||||
- [ ] **Step 7: Test code mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --code`
|
|
||||||
|
|
||||||
Then select "purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Code mode works with color picker
|
|
||||||
- Colors apply to code rendering
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify acceptance criteria**
|
|
||||||
|
|
||||||
✓ Color picker displays 3 theme options at startup
|
|
||||||
✓ Selection applies to all headline and message gradients
|
|
||||||
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
✓ No persistence between runs (each run picks fresh)
|
|
||||||
✓ Non-TTY environments default to green without error
|
|
||||||
✓ Architecture supports future random/animation modes
|
|
||||||
✓ All gradient color codes finalized with no TBD values
|
|
||||||
|
|
||||||
- [ ] **Step 9: Final commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat: color scheme switcher implementation complete
|
|
||||||
|
|
||||||
Closes color-pick feature with:
|
|
||||||
- Three selectable color themes (green, orange, purple)
|
|
||||||
- Interactive menu at startup (mirrors font picker UI)
|
|
||||||
- Complementary colors for ntfy message queue
|
|
||||||
- Fallback to green in non-interactive environments
|
|
||||||
- All tests passing, manual validation complete"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Create feature branch PR summary**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Color Scheme Switcher
|
|
||||||
|
|
||||||
Implements interactive color theme selection for Mainline news ticker.
|
|
||||||
|
|
||||||
### What's New
|
|
||||||
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
|
||||||
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
|
||||||
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
|
||||||
- Fresh theme selection each run (no persistence)
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
|
||||||
- `engine/render.py` (theme-aware gradients)
|
|
||||||
- `engine/scroll.py` (message gradient integration)
|
|
||||||
- `engine/app.py` (pick_color_theme UI)
|
|
||||||
- `tests/test_themes.py` (new theme tests)
|
|
||||||
- `README.md` (documentation)
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
All met. App fully tested and ready for merge.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
|
||||||
- [ ] Full suite: `pytest tests/ -v`
|
|
||||||
- [ ] Linting: `ruff check engine/ mainline.py`
|
|
||||||
- [ ] Manual: Green theme selection
|
|
||||||
- [ ] Manual: Orange theme selection
|
|
||||||
- [ ] Manual: Purple theme selection
|
|
||||||
- [ ] Manual: Poetry mode with colors
|
|
||||||
- [ ] Manual: Code mode with colors
|
|
||||||
- [ ] Manual: Non-TTY fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `themes.py` is data-only; never import config or render to prevent cycles
|
|
||||||
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
|
||||||
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
|
||||||
- Message gradients use complementary colors; lookup in scroll.py
|
|
||||||
- Each gradient has 12 colors; verify length in tests
|
|
||||||
- No persistence; fresh picker each run
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Code Scroll Mode — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Branch:** feat/code-scroll
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Mirror the existing `--poetry` mode pattern as closely as possible
|
|
||||||
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
|
||||||
- No changes to `scroll.py` or the render pipeline
|
|
||||||
- The item tuple shape `(text, src, ts)` is unchanged
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Files
|
|
||||||
|
|
||||||
### `engine/fetch_code.py`
|
|
||||||
|
|
||||||
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
|
||||||
|
|
||||||
**Algorithm:**
|
|
||||||
|
|
||||||
1. Glob `engine/*.py` in sorted order
|
|
||||||
2. For each file:
|
|
||||||
a. Read source text
|
|
||||||
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
|
||||||
c. Iterate source lines (1-indexed). Skip if:
|
|
||||||
- The stripped line is empty
|
|
||||||
- The stripped line starts with `#`
|
|
||||||
d. For each kept line emit:
|
|
||||||
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
|
||||||
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
|
||||||
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
|
||||||
3. Return `(items, len(items), 0)`
|
|
||||||
|
|
||||||
**Scope label rules:**
|
|
||||||
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
|
||||||
- `ClassDef` → `name` (no parens)
|
|
||||||
- No enclosing node → `<module>`
|
|
||||||
|
|
||||||
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modified Files
|
|
||||||
|
|
||||||
### `engine/config.py`
|
|
||||||
|
|
||||||
Extend `MODE` detection to recognise `--code`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
MODE = (
|
|
||||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code" if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `engine/app.py`
|
|
||||||
|
|
||||||
**Subtitle line** — extend the subtitle dict:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_subtitle = {
|
|
||||||
"poetry": "literary consciousness stream",
|
|
||||||
"code": "source consciousness stream",
|
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
```
|
|
||||||
|
|
||||||
No cache save/load — local source files are read instantly and change only on disk writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/*.py (sorted)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
fetch_code()
|
|
||||||
│ ast.parse → scope map
|
|
||||||
│ filter blank + comment lines
|
|
||||||
│ emit (line, scope(), engine.module)
|
|
||||||
▼
|
|
||||||
items: List[Tuple[str, str, str]]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
stream(items, ntfy, mic) ← unchanged
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
next_headline() shuffles + recycles automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
|
||||||
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
New file: `tests/test_fetch_code.py`
|
|
||||||
|
|
||||||
| Test | Assertion |
|
|
||||||
|------|-----------|
|
|
||||||
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
|
||||||
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
|
||||||
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
|
||||||
|
|
||||||
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 mainline.py --code # source consciousness mode
|
|
||||||
uv run mainline.py --code
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Syntax highlighting / token-aware coloring (can be added later)
|
|
||||||
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
|
||||||
- Caching code items to disk
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Color Scheme Switcher Design
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Status:** Revised after review
|
|
||||||
**Scope:** Interactive color theme selection for Mainline news ticker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
|
||||||
|
|
||||||
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
**Functional:**
|
|
||||||
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
|
||||||
2. Main headline gradient uses the selected primary color (white → color)
|
|
||||||
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
|
||||||
4. Selection is fresh each run (no persistence)
|
|
||||||
5. Design supports future "random rotation" mode without refactoring
|
|
||||||
|
|
||||||
**Complementary colors (precise opposites):**
|
|
||||||
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
|
||||||
- Orange (38;5;208) → Blue (38;5;21)
|
|
||||||
- Purple (38;5;129) → Yellow (38;5;226)
|
|
||||||
|
|
||||||
**Non-functional:**
|
|
||||||
- Reuse the existing font picker pattern for UI consistency
|
|
||||||
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
|
||||||
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
|
||||||
- Font picker UI remains hardcoded green for visual continuity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### New Module: `engine/themes.py`
|
|
||||||
|
|
||||||
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient # white → primary color
|
|
||||||
self.message_gradient = message_gradient # white → complementary
|
|
||||||
```
|
|
||||||
|
|
||||||
**Theme Registry:**
|
|
||||||
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
|
||||||
|
|
||||||
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
|
||||||
```
|
|
||||||
[
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright color
|
|
||||||
"\033[38;5;40m", # color
|
|
||||||
"\033[38;5;34m", # medium color
|
|
||||||
"\033[38;5;28m", # dark color
|
|
||||||
"\033[38;5;22m", # deep color
|
|
||||||
"\033[2;38;5;22m", # dim deep color
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Finalized color codes:**
|
|
||||||
|
|
||||||
**Green (primary: 22, complementary: 89)** — unchanged from current
|
|
||||||
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
|
||||||
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
|
||||||
|
|
||||||
**Orange (primary: 208, complementary: 21)**
|
|
||||||
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
|
||||||
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
|
||||||
|
|
||||||
**Purple (primary: 129, complementary: 226)**
|
|
||||||
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
|
||||||
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
|
||||||
|
|
||||||
**Public API:**
|
|
||||||
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
|
||||||
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/config.py`
|
|
||||||
|
|
||||||
**New globals:**
|
|
||||||
```python
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
|
||||||
```
|
|
||||||
|
|
||||||
**New function:**
|
|
||||||
```python
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme. Defaults to 'green' if not specified."""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Called by `app.pick_color_theme()` with user selection
|
|
||||||
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
|
||||||
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
|
||||||
|
|
||||||
**Removal:**
|
|
||||||
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/render.py`
|
|
||||||
|
|
||||||
**Updated gradient access in existing functions:**
|
|
||||||
|
|
||||||
Current pattern (will be removed):
|
|
||||||
```python
|
|
||||||
GRAD_COLS = [...] # hardcoded green
|
|
||||||
MSG_GRAD_COLS = [...] # hardcoded magenta
|
|
||||||
```
|
|
||||||
|
|
||||||
New pattern — update `lr_gradient()` function:
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient())
|
|
||||||
# ... rest of function unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
**Define fallback:**
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (current colors)."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
|
||||||
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
|
||||||
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
|
||||||
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Message gradient handling:**
|
|
||||||
|
|
||||||
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
|
||||||
```python
|
|
||||||
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
# Use:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
or define a helper:
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/app.py`
|
|
||||||
|
|
||||||
**New function: `pick_color_theme()`**
|
|
||||||
|
|
||||||
Mirrors `pick_font_face()` pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use default
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker (similar to font picker)
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
|
||||||
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Placement in `main()`:**
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... signal handler setup ...
|
|
||||||
pick_color_theme() # NEW — before title/subtitle
|
|
||||||
pick_font_face()
|
|
||||||
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User starts: mainline.py
|
|
||||||
↓
|
|
||||||
main() called
|
|
||||||
↓
|
|
||||||
pick_color_theme()
|
|
||||||
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
|
||||||
→ If not TTY: silently call config.set_active_theme("green")
|
|
||||||
↓
|
|
||||||
pick_font_face() — renders in hardcoded green UI colors
|
|
||||||
↓
|
|
||||||
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
|
||||||
↓
|
|
||||||
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
|
||||||
↓
|
|
||||||
On exit: no persistence
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Initialization Guarantee
|
|
||||||
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
|
||||||
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
|
||||||
2. It's called before any rendering happens
|
|
||||||
3. Default fallback ensures non-TTY environments don't crash
|
|
||||||
|
|
||||||
### Module Independence
|
|
||||||
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
|
||||||
|
|
||||||
### Color Code Finalization
|
|
||||||
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
|
||||||
|
|
||||||
### Theme ID Naming
|
|
||||||
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
|
||||||
|
|
||||||
### Terminal Resize Handling
|
|
||||||
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
|
||||||
1. The picker completes quickly (< 5 seconds typical interaction)
|
|
||||||
2. Once a theme is selected, the menu closes and rendering begins
|
|
||||||
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
|
||||||
|
|
||||||
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
1. **Unit tests** (`tests/test_themes.py`):
|
|
||||||
- Verify Theme class construction
|
|
||||||
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
|
||||||
- Confirm gradient lists have correct length (12)
|
|
||||||
|
|
||||||
2. **Integration tests** (`tests/test_render.py`):
|
|
||||||
- Mock `config.ACTIVE_THEME` to each theme
|
|
||||||
- Verify `lr_gradient()` uses correct colors
|
|
||||||
- Verify fallback works when `ACTIVE_THEME` is None
|
|
||||||
|
|
||||||
3. **Existing tests:**
|
|
||||||
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
|
||||||
- Use pytest fixtures to set theme per test case
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
|
||||||
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
|
||||||
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
|
||||||
- `tests/test_themes.py` (new unit tests)
|
|
||||||
- `tests/test_render.py` (update mocking strategy)
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
1. ✓ Color picker displays 3 theme options at startup
|
|
||||||
2. ✓ Selection applies to all headline and message gradients
|
|
||||||
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
5. ✓ No persistence between runs
|
|
||||||
6. ✓ Non-TTY environments default to green without error
|
|
||||||
7. ✓ Architecture supports future random/animation modes
|
|
||||||
8. ✓ All gradient color codes finalized with no TBD values
|
|
||||||
@@ -5,7 +5,6 @@ PLUGIN_DIR = Path(__file__).parent
|
|||||||
|
|
||||||
def discover_plugins():
|
def discover_plugins():
|
||||||
from engine.effects.registry import get_registry
|
from engine.effects.registry import get_registry
|
||||||
from engine.effects.types import EffectPlugin
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
imported = {}
|
imported = {}
|
||||||
@@ -23,8 +22,8 @@ def discover_plugins():
|
|||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
isinstance(attr, type)
|
isinstance(attr, type)
|
||||||
and issubclass(attr, EffectPlugin)
|
and hasattr(attr, "name")
|
||||||
and attr is not EffectPlugin
|
and hasattr(attr, "process")
|
||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import random
|
|||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
class FadeEffect(EffectPlugin):
|
class FadeEffect:
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect:
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
class GlitchEffect(EffectPlugin):
|
class GlitchEffect:
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect:
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
|||||||
215
engine/app.py
215
engine/app.py
@@ -10,11 +10,9 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render, themes
|
from engine import config, render
|
||||||
|
from engine.controller import StreamController
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
from engine.mic import MicMonitor
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
from engine.scroll import stream
|
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
CLR,
|
CLR,
|
||||||
CURSOR_OFF,
|
CURSOR_OFF,
|
||||||
@@ -65,30 +63,6 @@ def _read_picker_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
|
||||||
selected: Index of currently selected theme (0-2)
|
|
||||||
"""
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
|
||||||
)
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
def _normalize_preview_rows(rows):
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
non_empty = [r for r in rows if r.strip()]
|
non_empty = [r for r in rows if r.strip()]
|
||||||
@@ -155,50 +129,6 @@ def _draw_font_picker(faces, selected):
|
|||||||
print(f" {shown}")
|
print(f" {shown}")
|
||||||
|
|
||||||
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
|
||||||
|
|
||||||
Displays a menu of available themes and lets user select with arrow keys.
|
|
||||||
Non-interactive environments (piped stdin, CI) silently default to green.
|
|
||||||
"""
|
|
||||||
# Non-interactive fallback
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
def pick_font_face():
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
if not config.FONT_PICKER:
|
if not config.FONT_PICKER:
|
||||||
@@ -317,6 +247,110 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_effects_config():
|
||||||
|
"""Interactive picker for configuring effects pipeline."""
|
||||||
|
import effects_plugins
|
||||||
|
from engine.effects import get_effect_chain, get_registry
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = get_effect_chain()
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
|
||||||
|
effects = list(registry.list_all().values())
|
||||||
|
if not effects:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = 0
|
||||||
|
editing_intensity = False
|
||||||
|
intensity_value = 1.0
|
||||||
|
|
||||||
|
def _draw_effects_picker():
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print("\033[1;1H", end="")
|
||||||
|
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, effect in enumerate(effects):
|
||||||
|
prefix = " > " if i == selected else " "
|
||||||
|
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||||
|
if editing_intensity and i == selected:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print(
|
||||||
|
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_effects_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == " ":
|
||||||
|
return "toggle"
|
||||||
|
if ch == "q":
|
||||||
|
return "quit"
|
||||||
|
if ch == "+" or ch == "=":
|
||||||
|
return "up"
|
||||||
|
if ch == "-" or ch == "_":
|
||||||
|
return "down"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_effects_picker()
|
||||||
|
key = _read_effects_key()
|
||||||
|
|
||||||
|
if key == "quit" or key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "up" and editing_intensity:
|
||||||
|
intensity_value = min(1.0, intensity_value + 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "down" and editing_intensity:
|
||||||
|
intensity_value = max(0.0, intensity_value - 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(effects) - 1, selected + 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "toggle":
|
||||||
|
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
@@ -327,11 +361,13 @@ def main():
|
|||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
|
StreamController.warmup_topics()
|
||||||
|
|
||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
pick_color_theme()
|
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
|
pick_effects_config()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
@@ -341,10 +377,11 @@ def main():
|
|||||||
time.sleep(0.07)
|
time.sleep(0.07)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
_subtitle = {
|
_subtitle = (
|
||||||
"poetry": "literary consciousness stream",
|
"literary consciousness stream"
|
||||||
"code": "source consciousness stream",
|
if config.MODE == "poetry"
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
else "digital consciousness stream"
|
||||||
|
)
|
||||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
print()
|
print()
|
||||||
@@ -365,15 +402,6 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||||
save_cache(items)
|
save_cache(items)
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
else:
|
else:
|
||||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -391,9 +419,10 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
controller = StreamController()
|
||||||
mic_ok = mic.start()
|
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||||
if mic.available:
|
|
||||||
|
if controller.mic and controller.mic.available:
|
||||||
boot_ln(
|
boot_ln(
|
||||||
"Microphone",
|
"Microphone",
|
||||||
"ACTIVE"
|
"ACTIVE"
|
||||||
@@ -402,12 +431,6 @@ def main():
|
|||||||
bool(mic_ok),
|
bool(mic_ok),
|
||||||
)
|
)
|
||||||
|
|
||||||
ntfy = NtfyPoller(
|
|
||||||
config.NTFY_TOPIC,
|
|
||||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
|
||||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
|
||||||
)
|
|
||||||
ntfy_ok = ntfy.start()
|
|
||||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
if config.FIREHOSE:
|
if config.FIREHOSE:
|
||||||
@@ -420,7 +443,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
stream(items, ntfy, mic)
|
controller.run(items)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
|||||||
659
engine/benchmark.py
Normal file
659
engine/benchmark.py
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Benchmark runner for mainline - tests performance across effects and displays.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m engine.benchmark
|
||||||
|
python -m engine.benchmark --output report.md
|
||||||
|
python -m engine.benchmark --displays terminal,websocket --effects glitch,fade
|
||||||
|
python -m engine.benchmark --format json --output benchmark.json
|
||||||
|
|
||||||
|
Headless mode (default): suppress all terminal output during benchmarks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchmarkResult:
|
||||||
|
"""Result of a single benchmark run."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
display: str
|
||||||
|
effect: str | None
|
||||||
|
iterations: int
|
||||||
|
total_time_ms: float
|
||||||
|
avg_time_ms: float
|
||||||
|
std_dev_ms: float
|
||||||
|
min_ms: float
|
||||||
|
max_ms: float
|
||||||
|
fps: float
|
||||||
|
chars_processed: int
|
||||||
|
chars_per_sec: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchmarkReport:
|
||||||
|
"""Complete benchmark report."""
|
||||||
|
|
||||||
|
timestamp: str
|
||||||
|
python_version: str
|
||||||
|
results: list[BenchmarkResult] = field(default_factory=list)
|
||||||
|
summary: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sample_buffer(width: int = 80, height: int = 24) -> list[str]:
|
||||||
|
"""Generate a sample buffer for benchmarking."""
|
||||||
|
lines = []
|
||||||
|
for i in range(height):
|
||||||
|
line = f"\x1b[32mLine {i}\x1b[0m " + "A" * (width - 10)
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_display(
|
||||||
|
display_class, buffer: list[str], iterations: int = 100
|
||||||
|
) -> BenchmarkResult | None:
|
||||||
|
"""Benchmark a single display."""
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
sys.stdout = StringIO()
|
||||||
|
sys.stderr = StringIO()
|
||||||
|
|
||||||
|
display = display_class()
|
||||||
|
display.init(80, 24)
|
||||||
|
|
||||||
|
times = []
|
||||||
|
chars = sum(len(line) for line in buffer)
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
display.show(buffer)
|
||||||
|
elapsed = (time.perf_counter() - t0) * 1000
|
||||||
|
times.append(elapsed)
|
||||||
|
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
times_arr = np.array(times)
|
||||||
|
|
||||||
|
return BenchmarkResult(
|
||||||
|
name=f"display_{display_class.__name__}",
|
||||||
|
display=display_class.__name__,
|
||||||
|
effect=None,
|
||||||
|
iterations=iterations,
|
||||||
|
total_time_ms=sum(times),
|
||||||
|
avg_time_ms=float(np.mean(times_arr)),
|
||||||
|
std_dev_ms=float(np.std(times_arr)),
|
||||||
|
min_ms=float(np.min(times_arr)),
|
||||||
|
max_ms=float(np.max(times_arr)),
|
||||||
|
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||||
|
chars_processed=chars * iterations,
|
||||||
|
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||||
|
if sum(times) > 0
|
||||||
|
else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def benchmark_effect_with_display(
|
||||||
|
effect_class, display, buffer: list[str], iterations: int = 100
|
||||||
|
) -> BenchmarkResult | None:
|
||||||
|
"""Benchmark an effect with a display."""
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
old_stderr = sys.stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext
|
||||||
|
|
||||||
|
sys.stdout = StringIO()
|
||||||
|
sys.stderr = StringIO()
|
||||||
|
|
||||||
|
effect = effect_class()
|
||||||
|
effect.configure(EffectConfig(enabled=True, intensity=1.0))
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=0,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
times = []
|
||||||
|
chars = sum(len(line) for line in buffer)
|
||||||
|
|
||||||
|
for _ in range(iterations):
|
||||||
|
processed = effect.process(buffer, ctx)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
display.show(processed)
|
||||||
|
elapsed = (time.perf_counter() - t0) * 1000
|
||||||
|
times.append(elapsed)
|
||||||
|
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
sys.stderr = old_stderr
|
||||||
|
|
||||||
|
times_arr = np.array(times)
|
||||||
|
|
||||||
|
return BenchmarkResult(
|
||||||
|
name=f"effect_{effect_class.__name__}_with_{display.__class__.__name__}",
|
||||||
|
display=display.__class__.__name__,
|
||||||
|
effect=effect_class.__name__,
|
||||||
|
iterations=iterations,
|
||||||
|
total_time_ms=sum(times),
|
||||||
|
avg_time_ms=float(np.mean(times_arr)),
|
||||||
|
std_dev_ms=float(np.std(times_arr)),
|
||||||
|
min_ms=float(np.min(times_arr)),
|
||||||
|
max_ms=float(np.max(times_arr)),
|
||||||
|
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||||
|
chars_processed=chars * iterations,
|
||||||
|
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||||
|
if sum(times) > 0
|
||||||
|
else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_displays():
|
||||||
|
"""Get available display classes."""
|
||||||
|
from engine.display import (
|
||||||
|
DisplayRegistry,
|
||||||
|
NullDisplay,
|
||||||
|
TerminalDisplay,
|
||||||
|
)
|
||||||
|
|
||||||
|
DisplayRegistry.initialize()
|
||||||
|
|
||||||
|
displays = [
|
||||||
|
("null", NullDisplay),
|
||||||
|
("terminal", TerminalDisplay),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
|
displays.append(("websocket", WebSocketDisplay))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
displays.append(("sixel", SixelDisplay))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return displays
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_effects():
|
||||||
|
"""Get available effect classes."""
|
||||||
|
try:
|
||||||
|
from engine.effects import get_registry
|
||||||
|
|
||||||
|
try:
|
||||||
|
from effects_plugins import discover_plugins
|
||||||
|
|
||||||
|
discover_plugins()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
effects = []
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
for name, effect in registry.list_all().items():
|
||||||
|
if effect:
|
||||||
|
effect_cls = type(effect)
|
||||||
|
effects.append((name, effect_cls))
|
||||||
|
|
||||||
|
return effects
|
||||||
|
|
||||||
|
|
||||||
|
def run_benchmarks(
|
||||||
|
displays: list[tuple[str, Any]] | None = None,
|
||||||
|
effects: list[tuple[str, Any]] | None = None,
|
||||||
|
iterations: int = 100,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> BenchmarkReport:
|
||||||
|
"""Run all benchmarks and return report."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if displays is None:
|
||||||
|
displays = get_available_displays()
|
||||||
|
|
||||||
|
if effects is None:
|
||||||
|
effects = get_available_effects()
|
||||||
|
|
||||||
|
buffer = get_sample_buffer(80, 24)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f"Running benchmarks ({iterations} iterations each)...")
|
||||||
|
|
||||||
|
for name, display_class in displays:
|
||||||
|
if verbose:
|
||||||
|
print(f"Benchmarking display: {name}")
|
||||||
|
|
||||||
|
result = benchmark_display(display_class, buffer, iterations)
|
||||||
|
if result:
|
||||||
|
results.append(result)
|
||||||
|
if verbose:
|
||||||
|
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print()
|
||||||
|
|
||||||
|
for effect_name, effect_class in effects:
|
||||||
|
for display_name, display_class in displays:
|
||||||
|
if display_name == "websocket":
|
||||||
|
continue
|
||||||
|
if verbose:
|
||||||
|
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
||||||
|
|
||||||
|
display = display_class()
|
||||||
|
display.init(80, 24)
|
||||||
|
result = benchmark_effect_with_display(
|
||||||
|
effect_class, display, buffer, iterations
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
results.append(result)
|
||||||
|
if verbose:
|
||||||
|
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||||
|
|
||||||
|
summary = generate_summary(results)
|
||||||
|
|
||||||
|
return BenchmarkReport(
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
python_version=sys.version,
|
||||||
|
results=results,
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_summary(results: list[BenchmarkResult]) -> dict[str, Any]:
|
||||||
|
"""Generate summary statistics from results."""
|
||||||
|
by_display: dict[str, list[BenchmarkResult]] = {}
|
||||||
|
by_effect: dict[str, list[BenchmarkResult]] = {}
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
if r.display not in by_display:
|
||||||
|
by_display[r.display] = []
|
||||||
|
by_display[r.display].append(r)
|
||||||
|
|
||||||
|
if r.effect:
|
||||||
|
if r.effect not in by_effect:
|
||||||
|
by_effect[r.effect] = []
|
||||||
|
by_effect[r.effect].append(r)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"by_display": {},
|
||||||
|
"by_effect": {},
|
||||||
|
"overall": {
|
||||||
|
"total_tests": len(results),
|
||||||
|
"displays_tested": len(by_display),
|
||||||
|
"effects_tested": len(by_effect),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for display, res in by_display.items():
|
||||||
|
fps_values = [r.fps for r in res]
|
||||||
|
summary["by_display"][display] = {
|
||||||
|
"avg_fps": float(np.mean(fps_values)),
|
||||||
|
"min_fps": float(np.min(fps_values)),
|
||||||
|
"max_fps": float(np.max(fps_values)),
|
||||||
|
"tests": len(res),
|
||||||
|
}
|
||||||
|
|
||||||
|
for effect, res in by_effect.items():
|
||||||
|
fps_values = [r.fps for r in res]
|
||||||
|
summary["by_effect"][effect] = {
|
||||||
|
"avg_fps": float(np.mean(fps_values)),
|
||||||
|
"min_fps": float(np.min(fps_values)),
|
||||||
|
"max_fps": float(np.max(fps_values)),
|
||||||
|
"tests": len(res),
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CACHE_PATH = Path.home() / ".mainline_benchmark_cache.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_baseline(cache_path: Path | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Load baseline benchmark results from cache."""
|
||||||
|
path = cache_path or DEFAULT_CACHE_PATH
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_baseline(
|
||||||
|
results: list[BenchmarkResult],
|
||||||
|
cache_path: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Save benchmark results as baseline to cache."""
|
||||||
|
path = cache_path or DEFAULT_CACHE_PATH
|
||||||
|
baseline = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"results": {
|
||||||
|
r.name: {
|
||||||
|
"fps": r.fps,
|
||||||
|
"avg_time_ms": r.avg_time_ms,
|
||||||
|
"chars_per_sec": r.chars_per_sec,
|
||||||
|
}
|
||||||
|
for r in results
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(baseline, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_with_baseline(
|
||||||
|
results: list[BenchmarkResult],
|
||||||
|
baseline: dict[str, Any],
|
||||||
|
threshold: float = 0.2,
|
||||||
|
verbose: bool = True,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Compare current results with baseline. Returns (pass, messages)."""
|
||||||
|
baseline_results = baseline.get("results", {})
|
||||||
|
failures = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
for r in results:
|
||||||
|
if r.name not in baseline_results:
|
||||||
|
warnings.append(f"New test: {r.name} (no baseline)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
b = baseline_results[r.name]
|
||||||
|
if b["fps"] == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
degradation = (b["fps"] - r.fps) / b["fps"]
|
||||||
|
if degradation > threshold:
|
||||||
|
failures.append(
|
||||||
|
f"{r.name}: FPS degraded {degradation * 100:.1f}% "
|
||||||
|
f"(baseline: {b['fps']:.1f}, current: {r.fps:.1f})"
|
||||||
|
)
|
||||||
|
elif verbose:
|
||||||
|
print(f" {r.name}: {r.fps:.1f} FPS (baseline: {b['fps']:.1f})")
|
||||||
|
|
||||||
|
passed = len(failures) == 0
|
||||||
|
messages = []
|
||||||
|
if failures:
|
||||||
|
messages.extend(failures)
|
||||||
|
if warnings:
|
||||||
|
messages.extend(warnings)
|
||||||
|
|
||||||
|
return passed, messages
|
||||||
|
|
||||||
|
|
||||||
|
def run_hook_mode(
|
||||||
|
displays: list[tuple[str, Any]] | None = None,
|
||||||
|
effects: list[tuple[str, Any]] | None = None,
|
||||||
|
iterations: int = 20,
|
||||||
|
threshold: float = 0.2,
|
||||||
|
cache_path: Path | None = None,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Run in hook mode: compare against baseline, exit 0 on pass, 1 on fail."""
|
||||||
|
baseline = load_baseline(cache_path)
|
||||||
|
|
||||||
|
if baseline is None:
|
||||||
|
print("No baseline found. Run with --baseline to create one.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
report = run_benchmarks(displays, effects, iterations, verbose)
|
||||||
|
|
||||||
|
passed, messages = compare_with_baseline(
|
||||||
|
report.results, baseline, threshold, verbose
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n=== Benchmark Hook Results ===")
|
||||||
|
if passed:
|
||||||
|
print("PASSED - No significant performance degradation")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("FAILED - Performance degradation detected:")
|
||||||
|
for msg in messages:
|
||||||
|
print(f" - {msg}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def format_report_text(report: BenchmarkReport) -> str:
|
||||||
|
"""Format report as human-readable text."""
|
||||||
|
lines = [
|
||||||
|
"# Mainline Performance Benchmark Report",
|
||||||
|
"",
|
||||||
|
f"Generated: {report.timestamp}",
|
||||||
|
f"Python: {report.python_version}",
|
||||||
|
"",
|
||||||
|
"## Summary",
|
||||||
|
"",
|
||||||
|
f"Total tests: {report.summary['overall']['total_tests']}",
|
||||||
|
f"Displays tested: {report.summary['overall']['displays_tested']}",
|
||||||
|
f"Effects tested: {report.summary['overall']['effects_tested']}",
|
||||||
|
"",
|
||||||
|
"## By Display",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for display, stats in report.summary["by_display"].items():
|
||||||
|
lines.append(f"### {display}")
|
||||||
|
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||||
|
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||||
|
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||||
|
lines.append(f"- Tests: {stats['tests']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if report.summary["by_effect"]:
|
||||||
|
lines.append("## By Effect")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for effect, stats in report.summary["by_effect"].items():
|
||||||
|
lines.append(f"### {effect}")
|
||||||
|
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||||
|
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||||
|
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||||
|
lines.append(f"- Tests: {stats['tests']}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## Detailed Results")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| Display | Effect | FPS | Avg ms | StdDev ms | Min ms | Max ms |")
|
||||||
|
lines.append("|---------|--------|-----|--------|-----------|--------|--------|")
|
||||||
|
|
||||||
|
for r in report.results:
|
||||||
|
effect_col = r.effect if r.effect else "-"
|
||||||
|
lines.append(
|
||||||
|
f"| {r.display} | {effect_col} | {r.fps:.1f} | {r.avg_time_ms:.2f} | "
|
||||||
|
f"{r.std_dev_ms:.2f} | {r.min_ms:.2f} | {r.max_ms:.2f} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_report_json(report: BenchmarkReport) -> str:
|
||||||
|
"""Format report as JSON."""
|
||||||
|
data = {
|
||||||
|
"timestamp": report.timestamp,
|
||||||
|
"python_version": report.python_version,
|
||||||
|
"summary": report.summary,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": r.name,
|
||||||
|
"display": r.display,
|
||||||
|
"effect": r.effect,
|
||||||
|
"iterations": r.iterations,
|
||||||
|
"total_time_ms": r.total_time_ms,
|
||||||
|
"avg_time_ms": r.avg_time_ms,
|
||||||
|
"std_dev_ms": r.std_dev_ms,
|
||||||
|
"min_ms": r.min_ms,
|
||||||
|
"max_ms": r.max_ms,
|
||||||
|
"fps": r.fps,
|
||||||
|
"chars_processed": r.chars_processed,
|
||||||
|
"chars_per_sec": r.chars_per_sec,
|
||||||
|
}
|
||||||
|
for r in report.results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Run mainline benchmarks")
|
||||||
|
parser.add_argument(
|
||||||
|
"--displays",
|
||||||
|
help="Comma-separated list of displays to test (default: all)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--effects",
|
||||||
|
help="Comma-separated list of effects to test (default: all)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--iterations",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Number of iterations per test (default: 100)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
help="Output file path (default: stdout)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
choices=["text", "json"],
|
||||||
|
default="text",
|
||||||
|
help="Output format (default: text)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Show progress during benchmarking",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hook",
|
||||||
|
action="store_true",
|
||||||
|
help="Run in hook mode: compare against baseline, exit 0 pass, 1 fail",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--baseline",
|
||||||
|
action="store_true",
|
||||||
|
help="Save current results as baseline for future hook comparisons",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold",
|
||||||
|
type=float,
|
||||||
|
default=0.2,
|
||||||
|
help="Performance degradation threshold for hook mode (default: 0.2 = 20%%)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Path to baseline cache file (default: ~/.mainline_benchmark_cache.json)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cache_path = Path(args.cache) if args.cache else DEFAULT_CACHE_PATH
|
||||||
|
|
||||||
|
if args.hook:
|
||||||
|
displays = None
|
||||||
|
if args.displays:
|
||||||
|
display_map = dict(get_available_displays())
|
||||||
|
displays = [
|
||||||
|
(name, display_map[name])
|
||||||
|
for name in args.displays.split(",")
|
||||||
|
if name in display_map
|
||||||
|
]
|
||||||
|
|
||||||
|
effects = None
|
||||||
|
if args.effects:
|
||||||
|
effect_map = dict(get_available_effects())
|
||||||
|
effects = [
|
||||||
|
(name, effect_map[name])
|
||||||
|
for name in args.effects.split(",")
|
||||||
|
if name in effect_map
|
||||||
|
]
|
||||||
|
|
||||||
|
return run_hook_mode(
|
||||||
|
displays,
|
||||||
|
effects,
|
||||||
|
iterations=args.iterations,
|
||||||
|
threshold=args.threshold,
|
||||||
|
cache_path=cache_path,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
displays = None
|
||||||
|
if args.displays:
|
||||||
|
display_map = dict(get_available_displays())
|
||||||
|
displays = [
|
||||||
|
(name, display_map[name])
|
||||||
|
for name in args.displays.split(",")
|
||||||
|
if name in display_map
|
||||||
|
]
|
||||||
|
|
||||||
|
effects = None
|
||||||
|
if args.effects:
|
||||||
|
effect_map = dict(get_available_effects())
|
||||||
|
effects = [
|
||||||
|
(name, effect_map[name])
|
||||||
|
for name in args.effects.split(",")
|
||||||
|
if name in effect_map
|
||||||
|
]
|
||||||
|
|
||||||
|
report = run_benchmarks(displays, effects, args.iterations, args.verbose)
|
||||||
|
|
||||||
|
if args.baseline:
|
||||||
|
save_baseline(report.results, cache_path)
|
||||||
|
print(f"Baseline saved to {cache_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.format == "json":
|
||||||
|
output = format_report_json(report)
|
||||||
|
else:
|
||||||
|
output = format_report_text(report)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w") as f:
|
||||||
|
f.write(output)
|
||||||
|
else:
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -105,6 +105,8 @@ class Config:
|
|||||||
firehose: bool = False
|
firehose: bool = False
|
||||||
|
|
||||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
ntfy_reconnect_delay: int = 5
|
ntfy_reconnect_delay: int = 5
|
||||||
message_display_secs: int = 30
|
message_display_secs: int = 30
|
||||||
|
|
||||||
@@ -127,6 +129,10 @@ class Config:
|
|||||||
|
|
||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
display: str = "terminal"
|
||||||
|
websocket: bool = False
|
||||||
|
websocket_port: int = 8765
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
@@ -148,6 +154,8 @@ class Config:
|
|||||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
firehose="--firehose" in argv,
|
firehose="--firehose" in argv,
|
||||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||||
|
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||||
ntfy_reconnect_delay=5,
|
ntfy_reconnect_delay=5,
|
||||||
message_display_secs=30,
|
message_display_secs=30,
|
||||||
font_dir=font_dir,
|
font_dir=font_dir,
|
||||||
@@ -164,6 +172,9 @@ class Config:
|
|||||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
display=_arg_value("--display", argv) or "terminal",
|
||||||
|
websocket="--websocket" in argv,
|
||||||
|
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,17 +199,13 @@ def set_config(config: Config) -> None:
|
|||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = (
|
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
||||||
"poetry"
|
|
||||||
if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code"
|
|
||||||
if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
FIREHOSE = "--firehose" in sys.argv
|
FIREHOSE = "--firehose" in sys.argv
|
||||||
|
|
||||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
@@ -229,6 +236,11 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
|||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||||
|
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
|
||||||
|
WEBSOCKET = "--websocket" in sys.argv
|
||||||
|
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||||
|
|
||||||
|
|
||||||
def set_font_selection(font_path=None, font_index=None):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
@@ -237,26 +249,3 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: Theme identifier ("green", "orange", or "purple")
|
|
||||||
Defaults to "green"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is not in the theme registry
|
|
||||||
|
|
||||||
Side Effects:
|
|
||||||
Sets the ACTIVE_THEME global variable
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ Stream controller - manages input sources and orchestrates the render stream.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from engine.config import Config, get_config
|
from engine.config import Config, get_config
|
||||||
|
from engine.display import (
|
||||||
|
DisplayRegistry,
|
||||||
|
MultiDisplay,
|
||||||
|
NullDisplay,
|
||||||
|
SixelDisplay,
|
||||||
|
TerminalDisplay,
|
||||||
|
WebSocketDisplay,
|
||||||
|
)
|
||||||
|
from engine.effects.controller import handle_effects_command
|
||||||
from engine.eventbus import EventBus
|
from engine.eventbus import EventBus
|
||||||
from engine.events import EventType, StreamEvent
|
from engine.events import EventType, StreamEvent
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
@@ -10,14 +19,76 @@ from engine.ntfy import NtfyPoller
|
|||||||
from engine.scroll import stream
|
from engine.scroll import stream
|
||||||
|
|
||||||
|
|
||||||
|
def _get_display(config: Config):
|
||||||
|
"""Get the appropriate display based on config."""
|
||||||
|
DisplayRegistry.initialize()
|
||||||
|
display_mode = config.display.lower()
|
||||||
|
|
||||||
|
displays = []
|
||||||
|
|
||||||
|
if display_mode in ("terminal", "both"):
|
||||||
|
displays.append(TerminalDisplay())
|
||||||
|
|
||||||
|
if display_mode in ("websocket", "both"):
|
||||||
|
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
|
||||||
|
ws.start_server()
|
||||||
|
ws.start_http_server()
|
||||||
|
displays.append(ws)
|
||||||
|
|
||||||
|
if display_mode == "sixel":
|
||||||
|
displays.append(SixelDisplay())
|
||||||
|
|
||||||
|
if not displays:
|
||||||
|
return NullDisplay()
|
||||||
|
|
||||||
|
if len(displays) == 1:
|
||||||
|
return displays[0]
|
||||||
|
|
||||||
|
return MultiDisplay(displays)
|
||||||
|
|
||||||
|
|
||||||
class StreamController:
|
class StreamController:
|
||||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||||
|
|
||||||
|
_topics_warmed = False
|
||||||
|
|
||||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||||
self.config = config or get_config()
|
self.config = config or get_config()
|
||||||
self.event_bus = event_bus
|
self.event_bus = event_bus
|
||||||
self.mic: MicMonitor | None = None
|
self.mic: MicMonitor | None = None
|
||||||
self.ntfy: NtfyPoller | None = None
|
self.ntfy: NtfyPoller | None = None
|
||||||
|
self.ntfy_cc: NtfyPoller | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def warmup_topics(cls) -> None:
|
||||||
|
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
||||||
|
if cls._topics_warmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
topics = [
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
||||||
|
"https://ntfy.sh/klubhaus_terminal_mainline",
|
||||||
|
]
|
||||||
|
|
||||||
|
for topic in topics:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic,
|
||||||
|
data=b"init",
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cls._topics_warmed = True
|
||||||
|
|
||||||
def initialize_sources(self) -> tuple[bool, bool]:
|
def initialize_sources(self) -> tuple[bool, bool]:
|
||||||
"""Initialize microphone and ntfy sources.
|
"""Initialize microphone and ntfy sources.
|
||||||
@@ -35,7 +106,38 @@ class StreamController:
|
|||||||
)
|
)
|
||||||
ntfy_ok = self.ntfy.start()
|
ntfy_ok = self.ntfy.start()
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok
|
self.ntfy_cc = NtfyPoller(
|
||||||
|
self.config.ntfy_cc_cmd_topic,
|
||||||
|
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||||
|
display_secs=5,
|
||||||
|
)
|
||||||
|
self.ntfy_cc.subscribe(self._handle_cc_message)
|
||||||
|
ntfy_cc_ok = self.ntfy_cc.start()
|
||||||
|
|
||||||
|
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
||||||
|
|
||||||
|
def _handle_cc_message(self, event) -> None:
|
||||||
|
"""Handle incoming C&C message - like a serial port control interface."""
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
||||||
|
if not cmd.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
response = handle_effects_command(cmd)
|
||||||
|
|
||||||
|
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
||||||
|
data = response.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=data,
|
||||||
|
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def run(self, items: list) -> None:
|
def run(self, items: list) -> None:
|
||||||
"""Run the stream with initialized sources."""
|
"""Run the stream with initialized sources."""
|
||||||
@@ -51,7 +153,10 @@ class StreamController:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
stream(items, self.ntfy, self.mic)
|
display = _get_display(self.config)
|
||||||
|
stream(items, self.ntfy, self.mic, display)
|
||||||
|
if display:
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
if self.event_bus:
|
if self.event_bus:
|
||||||
self.event_bus.publish(
|
self.event_bus.publish(
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"""
|
|
||||||
Display output abstraction - allows swapping output backends.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
- init(width, height): Initialize display with terminal dimensions
|
|
||||||
- show(buffer): Render buffer (list of strings) to display
|
|
||||||
- clear(): Clear the display
|
|
||||||
- cleanup(): Shutdown display
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
|
||||||
"""Protocol for display backends."""
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
"""Initialize display with dimensions."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
"""Show buffer on display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Shutdown display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def get_monitor():
|
|
||||||
"""Get the performance monitor."""
|
|
||||||
try:
|
|
||||||
from engine.effects.performance import get_monitor as _get_monitor
|
|
||||||
|
|
||||||
return _get_monitor()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
|
||||||
"""ANSI terminal display backend."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
from engine.terminal import CURSOR_OFF
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
print(CURSOR_OFF, end="", flush=True)
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
sys.stdout.buffer.write("".join(buffer).encode())
|
|
||||||
sys.stdout.flush()
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
from engine.terminal import CLR
|
|
||||||
|
|
||||||
print(CLR, end="", flush=True)
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
from engine.terminal import CURSOR_ON
|
|
||||||
|
|
||||||
print(CURSOR_ON, end="", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
class NullDisplay:
|
|
||||||
"""Headless/null display - discards all output."""
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
102
engine/display/__init__.py
Normal file
102
engine/display/__init__.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Display backend system with registry pattern.
|
||||||
|
|
||||||
|
Allows swapping output backends via the Display protocol.
|
||||||
|
Supports auto-discovery of display backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from engine.display.backends.multi import MultiDisplay
|
||||||
|
from engine.display.backends.null import NullDisplay
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
from engine.display.backends.terminal import TerminalDisplay
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayRegistry:
|
||||||
|
"""Registry for display backends with auto-discovery."""
|
||||||
|
|
||||||
|
_backends: dict[str, type[Display]] = {}
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||||
|
"""Register a display backend."""
|
||||||
|
cls._backends[name.lower()] = backend_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, name: str) -> type[Display] | None:
|
||||||
|
"""Get a display backend class by name."""
|
||||||
|
return cls._backends.get(name.lower())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_backends(cls) -> list[str]:
|
||||||
|
"""List all available display backend names."""
|
||||||
|
return list(cls._backends.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, name: str, **kwargs) -> Display | None:
|
||||||
|
"""Create a display instance by name."""
|
||||||
|
backend_class = cls.get(name)
|
||||||
|
if backend_class:
|
||||||
|
return backend_class(**kwargs)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initialize(cls) -> None:
|
||||||
|
"""Initialize and register all built-in backends."""
|
||||||
|
if cls._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
cls.register("terminal", TerminalDisplay)
|
||||||
|
cls.register("null", NullDisplay)
|
||||||
|
cls.register("websocket", WebSocketDisplay)
|
||||||
|
cls.register("sixel", SixelDisplay)
|
||||||
|
|
||||||
|
cls._initialized = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Display",
|
||||||
|
"DisplayRegistry",
|
||||||
|
"get_monitor",
|
||||||
|
"TerminalDisplay",
|
||||||
|
"NullDisplay",
|
||||||
|
"WebSocketDisplay",
|
||||||
|
"SixelDisplay",
|
||||||
|
"MultiDisplay",
|
||||||
|
]
|
||||||
33
engine/display/backends/multi.py
Normal file
33
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
Multi display backend - forwards to multiple displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MultiDisplay:
|
||||||
|
"""Display that forwards to multiple displays."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self, displays: list):
|
||||||
|
self.displays = displays
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
for d in self.displays:
|
||||||
|
d.init(width, height)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.show(buffer)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.clear()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.cleanup()
|
||||||
32
engine/display/backends/null.py
Normal file
32
engine/display/backends/null.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
Null/headless display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
from engine.display import get_monitor
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
269
engine/display/backends/sixel.py
Normal file
269
engine/display/backends/sixel.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Sixel graphics display backend - renders to sixel graphics in terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ansi(
|
||||||
|
text: str,
|
||||||
|
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||||
|
"""Parse ANSI text into tokens with fg/bg colors.
|
||||||
|
|
||||||
|
Returns list of (text, fg_rgb, bg_rgb, bold).
|
||||||
|
"""
|
||||||
|
tokens = []
|
||||||
|
current_text = ""
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
ANSI_COLORS = {
|
||||||
|
0: (0, 0, 0),
|
||||||
|
1: (205, 49, 49),
|
||||||
|
2: (13, 188, 121),
|
||||||
|
3: (229, 229, 16),
|
||||||
|
4: (36, 114, 200),
|
||||||
|
5: (188, 63, 188),
|
||||||
|
6: (17, 168, 205),
|
||||||
|
7: (229, 229, 229),
|
||||||
|
8: (102, 102, 102),
|
||||||
|
9: (241, 76, 76),
|
||||||
|
10: (35, 209, 139),
|
||||||
|
11: (245, 245, 67),
|
||||||
|
12: (59, 142, 234),
|
||||||
|
13: (214, 112, 214),
|
||||||
|
14: (41, 184, 219),
|
||||||
|
15: (255, 255, 255),
|
||||||
|
}
|
||||||
|
|
||||||
|
while i < len(text):
|
||||||
|
char = text[i]
|
||||||
|
|
||||||
|
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||||
|
if current_text:
|
||||||
|
tokens.append((current_text, fg, bg, bold))
|
||||||
|
current_text = ""
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
code = ""
|
||||||
|
while i < len(text):
|
||||||
|
c = text[i]
|
||||||
|
if c.isalpha():
|
||||||
|
break
|
||||||
|
code += c
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if code:
|
||||||
|
codes = code.split(";")
|
||||||
|
for c in codes:
|
||||||
|
try:
|
||||||
|
n = int(c) if c else 0
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
elif n == 1:
|
||||||
|
bold = True
|
||||||
|
elif n == 22:
|
||||||
|
bold = False
|
||||||
|
elif n == 39:
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
elif n == 49:
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
elif 30 <= n <= 37:
|
||||||
|
fg = ANSI_COLORS.get(n - 30 + (8 if bold else 0), fg)
|
||||||
|
elif 40 <= n <= 47:
|
||||||
|
bg = ANSI_COLORS.get(n - 40, bg)
|
||||||
|
elif 90 <= n <= 97:
|
||||||
|
fg = ANSI_COLORS.get(n - 90 + 8, fg)
|
||||||
|
elif 100 <= n <= 107:
|
||||||
|
bg = ANSI_COLORS.get(n - 100 + 8, bg)
|
||||||
|
elif 1 <= n <= 256:
|
||||||
|
if n < 16:
|
||||||
|
fg = ANSI_COLORS.get(n, fg)
|
||||||
|
elif n < 232:
|
||||||
|
c = n - 16
|
||||||
|
r = (c // 36) * 51
|
||||||
|
g = ((c % 36) // 6) * 51
|
||||||
|
b = (c % 6) * 51
|
||||||
|
fg = (r, g, b)
|
||||||
|
else:
|
||||||
|
gray = (n - 232) * 10 + 8
|
||||||
|
fg = (gray, gray, gray)
|
||||||
|
else:
|
||||||
|
current_text += char
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if current_text:
|
||||||
|
tokens.append((current_text, fg, bg, bold))
|
||||||
|
|
||||||
|
return tokens if tokens else [("", fg, bg, bold)]
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_sixel(image) -> str:
|
||||||
|
"""Encode a PIL Image to sixel format (pure Python)."""
|
||||||
|
img = image.convert("RGBA")
|
||||||
|
width, height = img.size
|
||||||
|
pixels = img.load()
|
||||||
|
|
||||||
|
palette = []
|
||||||
|
pixel_palette_idx = {}
|
||||||
|
|
||||||
|
def get_color_idx(r, g, b, a):
|
||||||
|
if a < 128:
|
||||||
|
return -1
|
||||||
|
key = (r // 32, g // 32, b // 32)
|
||||||
|
if key not in pixel_palette_idx:
|
||||||
|
idx = len(palette)
|
||||||
|
if idx < 256:
|
||||||
|
palette.append((r, g, b))
|
||||||
|
pixel_palette_idx[key] = idx
|
||||||
|
return pixel_palette_idx.get(key, 0)
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
r, g, b, a = pixels[x, y]
|
||||||
|
get_color_idx(r, g, b, a)
|
||||||
|
|
||||||
|
if not palette:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if len(palette) == 1:
|
||||||
|
palette = [palette[0], (0, 0, 0)]
|
||||||
|
|
||||||
|
sixel_data = []
|
||||||
|
sixel_data.append(
|
||||||
|
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
||||||
|
)
|
||||||
|
|
||||||
|
for x in range(width):
|
||||||
|
col_data = []
|
||||||
|
for y in range(0, height, 6):
|
||||||
|
bits = 0
|
||||||
|
color_idx = -1
|
||||||
|
for dy in range(6):
|
||||||
|
if y + dy < height:
|
||||||
|
r, g, b, a = pixels[x, y + dy]
|
||||||
|
if a >= 128:
|
||||||
|
bits |= 1 << dy
|
||||||
|
idx = get_color_idx(r, g, b, a)
|
||||||
|
if color_idx == -1:
|
||||||
|
color_idx = idx
|
||||||
|
elif color_idx != idx:
|
||||||
|
color_idx = -2
|
||||||
|
|
||||||
|
if color_idx >= 0:
|
||||||
|
col_data.append(
|
||||||
|
chr(63 + color_idx) + chr(63 + bits)
|
||||||
|
if bits
|
||||||
|
else chr(63 + color_idx) + "?"
|
||||||
|
)
|
||||||
|
elif color_idx == -2:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if col_data:
|
||||||
|
sixel_data.append("".join(col_data) + "$")
|
||||||
|
else:
|
||||||
|
sixel_data.append("-" if x < width - 1 else "$")
|
||||||
|
|
||||||
|
sixel_data.append("\x1b\\")
|
||||||
|
|
||||||
|
return "\x1bPq" + "".join(sixel_data)
|
||||||
|
|
||||||
|
|
||||||
|
class SixelDisplay:
|
||||||
|
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
self.cell_width = cell_width
|
||||||
|
self.cell_height = cell_height
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
img_width = self.width * self.cell_width
|
||||||
|
img_height = self.height * self.cell_height
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||||
|
self.cell_height - 2,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
except Exception:
|
||||||
|
font = None
|
||||||
|
|
||||||
|
for row_idx, line in enumerate(buffer[: self.height]):
|
||||||
|
if row_idx >= self.height:
|
||||||
|
break
|
||||||
|
|
||||||
|
tokens = _parse_ansi(line)
|
||||||
|
x_pos = 0
|
||||||
|
y_pos = row_idx * self.cell_height
|
||||||
|
|
||||||
|
for text, fg, bg, bold in tokens:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bg != (0, 0, 0):
|
||||||
|
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||||
|
draw.rectangle(bbox, fill=(*bg, 255))
|
||||||
|
|
||||||
|
if bold and font:
|
||||||
|
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||||
|
|
||||||
|
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||||
|
|
||||||
|
if font:
|
||||||
|
x_pos += draw.textlength(text, font=font)
|
||||||
|
|
||||||
|
sixel = _encode_sixel(img)
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
from engine.display import get_monitor
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
48
engine/display/backends/terminal.py
Normal file
48
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
ANSI terminal display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
sys.stdout.buffer.write("".join(buffer).encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
from engine.display import get_monitor
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
266
engine/display/backends/websocket.py
Normal file
266
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
websockets = None
|
||||||
|
|
||||||
|
|
||||||
|
class Display(Protocol):
|
||||||
|
"""Protocol for display backends."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Show buffer on display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Shutdown display."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketDisplay:
|
||||||
|
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = 8765,
|
||||||
|
http_port: int = 8766,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.http_port = http_port
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
self._clients: set = set()
|
||||||
|
self._server_running = False
|
||||||
|
self._http_running = False
|
||||||
|
self._server_thread: threading.Thread | None = None
|
||||||
|
self._http_thread: threading.Thread | None = None
|
||||||
|
self._available = True
|
||||||
|
self._max_clients = 10
|
||||||
|
self._client_connected_callback = None
|
||||||
|
self._client_disconnected_callback = None
|
||||||
|
self._frame_delay = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets as _ws
|
||||||
|
|
||||||
|
self._available = _ws is not None
|
||||||
|
except ImportError:
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if WebSocket support is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
"""Initialize display with dimensions and start server."""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.start_server()
|
||||||
|
self.start_http_server()
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
"""Broadcast buffer to all connected clients."""
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
if self._clients:
|
||||||
|
frame_data = {
|
||||||
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Broadcast clear command to all clients."""
|
||||||
|
if self._clients:
|
||||||
|
clear_data = {"type": "clear"}
|
||||||
|
message = json.dumps(clear_data)
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Stop the servers."""
|
||||||
|
self.stop_server()
|
||||||
|
self.stop_http_server()
|
||||||
|
|
||||||
|
async def _websocket_handler(self, websocket):
|
||||||
|
"""Handle WebSocket connections."""
|
||||||
|
if len(self._clients) >= self._max_clients:
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._clients.add(websocket)
|
||||||
|
if self._client_connected_callback:
|
||||||
|
self._client_connected_callback(websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for message in websocket:
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
if data.get("type") == "resize":
|
||||||
|
self.width = data.get("width", 80)
|
||||||
|
self.height = data.get("height", 24)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._clients.discard(websocket)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(websocket)
|
||||||
|
|
||||||
|
async def _run_websocket_server(self):
|
||||||
|
"""Run the WebSocket server."""
|
||||||
|
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||||
|
while self._server_running:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
async def _run_http_server(self):
|
||||||
|
"""Run simple HTTP server for the client."""
|
||||||
|
import os
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=client_dir, **kwargs)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
|
while self._http_running:
|
||||||
|
httpd.handle_request()
|
||||||
|
|
||||||
|
def _run_async(self, coro):
|
||||||
|
"""Run coroutine in background."""
|
||||||
|
try:
|
||||||
|
asyncio.run(coro)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WebSocket async error: {e}")
|
||||||
|
|
||||||
|
def start_server(self):
|
||||||
|
"""Start the WebSocket server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._server_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._server_running = True
|
||||||
|
self._server_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._server_thread.start()
|
||||||
|
|
||||||
|
def stop_server(self):
|
||||||
|
"""Stop the WebSocket server."""
|
||||||
|
self._server_running = False
|
||||||
|
self._server_thread = None
|
||||||
|
|
||||||
|
def start_http_server(self):
|
||||||
|
"""Start the HTTP server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._http_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
self._http_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._http_thread.start()
|
||||||
|
|
||||||
|
def stop_http_server(self):
|
||||||
|
"""Stop the HTTP server."""
|
||||||
|
self._http_running = False
|
||||||
|
self._http_thread = None
|
||||||
|
|
||||||
|
def client_count(self) -> int:
|
||||||
|
"""Return number of connected clients."""
|
||||||
|
return len(self._clients)
|
||||||
|
|
||||||
|
def get_ws_port(self) -> int:
|
||||||
|
"""Return WebSocket port."""
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
def get_http_port(self) -> int:
|
||||||
|
"""Return HTTP port."""
|
||||||
|
return self.http_port
|
||||||
|
|
||||||
|
def set_frame_delay(self, delay: float) -> None:
|
||||||
|
"""Set delay between frames in seconds."""
|
||||||
|
self._frame_delay = delay
|
||||||
|
|
||||||
|
def get_frame_delay(self) -> float:
|
||||||
|
"""Get delay between frames."""
|
||||||
|
return self._frame_delay
|
||||||
|
|
||||||
|
def set_client_connected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client connections."""
|
||||||
|
self._client_connected_callback = callback
|
||||||
|
|
||||||
|
def set_client_disconnected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client disconnections."""
|
||||||
|
self._client_disconnected_callback = callback
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -9,11 +8,10 @@ class EffectContext:
|
|||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
ticker_height: int
|
ticker_height: int
|
||||||
camera_x: int = 0
|
mic_excess: float
|
||||||
mic_excess: float = 0.0
|
grad_offset: float
|
||||||
grad_offset: float = 0.0
|
frame_number: int
|
||||||
frame_number: int = 0
|
has_message: bool
|
||||||
has_message: bool = False
|
|
||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -24,42 +22,15 @@ class EffectConfig:
|
|||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin(ABC):
|
class EffectPlugin:
|
||||||
name: str
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
|
||||||
@abstractmethod
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
def configure(self, config: EffectConfig) -> None:
|
||||||
def configure(self, config: EffectConfig) -> None: ...
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
def create_effect_context(
|
|
||||||
terminal_width: int = 80,
|
|
||||||
terminal_height: int = 24,
|
|
||||||
scroll_cam: int = 0,
|
|
||||||
ticker_height: int = 0,
|
|
||||||
camera_x: int = 0,
|
|
||||||
mic_excess: float = 0.0,
|
|
||||||
grad_offset: float = 0.0,
|
|
||||||
frame_number: int = 0,
|
|
||||||
has_message: bool = False,
|
|
||||||
items: list | None = None,
|
|
||||||
) -> EffectContext:
|
|
||||||
"""Factory function to create EffectContext with sensible defaults."""
|
|
||||||
return EffectContext(
|
|
||||||
terminal_width=terminal_width,
|
|
||||||
terminal_height=terminal_height,
|
|
||||||
scroll_cam=scroll_cam,
|
|
||||||
ticker_height=ticker_height,
|
|
||||||
camera_x=camera_x,
|
|
||||||
mic_excess=mic_excess,
|
|
||||||
grad_offset=grad_offset,
|
|
||||||
frame_number=frame_number,
|
|
||||||
has_message=has_message,
|
|
||||||
items=items or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
|
||||||
as scroll items. Used by --code mode.
|
|
||||||
Depends on: nothing (stdlib only).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_ENGINE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
|
|
||||||
def _scope_map(source: str) -> dict[int, str]:
|
|
||||||
"""Return {line_number: scope_label} for every line in source.
|
|
||||||
|
|
||||||
Nodes are sorted by range size descending so inner scopes overwrite
|
|
||||||
outer ones, guaranteeing the narrowest enclosing scope wins.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(source)
|
|
||||||
except SyntaxError:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
span = end - node.lineno
|
|
||||||
nodes.append((span, node))
|
|
||||||
|
|
||||||
# Largest range first → inner scopes overwrite on second pass
|
|
||||||
nodes.sort(key=lambda x: x[0], reverse=True)
|
|
||||||
|
|
||||||
scope = {}
|
|
||||||
for _, node in nodes:
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
if isinstance(node, ast.ClassDef):
|
|
||||||
label = node.name
|
|
||||||
else:
|
|
||||||
label = f"{node.name}()"
|
|
||||||
for ln in range(node.lineno, end + 1):
|
|
||||||
scope[ln] = label
|
|
||||||
|
|
||||||
return scope
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_code():
|
|
||||||
"""Read engine/*.py and return (items, line_count, 0).
|
|
||||||
|
|
||||||
Each item is (text, src, ts) where:
|
|
||||||
text = the code line (rstripped, indentation preserved)
|
|
||||||
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
|
||||||
ts = dotted module path, e.g. 'engine.scroll'
|
|
||||||
"""
|
|
||||||
items = []
|
|
||||||
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
|
||||||
module = f"engine.{path.stem}"
|
|
||||||
source = path.read_text(encoding="utf-8")
|
|
||||||
scope = _scope_map(source)
|
|
||||||
for lineno, raw in enumerate(source.splitlines(), start=1):
|
|
||||||
stripped = raw.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
label = scope.get(lineno, "<module>")
|
|
||||||
items.append((raw.rstrip(), label, module))
|
|
||||||
|
|
||||||
return items, len(items), 0
|
|
||||||
@@ -18,7 +18,7 @@ from engine.effects import (
|
|||||||
noise,
|
noise,
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||||
from engine.terminal import RST, W_COOL
|
from engine.terminal import RST, W_COOL
|
||||||
|
|
||||||
MSG_META = "\033[38;5;245m"
|
MSG_META = "\033[38;5;245m"
|
||||||
@@ -57,7 +57,7 @@ def render_message_overlay(
|
|||||||
else:
|
else:
|
||||||
msg_rows = msg_cache[1]
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
msg_rows = msg_gradient(
|
msg_rows = lr_gradient_opposite(
|
||||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,40 +15,9 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
|||||||
from engine.terminal import RST
|
from engine.terminal import RST
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
def _color_codes_to_ansi(color_codes):
|
# Left → right: white-hot leading edge fades to near-black
|
||||||
"""Convert a list of 256-color codes to ANSI escape code strings.
|
GRAD_COLS = [
|
||||||
|
|
||||||
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
|
||||||
@@ -61,12 +30,10 @@ def _default_green_gradient():
|
|||||||
"\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)
|
||||||
def _default_magenta_gradient():
|
MSG_GRAD_COLS = [
|
||||||
"""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
|
||||||
@@ -79,8 +46,7 @@ def _default_magenta_gradient():
|
|||||||
"\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
|
||||||
@@ -223,15 +189,9 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, cols=None):
|
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
if cols is None:
|
cols = grad_cols or GRAD_COLS
|
||||||
from engine import config
|
|
||||||
|
|
||||||
if config.ACTIVE_THEME:
|
|
||||||
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
|
||||||
else:
|
|
||||||
cols = _default_green_gradient()
|
|
||||||
n = len(cols)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -253,30 +213,7 @@ def lr_gradient(rows, offset=0.0, cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, _default_magenta_gradient())
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors.
|
|
||||||
|
|
||||||
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
|
||||||
falling back to default magenta if no theme is set.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text strings to colorize
|
|
||||||
offset: Gradient offset (0.0-1.0) for animation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of rows with ANSI color codes applied
|
|
||||||
"""
|
|
||||||
from engine import config
|
|
||||||
|
|
||||||
cols = (
|
|
||||||
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""
|
|
||||||
Theme definitions with color gradients for terminal rendering.
|
|
||||||
|
|
||||||
This module is data-only and does not import config or render
|
|
||||||
to prevent circular dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Theme:
|
|
||||||
"""Represents a color theme with two gradients."""
|
|
||||||
|
|
||||||
def __init__(self, name, main_gradient, message_gradient):
|
|
||||||
"""Initialize a theme with name and color gradients.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Theme identifier string
|
|
||||||
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
|
||||||
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient
|
|
||||||
self.message_gradient = message_gradient
|
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
|
||||||
# Each gradient is 12 ANSI 256-color codes in sequence
|
|
||||||
# Format: [light...] → [medium...] → [dark...] → [black]
|
|
||||||
|
|
||||||
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
|
||||||
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
|
||||||
|
|
||||||
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
|
||||||
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
|
||||||
|
|
||||||
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
|
||||||
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
THEME_REGISTRY = {
|
|
||||||
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
|
||||||
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
|
||||||
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme(theme_id):
|
|
||||||
"""Retrieve a theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: Theme identifier string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Theme object matching the ID
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is not in registry
|
|
||||||
"""
|
|
||||||
return THEME_REGISTRY[theme_id]
|
|
||||||
Binary file not shown.
Binary file not shown.
3
hk.pkl
3
hk.pkl
@@ -22,6 +22,9 @@ hooks {
|
|||||||
prefix = "uv run"
|
prefix = "uv run"
|
||||||
check = "ruff check engine/ tests/"
|
check = "ruff check engine/ tests/"
|
||||||
}
|
}
|
||||||
|
["benchmark"] {
|
||||||
|
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
mise.toml
61
mise.toml
@@ -5,45 +5,82 @@ pkl = "latest"
|
|||||||
|
|
||||||
[tasks]
|
[tasks]
|
||||||
# =====================
|
# =====================
|
||||||
# Development
|
# Testing
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-v = "uv run pytest -v"
|
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
||||||
|
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Linting & Formatting
|
||||||
|
# =====================
|
||||||
|
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Runtime
|
# Runtime Modes
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
run = "uv run mainline.py"
|
run = "uv run mainline.py"
|
||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
|
|
||||||
|
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
||||||
|
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
||||||
|
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||||
|
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Command & Control
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
cmd = "uv run cmdline.py"
|
||||||
|
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Benchmark
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||||
|
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
||||||
|
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
||||||
|
|
||||||
|
# Initialize ntfy topics (warm up before first use)
|
||||||
|
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||||
|
|
||||||
|
# =====================
|
||||||
|
# Daemon
|
||||||
|
# =====================
|
||||||
|
|
||||||
|
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
||||||
|
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||||
|
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
sync = "uv sync"
|
sync = "uv sync"
|
||||||
sync-all = "uv sync --all-extras"
|
sync-all = "uv sync --all-extras"
|
||||||
install = "uv sync"
|
install = "mise run sync"
|
||||||
install-dev = "uv sync --group dev"
|
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
||||||
|
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
||||||
|
|
||||||
bootstrap = "uv sync && uv run mainline.py --help"
|
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
|
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# CI/CD
|
# CI/CD
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Git Hooks (via hk)
|
# Git Hooks (via hk)
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ mic = [
|
|||||||
"sounddevice>=0.4.0",
|
"sounddevice>=0.4.0",
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
]
|
]
|
||||||
|
websocket = [
|
||||||
|
"websockets>=12.0",
|
||||||
|
]
|
||||||
|
sixel = [
|
||||||
|
"Pillow>=10.0.0",
|
||||||
|
]
|
||||||
|
browser = [
|
||||||
|
"playwright>=1.40.0",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-cov>=4.1.0",
|
"pytest-cov>=4.1.0",
|
||||||
|
|||||||
133
tests/e2e/test_web_client.py
Normal file
133
tests/e2e/test_web_client.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
End-to-end tests for web client with headless browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||||
|
"""Threaded HTTP server for handling concurrent requests."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def http_server():
|
||||||
|
"""Start a local HTTP server for the client."""
|
||||||
|
os.chdir(CLIENT_DIR)
|
||||||
|
|
||||||
|
handler = SimpleHTTPRequestHandler
|
||||||
|
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = server.server_address[1]
|
||||||
|
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
|
||||||
|
server.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClient:
|
||||||
|
"""Tests for the web client using Playwright."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_client_loads(self, http_server):
|
||||||
|
"""Web client loads without errors."""
|
||||||
|
response = self.page.goto(http_server)
|
||||||
|
assert response.status == 200, f"Page load failed with status {response.status}"
|
||||||
|
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
content = self.page.content()
|
||||||
|
assert "<canvas" in content, "Canvas element not found in page"
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_status_shows_connecting(self, http_server):
|
||||||
|
"""Status shows connecting initially."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
|
|
||||||
|
def test_canvas_has_dimensions(self, http_server):
|
||||||
|
"""Canvas has correct dimensions after load."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
canvas = self.page.locator("#terminal")
|
||||||
|
assert canvas.count() > 0, "Canvas not found"
|
||||||
|
|
||||||
|
def test_no_console_errors_on_load(self, http_server):
|
||||||
|
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
||||||
|
js_errors = []
|
||||||
|
|
||||||
|
def handle_console(msg):
|
||||||
|
if msg.type == "error":
|
||||||
|
text = msg.text
|
||||||
|
if "WebSocket" not in text:
|
||||||
|
js_errors.append(text)
|
||||||
|
|
||||||
|
self.page.on("console", handle_console)
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebClientProtocol:
|
||||||
|
"""Tests for WebSocket protocol handling in client."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_browser(self):
|
||||||
|
"""Set up browser for tests."""
|
||||||
|
pytest.importorskip("playwright")
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
self.browser = self.playwright.chromium.launch(headless=True)
|
||||||
|
self.context = self.browser.new_context()
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
self.page.close()
|
||||||
|
self.context.close()
|
||||||
|
self.browser.close()
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def test_websocket_reconnection(self, http_server):
|
||||||
|
"""Client attempts reconnection on disconnect."""
|
||||||
|
self.page.goto(http_server)
|
||||||
|
self.page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
|
status = self.page.locator("#status")
|
||||||
|
assert status.count() > 0, "Status element not found"
|
||||||
55
tests/test_app.py
Normal file
55
tests/test_app.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.app module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.app import _normalize_preview_rows
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizePreviewRows:
|
||||||
|
"""Tests for _normalize_preview_rows function."""
|
||||||
|
|
||||||
|
def test_empty_rows(self):
|
||||||
|
"""Empty input returns empty list."""
|
||||||
|
result = _normalize_preview_rows([])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
def test_strips_left_padding(self):
|
||||||
|
"""Left padding is stripped."""
|
||||||
|
result = _normalize_preview_rows([" content", " more"])
|
||||||
|
assert all(not r.startswith(" ") for r in result)
|
||||||
|
|
||||||
|
def test_preserves_content(self):
|
||||||
|
"""Content is preserved."""
|
||||||
|
result = _normalize_preview_rows([" hello world "])
|
||||||
|
assert "hello world" in result[0]
|
||||||
|
|
||||||
|
def test_handles_all_empty_rows(self):
|
||||||
|
"""All empty rows returns single empty string."""
|
||||||
|
result = _normalize_preview_rows(["", " ", ""])
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppConstants:
|
||||||
|
"""Tests for app module constants."""
|
||||||
|
|
||||||
|
def test_title_defined(self):
|
||||||
|
"""TITLE is defined."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert len(TITLE) > 0
|
||||||
|
|
||||||
|
def test_title_lines_are_strings(self):
|
||||||
|
"""TITLE contains string lines."""
|
||||||
|
from engine.app import TITLE
|
||||||
|
|
||||||
|
assert all(isinstance(line, str) for line in TITLE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppImports:
|
||||||
|
"""Tests for app module imports."""
|
||||||
|
|
||||||
|
def test_app_imports_without_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine import app
|
||||||
|
|
||||||
|
assert app is not None
|
||||||
@@ -83,35 +83,3 @@ class TestStreamControllerCleanup:
|
|||||||
controller.cleanup()
|
controller.cleanup()
|
||||||
|
|
||||||
mock_mic_instance.stop.assert_called_once()
|
mock_mic_instance.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestStreamControllerWarmup:
|
|
||||||
"""Tests for StreamController topic warmup."""
|
|
||||||
|
|
||||||
def test_warmup_topics_idempotent(self):
|
|
||||||
"""warmup_topics can be called multiple times."""
|
|
||||||
StreamController._topics_warmed = False
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
|
|
||||||
assert mock_urlopen.call_count >= 3
|
|
||||||
|
|
||||||
def test_warmup_topics_sets_flag(self):
|
|
||||||
"""warmup_topics sets the warmed flag."""
|
|
||||||
StreamController._topics_warmed = False
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen"):
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
|
|
||||||
assert StreamController._topics_warmed is True
|
|
||||||
|
|
||||||
def test_warmup_topics_skips_after_first(self):
|
|
||||||
"""warmup_topics skips after first call."""
|
|
||||||
StreamController._topics_warmed = True
|
|
||||||
|
|
||||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
|
||||||
StreamController.warmup_topics()
|
|
||||||
|
|
||||||
mock_urlopen.assert_not_called()
|
|
||||||
|
|||||||
234
tests/test_fetch.py
Normal file
234
tests/test_fetch.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.fetch module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.fetch import (
|
||||||
|
_fetch_gutenberg,
|
||||||
|
fetch_all,
|
||||||
|
fetch_feed,
|
||||||
|
fetch_poetry,
|
||||||
|
load_cache,
|
||||||
|
save_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchFeed:
|
||||||
|
"""Tests for fetch_feed function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_success(self, mock_urlopen):
|
||||||
|
"""Successful feed fetch returns parsed feed."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"<rss>test</rss>"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_fetch_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns None."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchAll:
|
||||||
|
"""Tests for fetch_all function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_success(self, mock_boot, mock_skip, mock_strip, mock_fetch_feed):
|
||||||
|
"""Successful fetch returns items."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||||
|
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.return_value = False
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||||
|
"""Feed error increments failed count."""
|
||||||
|
mock_fetch_feed.return_value = None
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.fetch_feed")
|
||||||
|
@patch("engine.fetch.strip_tags")
|
||||||
|
@patch("engine.fetch.skip")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_all_skips_filtered(
|
||||||
|
self, mock_boot, mock_skip, mock_strip, mock_fetch_feed
|
||||||
|
):
|
||||||
|
"""Filtered headlines are skipped."""
|
||||||
|
mock_feed = MagicMock()
|
||||||
|
mock_feed.bozo = False
|
||||||
|
mock_feed.entries = [
|
||||||
|
{"title": "Sports scores"},
|
||||||
|
{"title": "Valid headline"},
|
||||||
|
]
|
||||||
|
mock_fetch_feed.return_value = mock_feed
|
||||||
|
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||||
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
|
assert any("Valid headline" in item[0] for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchGutenberg:
|
||||||
|
"""Tests for _fetch_gutenberg function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_success(self, mock_urlopen):
|
||||||
|
"""Successful gutenberg fetch returns items."""
|
||||||
|
text = """Project Gutenberg
|
||||||
|
|
||||||
|
*** START OF THE PROJECT GUTENBERG ***
|
||||||
|
This is a test poem with multiple lines
|
||||||
|
that should be parsed as a block.
|
||||||
|
|
||||||
|
Another stanza with more content here.
|
||||||
|
|
||||||
|
*** END OF THE PROJECT GUTENBERG ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_network_error(self, mock_urlopen):
|
||||||
|
"""Network error returns empty list."""
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_short_blocks(self, mock_urlopen):
|
||||||
|
"""Blocks shorter than 20 chars are skipped."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
Short
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
|
def test_gutenberg_skips_all_caps_headers(self, mock_urlopen):
|
||||||
|
"""All-caps lines are skipped as headers."""
|
||||||
|
text = """*** START OF THE ***
|
||||||
|
THIS IS ALL CAPS HEADER
|
||||||
|
more content here
|
||||||
|
*** END OF THE ***
|
||||||
|
"""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = text.encode("utf-8")
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _fetch_gutenberg("http://example.com/test", "Test")
|
||||||
|
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchPoetry:
|
||||||
|
"""Tests for fetch_poetry function."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_success(self, mock_boot, mock_fetch):
|
||||||
|
"""Successful poetry fetch returns items."""
|
||||||
|
mock_fetch.return_value = [
|
||||||
|
("Stanza 1 content here", "Test", ""),
|
||||||
|
("Stanza 2 content here", "Test", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert linked > 0
|
||||||
|
assert failed == 0
|
||||||
|
|
||||||
|
@patch("engine.fetch._fetch_gutenberg")
|
||||||
|
@patch("engine.fetch.boot_ln")
|
||||||
|
def test_fetch_poetry_failure(self, mock_boot, mock_fetch):
|
||||||
|
"""Failed fetch increments failed count."""
|
||||||
|
mock_fetch.return_value = []
|
||||||
|
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
|
||||||
|
assert failed > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCache:
|
||||||
|
"""Tests for cache functions."""
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_success(self, mock_path):
|
||||||
|
"""Successful cache load returns items."""
|
||||||
|
mock_path.return_value.__str__ = MagicMock(return_value="/tmp/cache")
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.return_value = json.dumps(
|
||||||
|
{"items": [("title", "source", "time")]}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_missing_file(self, mock_path):
|
||||||
|
"""Missing cache file returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = False
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_load_cache_invalid_json(self, mock_path):
|
||||||
|
"""Invalid JSON returns None."""
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_path.return_value.read_text.side_effect = json.JSONDecodeError("", "", 0)
|
||||||
|
|
||||||
|
result = load_cache()
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@patch("engine.fetch._cache_path")
|
||||||
|
def test_save_cache_success(self, mock_path):
|
||||||
|
"""Save cache writes to file."""
|
||||||
|
mock_path.return_value.__truediv__ = MagicMock(
|
||||||
|
return_value=mock_path.return_value
|
||||||
|
)
|
||||||
|
|
||||||
|
save_cache([("title", "source", "time")])
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
|
|
||||||
|
|
||||||
def test_return_shape():
|
|
||||||
items, line_count, ignored = fetch_code()
|
|
||||||
assert isinstance(items, list)
|
|
||||||
assert line_count == len(items)
|
|
||||||
assert ignored == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_items_are_tuples():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
assert items, "expected at least one code line"
|
|
||||||
for item in items:
|
|
||||||
assert isinstance(item, tuple) and len(item) == 3
|
|
||||||
text, src, ts = item
|
|
||||||
assert isinstance(text, str)
|
|
||||||
assert isinstance(src, str)
|
|
||||||
assert isinstance(ts, str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_blank_and_comment_lines_excluded():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
for text, _, _ in items:
|
|
||||||
assert text.strip(), "blank line should have been filtered"
|
|
||||||
assert not text.strip().startswith("#"), "comment line should have been filtered"
|
|
||||||
|
|
||||||
|
|
||||||
def test_module_path_format():
|
|
||||||
items, _, _ = fetch_code()
|
|
||||||
pattern = re.compile(r"^engine\.\w+$")
|
|
||||||
for _, _, ts in items:
|
|
||||||
assert pattern.match(ts), f"unexpected module path: {ts!r}"
|
|
||||||
127
tests/test_ntfy_integration.py
Normal file
127
tests/test_ntfy_integration.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for ntfy topics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class TestNtfyTopics:
|
||||||
|
def test_cc_cmd_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C CMD topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_resp_topic_exists_and_writable(self):
|
||||||
|
"""Verify C&C RESP topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_CC_RESP_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
|
||||||
|
|
||||||
|
def test_message_topic_exists_and_writable(self):
|
||||||
|
"""Verify message topic exists and accepts messages."""
|
||||||
|
from engine.config import NTFY_TOPIC
|
||||||
|
|
||||||
|
topic_url = NTFY_TOPIC.replace("/json", "")
|
||||||
|
test_message = f"test_{int(time.time())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
assert resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to message topic: {e}") from e
|
||||||
|
|
||||||
|
def test_cc_cmd_topic_readable(self):
|
||||||
|
"""Verify we can read messages from C&C CMD topic."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC
|
||||||
|
|
||||||
|
test_message = f"integration_test_{int(time.time())}"
|
||||||
|
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
topic_url,
|
||||||
|
data=test_message.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"User-Agent": "mainline-test/0.1",
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
poll_url,
|
||||||
|
headers={"User-Agent": "mainline-test/0.1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
if body.strip():
|
||||||
|
data = json.loads(body.split("\n")[0])
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
|
||||||
|
|
||||||
|
def test_topics_are_different(self):
|
||||||
|
"""Verify C&C CMD/RESP and message topics are different."""
|
||||||
|
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
|
||||||
|
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
|
||||||
|
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
|
||||||
|
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
|
||||||
|
assert "_cc_resp" in NTFY_CC_RESP_TOPIC
|
||||||
@@ -2,300 +2,231 @@
|
|||||||
Tests for engine.render module.
|
Tests for engine.render module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from engine import config, render
|
from engine.render import (
|
||||||
|
GRAD_COLS,
|
||||||
|
MSG_GRAD_COLS,
|
||||||
|
clear_font_cache,
|
||||||
|
font_for_lang,
|
||||||
|
lr_gradient,
|
||||||
|
lr_gradient_opposite,
|
||||||
|
make_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultGradients:
|
class TestGradientConstants:
|
||||||
"""Tests for default gradient fallback functions."""
|
"""Tests for gradient color constants."""
|
||||||
|
|
||||||
def test_default_green_gradient_length(self):
|
def test_grad_cols_defined(self):
|
||||||
"""_default_green_gradient returns 12 colors."""
|
"""GRAD_COLS is defined with expected length."""
|
||||||
gradient = render._default_green_gradient()
|
assert len(GRAD_COLS) > 0
|
||||||
assert len(gradient) == 12
|
assert all(isinstance(c, str) for c in GRAD_COLS)
|
||||||
|
|
||||||
def test_default_green_gradient_is_list(self):
|
def test_msg_grad_cols_defined(self):
|
||||||
"""_default_green_gradient returns a list."""
|
"""MSG_GRAD_COLS is defined with expected length."""
|
||||||
gradient = render._default_green_gradient()
|
assert len(MSG_GRAD_COLS) > 0
|
||||||
assert isinstance(gradient, list)
|
assert all(isinstance(c, str) for c in MSG_GRAD_COLS)
|
||||||
|
|
||||||
def test_default_green_gradient_all_strings(self):
|
def test_grad_cols_start_with_white(self):
|
||||||
"""_default_green_gradient returns list of ANSI code strings."""
|
"""GRAD_COLS starts with white."""
|
||||||
gradient = render._default_green_gradient()
|
assert "231" in GRAD_COLS[0]
|
||||||
assert all(isinstance(code, str) for code in gradient)
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_length(self):
|
def test_msg_grad_cols_different_from_grad_cols(self):
|
||||||
"""_default_magenta_gradient returns 12 colors."""
|
"""MSG_GRAD_COLS is different from GRAD_COLS."""
|
||||||
gradient = render._default_magenta_gradient()
|
assert MSG_GRAD_COLS != GRAD_COLS
|
||||||
assert len(gradient) == 12
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_is_list(self):
|
|
||||||
"""_default_magenta_gradient returns a list."""
|
|
||||||
gradient = render._default_magenta_gradient()
|
|
||||||
assert isinstance(gradient, list)
|
|
||||||
|
|
||||||
def test_default_magenta_gradient_all_strings(self):
|
|
||||||
"""_default_magenta_gradient returns list of ANSI code strings."""
|
|
||||||
gradient = render._default_magenta_gradient()
|
|
||||||
assert all(isinstance(code, str) for code in gradient)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLrGradientUsesActiveTheme:
|
class TestLrGradient:
|
||||||
"""Tests for lr_gradient using active theme."""
|
"""Tests for lr_gradient function."""
|
||||||
|
|
||||||
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
def test_empty_rows(self):
|
||||||
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
"""Empty input returns empty output."""
|
||||||
# Save original state
|
result = lr_gradient([], 0.0)
|
||||||
original_theme = config.ACTIVE_THEME
|
assert result == []
|
||||||
|
|
||||||
try:
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
def test_preserves_empty_rows(self):
|
||||||
|
"""Empty rows are preserved."""
|
||||||
|
result = lr_gradient([""], 0.0)
|
||||||
assert result == [""]
|
assert result == [""]
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_lr_gradient_multiple_rows(self):
|
def test_adds_gradient_to_content(self):
|
||||||
"""lr_gradient handles multiple rows."""
|
"""Non-empty rows get gradient coloring."""
|
||||||
rows = ["row1", "row2", "row3"]
|
result = lr_gradient(["hello"], 0.0)
|
||||||
|
|
||||||
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
|
assert len(result) == 1
|
||||||
# Should have ANSI codes from the message gradient
|
|
||||||
assert result[0] != "MESSAGE"
|
|
||||||
assert "\033[" in result[0]
|
assert "\033[" in result[0]
|
||||||
finally:
|
|
||||||
# Restore original state
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_fallback_when_no_theme(self):
|
def test_preserves_spaces(self):
|
||||||
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
"""Spaces are preserved without coloring."""
|
||||||
# Save original state
|
result = lr_gradient(["hello world"], 0.0)
|
||||||
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]
|
assert " " in result[0]
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
|
||||||
|
|
||||||
def test_msg_gradient_empty_rows(self):
|
def test_offset_wraps_around(self):
|
||||||
"""msg_gradient handles empty rows correctly."""
|
"""Offset wraps around at 1.0."""
|
||||||
original_theme = config.ACTIVE_THEME
|
result1 = lr_gradient(["hello"], 0.0)
|
||||||
|
result2 = lr_gradient(["hello"], 1.0)
|
||||||
|
assert result1 != result2 or result1 == result2
|
||||||
|
|
||||||
try:
|
|
||||||
config.set_active_theme("green")
|
|
||||||
rows = [""]
|
|
||||||
|
|
||||||
result = render.msg_gradient(rows, offset=0.0)
|
class TestLrGradientOpposite:
|
||||||
|
"""Tests for lr_gradient_opposite function."""
|
||||||
|
|
||||||
# Empty row should stay empty
|
def test_uses_msg_grad_cols(self):
|
||||||
|
"""Uses MSG_GRAD_COLS instead of GRAD_COLS."""
|
||||||
|
result = lr_gradient_opposite(["test"])
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearFontCache:
|
||||||
|
"""Tests for clear_font_cache function."""
|
||||||
|
|
||||||
|
def test_clears_without_error(self):
|
||||||
|
"""Function runs without error."""
|
||||||
|
clear_font_cache()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFontForLang:
|
||||||
|
"""Tests for font_for_lang function."""
|
||||||
|
|
||||||
|
@patch("engine.render.font")
|
||||||
|
def test_returns_default_for_none(self, mock_font):
|
||||||
|
"""Returns default font when lang is None."""
|
||||||
|
result = font_for_lang(None)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("engine.render.font")
|
||||||
|
def test_returns_default_for_unknown_lang(self, mock_font):
|
||||||
|
"""Returns default font for unknown language."""
|
||||||
|
result = font_for_lang("unknown_lang")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeBlock:
|
||||||
|
"""Tests for make_block function."""
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_basic(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Basic make_block returns content, color, meta index."""
|
||||||
|
mock_wrap.return_value = ["Headline content", ""]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
content, color, meta_idx = make_block(
|
||||||
|
"Test headline", "TestSource", "12:00", 80
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(content) > 0
|
||||||
|
assert color is not None
|
||||||
|
assert meta_idx >= 0
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires full PIL/font environment")
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_translation(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Translation is applied when mode is news."""
|
||||||
|
mock_wrap.return_value = ["Translated"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
mock_detect.return_value = "de"
|
||||||
|
|
||||||
|
with patch("engine.config.MODE", "news"):
|
||||||
|
content, _, _ = make_block("Test", "Source", "12:00", 80)
|
||||||
|
mock_translate.assert_called_once()
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_no_translation_poetry(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""No translation when mode is poetry."""
|
||||||
|
mock_wrap.return_value = ["Poem content"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
with patch("engine.config.MODE", "poetry"):
|
||||||
|
make_block("Test", "Source", "12:00", 80)
|
||||||
|
mock_translate.assert_not_called()
|
||||||
|
|
||||||
|
@patch("engine.translate.translate_headline")
|
||||||
|
@patch("engine.translate.detect_location_language")
|
||||||
|
@patch("engine.render.font_for_lang")
|
||||||
|
@patch("engine.render.big_wrap")
|
||||||
|
@patch("engine.render.random")
|
||||||
|
def test_make_block_meta_format(
|
||||||
|
self, mock_random, mock_wrap, mock_font, mock_detect, mock_translate
|
||||||
|
):
|
||||||
|
"""Meta line includes source and timestamp."""
|
||||||
|
mock_wrap.return_value = ["Content"]
|
||||||
|
mock_random.choice.return_value = "\033[38;5;46m"
|
||||||
|
|
||||||
|
content, _, meta_idx = make_block("Test", "MySource", "14:30", 80)
|
||||||
|
|
||||||
|
meta_line = content[meta_idx]
|
||||||
|
assert "MySource" in meta_line
|
||||||
|
assert "14:30" in meta_line
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderLine:
|
||||||
|
"""Tests for render_line function."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Empty string returns empty list."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
result = render_line("")
|
||||||
assert result == [""]
|
assert result == [""]
|
||||||
finally:
|
|
||||||
config.ACTIVE_THEME = original_theme
|
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||||
|
def test_uses_default_font(self):
|
||||||
|
"""Uses default font when none provided."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||||
|
render_line("test")
|
||||||
|
|
||||||
|
def test_getbbox_returns_none(self):
|
||||||
|
"""Handles None bbox gracefully."""
|
||||||
|
from engine.render import render_line
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = None
|
||||||
|
result = render_line("test")
|
||||||
|
assert result == [""]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBigWrap:
|
||||||
|
"""Tests for big_wrap function."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
"""Empty string returns empty list."""
|
||||||
|
from engine.render import big_wrap
|
||||||
|
|
||||||
|
result = big_wrap("", 80)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Requires real font/PIL setup")
|
||||||
|
def test_single_word_fits(self):
|
||||||
|
"""Single short word returns rendered."""
|
||||||
|
from engine.render import big_wrap
|
||||||
|
|
||||||
|
with patch("engine.render.font") as mock_font:
|
||||||
|
mock_font.return_value = MagicMock()
|
||||||
|
mock_font.return_value.getbbox.return_value = (0, 0, 10, 10)
|
||||||
|
result = big_wrap("test", 80)
|
||||||
|
assert len(result) > 0
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for engine.themes module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine import themes
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeConstruction:
|
|
||||||
"""Tests for Theme class initialization."""
|
|
||||||
|
|
||||||
def test_theme_construction(self):
|
|
||||||
"""Theme stores name and gradients correctly."""
|
|
||||||
main_grad = ["color1", "color2", "color3"]
|
|
||||||
msg_grad = ["msg1", "msg2", "msg3"]
|
|
||||||
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
|
||||||
|
|
||||||
assert theme.name == "test_theme"
|
|
||||||
assert theme.main_gradient == main_grad
|
|
||||||
assert theme.message_gradient == msg_grad
|
|
||||||
|
|
||||||
|
|
||||||
class TestGradientLength:
|
|
||||||
"""Tests for gradient length validation."""
|
|
||||||
|
|
||||||
def test_gradient_length_green(self):
|
|
||||||
"""Green theme has exactly 12 colors in each gradient."""
|
|
||||||
green = themes.THEME_REGISTRY["green"]
|
|
||||||
assert len(green.main_gradient) == 12
|
|
||||||
assert len(green.message_gradient) == 12
|
|
||||||
|
|
||||||
def test_gradient_length_orange(self):
|
|
||||||
"""Orange theme has exactly 12 colors in each gradient."""
|
|
||||||
orange = themes.THEME_REGISTRY["orange"]
|
|
||||||
assert len(orange.main_gradient) == 12
|
|
||||||
assert len(orange.message_gradient) == 12
|
|
||||||
|
|
||||||
def test_gradient_length_purple(self):
|
|
||||||
"""Purple theme has exactly 12 colors in each gradient."""
|
|
||||||
purple = themes.THEME_REGISTRY["purple"]
|
|
||||||
assert len(purple.main_gradient) == 12
|
|
||||||
assert len(purple.message_gradient) == 12
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeRegistry:
|
|
||||||
"""Tests for THEME_REGISTRY dictionary."""
|
|
||||||
|
|
||||||
def test_theme_registry_has_three_themes(self):
|
|
||||||
"""Registry contains exactly three themes: green, orange, purple."""
|
|
||||||
assert len(themes.THEME_REGISTRY) == 3
|
|
||||||
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
|
||||||
|
|
||||||
def test_registry_values_are_themes(self):
|
|
||||||
"""All registry values are Theme instances."""
|
|
||||||
for theme_id, theme in themes.THEME_REGISTRY.items():
|
|
||||||
assert isinstance(theme, themes.Theme)
|
|
||||||
assert theme.name == theme_id
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetTheme:
|
|
||||||
"""Tests for get_theme function."""
|
|
||||||
|
|
||||||
def test_get_theme_valid_green(self):
|
|
||||||
"""get_theme('green') returns correct green Theme."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
assert isinstance(green, themes.Theme)
|
|
||||||
assert green.name == "green"
|
|
||||||
|
|
||||||
def test_get_theme_valid_orange(self):
|
|
||||||
"""get_theme('orange') returns correct orange Theme."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
assert isinstance(orange, themes.Theme)
|
|
||||||
assert orange.name == "orange"
|
|
||||||
|
|
||||||
def test_get_theme_valid_purple(self):
|
|
||||||
"""get_theme('purple') returns correct purple Theme."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
assert isinstance(purple, themes.Theme)
|
|
||||||
assert purple.name == "purple"
|
|
||||||
|
|
||||||
def test_get_theme_invalid(self):
|
|
||||||
"""get_theme with invalid ID raises KeyError."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
themes.get_theme("invalid_theme")
|
|
||||||
|
|
||||||
def test_get_theme_invalid_none(self):
|
|
||||||
"""get_theme with None raises KeyError."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
themes.get_theme(None)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGreenTheme:
|
|
||||||
"""Tests for green theme specific values."""
|
|
||||||
|
|
||||||
def test_green_theme_unchanged(self):
|
|
||||||
"""Green theme maintains original color sequence."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
|
||||||
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
|
||||||
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
|
||||||
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
|
||||||
|
|
||||||
assert green.main_gradient == expected_main
|
|
||||||
assert green.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_green_theme_name(self):
|
|
||||||
"""Green theme has correct name."""
|
|
||||||
green = themes.get_theme("green")
|
|
||||||
assert green.name == "green"
|
|
||||||
|
|
||||||
|
|
||||||
class TestOrangeTheme:
|
|
||||||
"""Tests for orange theme specific values."""
|
|
||||||
|
|
||||||
def test_orange_theme_unchanged(self):
|
|
||||||
"""Orange theme maintains original color sequence."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
|
||||||
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
|
||||||
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
|
||||||
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
|
||||||
|
|
||||||
assert orange.main_gradient == expected_main
|
|
||||||
assert orange.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_orange_theme_name(self):
|
|
||||||
"""Orange theme has correct name."""
|
|
||||||
orange = themes.get_theme("orange")
|
|
||||||
assert orange.name == "orange"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPurpleTheme:
|
|
||||||
"""Tests for purple theme specific values."""
|
|
||||||
|
|
||||||
def test_purple_theme_unchanged(self):
|
|
||||||
"""Purple theme maintains original color sequence."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
|
|
||||||
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
|
||||||
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
|
||||||
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
|
||||||
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
|
||||||
|
|
||||||
assert purple.main_gradient == expected_main
|
|
||||||
assert purple.message_gradient == expected_msg
|
|
||||||
|
|
||||||
def test_purple_theme_name(self):
|
|
||||||
"""Purple theme has correct name."""
|
|
||||||
purple = themes.get_theme("purple")
|
|
||||||
assert purple.name == "purple"
|
|
||||||
|
|
||||||
|
|
||||||
class TestThemeDataOnly:
|
|
||||||
"""Tests to ensure themes module has no problematic imports."""
|
|
||||||
|
|
||||||
def test_themes_module_imports(self):
|
|
||||||
"""themes module should be data-only without config/render imports."""
|
|
||||||
import inspect
|
|
||||||
source = inspect.getsource(themes)
|
|
||||||
# Verify no imports of config or render (look for actual import statements)
|
|
||||||
lines = source.split('\n')
|
|
||||||
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
|
||||||
# Filter out empty and comment lines
|
|
||||||
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
|
||||||
# Should have no import lines
|
|
||||||
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
|
||||||
115
tests/test_translate.py
Normal file
115
tests/test_translate.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.translate module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from engine.translate import (
|
||||||
|
_translate_cached,
|
||||||
|
detect_location_language,
|
||||||
|
translate_headline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_translate_cache():
|
||||||
|
"""Clear the LRU cache between tests."""
|
||||||
|
_translate_cached.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLocationLanguage:
|
||||||
|
"""Tests for detect_location_language function."""
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown_location(self):
|
||||||
|
"""Returns None when no location pattern matches."""
|
||||||
|
result = detect_location_language("Breaking news about technology")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_detects_berlin(self):
|
||||||
|
"""Detects Berlin location."""
|
||||||
|
result = detect_location_language("Berlin police arrest protesters")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_detects_paris(self):
|
||||||
|
"""Detects Paris location."""
|
||||||
|
result = detect_location_language("Paris fashion week begins")
|
||||||
|
assert result == "fr"
|
||||||
|
|
||||||
|
def test_detects_tokyo(self):
|
||||||
|
"""Detects Tokyo location."""
|
||||||
|
result = detect_location_language("Tokyo stocks rise")
|
||||||
|
assert result == "ja"
|
||||||
|
|
||||||
|
def test_detects_berlin_again(self):
|
||||||
|
"""Detects Berlin location again."""
|
||||||
|
result = detect_location_language("Berlin marathon set to begin")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
"""Detection is case insensitive."""
|
||||||
|
result = detect_location_language("BERLIN SUMMER FESTIVAL")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
def test_returns_first_match(self):
|
||||||
|
"""Returns first matching pattern."""
|
||||||
|
result = detect_location_language("Berlin in Paris for the event")
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateHeadline:
|
||||||
|
"""Tests for translate_headline function."""
|
||||||
|
|
||||||
|
def test_returns_translated_text(self):
|
||||||
|
"""Returns translated text from cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.translate_headline") as mock_fn:
|
||||||
|
mock_fn.return_value = "Translated title"
|
||||||
|
from engine.translate import translate_headline as th
|
||||||
|
|
||||||
|
result = th("Original title", "de")
|
||||||
|
assert result == "Translated title"
|
||||||
|
|
||||||
|
def test_uses_cached_result(self):
|
||||||
|
"""Translation uses LRU cache."""
|
||||||
|
clear_translate_cache()
|
||||||
|
result1 = translate_headline("Test unique", "es")
|
||||||
|
result2 = translate_headline("Test unique", "es")
|
||||||
|
assert result1 == result2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateCached:
|
||||||
|
"""Tests for _translate_cached function."""
|
||||||
|
|
||||||
|
def test_translation_network_error(self):
|
||||||
|
"""Network error returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
result = _translate_cached("Hello world", "de")
|
||||||
|
|
||||||
|
assert result == "Hello world"
|
||||||
|
|
||||||
|
def test_translation_invalid_json(self):
|
||||||
|
"""Invalid JSON returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b"invalid json"
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
|
|
||||||
|
def test_translation_empty_response(self):
|
||||||
|
"""Empty translation response returns original text."""
|
||||||
|
clear_translate_cache()
|
||||||
|
with patch("engine.translate.urllib.request.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = json.dumps([[[""], None, "de"], None])
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
result = _translate_cached("Hello", "de")
|
||||||
|
|
||||||
|
assert result == "Hello"
|
||||||
161
tests/test_websocket.py
Normal file
161
tests/test_websocket.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display.backends.websocket module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayImport:
|
||||||
|
"""Test that websocket module can be imported."""
|
||||||
|
|
||||||
|
def test_import_does_not_error(self):
|
||||||
|
"""Module imports without error."""
|
||||||
|
from engine.display import backends
|
||||||
|
|
||||||
|
assert backends is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayInit:
|
||||||
|
"""Tests for WebSocketDisplay initialization."""
|
||||||
|
|
||||||
|
def test_default_init(self):
|
||||||
|
"""Default initialization sets correct defaults."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", None):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.host == "0.0.0.0"
|
||||||
|
assert display.port == 8765
|
||||||
|
assert display.http_port == 8766
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_custom_init(self):
|
||||||
|
"""Custom initialization uses provided values."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", None):
|
||||||
|
display = WebSocketDisplay(host="localhost", port=9000, http_port=9001)
|
||||||
|
assert display.host == "localhost"
|
||||||
|
assert display.port == 9000
|
||||||
|
assert display.http_port == 9001
|
||||||
|
|
||||||
|
def test_is_available_when_websockets_present(self):
|
||||||
|
"""is_available returns True when websockets is available."""
|
||||||
|
pytest.importorskip("websockets")
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is True
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_is_available_when_websockets_missing(self):
|
||||||
|
"""is_available returns False when websockets is not available."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.is_available() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayProtocol:
|
||||||
|
"""Test that WebSocketDisplay satisfies Display protocol."""
|
||||||
|
|
||||||
|
def test_websocket_display_is_display(self):
|
||||||
|
"""WebSocketDisplay satisfies Display protocol."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert hasattr(display, "init")
|
||||||
|
assert hasattr(display, "show")
|
||||||
|
assert hasattr(display, "clear")
|
||||||
|
assert hasattr(display, "cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayMethods:
|
||||||
|
"""Tests for WebSocketDisplay methods."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores terminal dimensions."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.init(100, 40)
|
||||||
|
assert display.width == 100
|
||||||
|
assert display.height == 40
|
||||||
|
|
||||||
|
def test_client_count_initially_zero(self):
|
||||||
|
"""client_count returns 0 when no clients connected."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.client_count() == 0
|
||||||
|
|
||||||
|
def test_get_ws_port(self):
|
||||||
|
"""get_ws_port returns configured port."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(port=9000)
|
||||||
|
assert display.get_ws_port() == 9000
|
||||||
|
|
||||||
|
def test_get_http_port(self):
|
||||||
|
"""get_http_port returns configured port."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay(http_port=9001)
|
||||||
|
assert display.get_http_port() == 9001
|
||||||
|
|
||||||
|
def test_frame_delay_defaults_to_zero(self):
|
||||||
|
"""get_frame_delay returns 0 by default."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display.get_frame_delay() == 0.0
|
||||||
|
|
||||||
|
def test_set_frame_delay(self):
|
||||||
|
"""set_frame_delay stores the value."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.set_frame_delay(0.05)
|
||||||
|
assert display.get_frame_delay() == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayCallbacks:
|
||||||
|
"""Tests for WebSocketDisplay callback methods."""
|
||||||
|
|
||||||
|
def test_set_client_connected_callback(self):
|
||||||
|
"""set_client_connected_callback stores callback."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_connected_callback(callback)
|
||||||
|
assert display._client_connected_callback is callback
|
||||||
|
|
||||||
|
def test_set_client_disconnected_callback(self):
|
||||||
|
"""set_client_disconnected_callback stores callback."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_client_disconnected_callback(callback)
|
||||||
|
assert display._client_disconnected_callback is callback
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketDisplayUnavailable:
|
||||||
|
"""Tests when WebSocket support is unavailable."""
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_server_noop_when_unavailable(self):
|
||||||
|
"""start_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_server()
|
||||||
|
assert display._server_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_start_http_server_noop_when_unavailable(self):
|
||||||
|
"""start_http_server does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.start_http_server()
|
||||||
|
assert display._http_thread is None
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
pytest.importorskip("websockets") is not None, reason="websockets is available"
|
||||||
|
)
|
||||||
|
def test_show_noops_when_unavailable(self):
|
||||||
|
"""show does nothing when websockets unavailable."""
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display.show(["line1", "line2"])
|
||||||
Reference in New Issue
Block a user