forked from genewildish/Mainline
Merge pull request 'feat/color-pick' (#30) from feat/color-pick into main
Reviewed-on: genewildish/Mainline#30
This commit is contained in:
17
README.md
17
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,6 +68,21 @@ Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The select
|
|||||||
|
|
||||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||||
|
|
||||||
|
### Color Schemes
|
||||||
|
|
||||||
|
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
|
||||||
|
|
||||||
|
On startup, an interactive picker presents all available color schemes:
|
||||||
|
```
|
||||||
|
[1] Verdant Green (white-hot → deep green)
|
||||||
|
[2] Molten Orange (white-hot → deep orange)
|
||||||
|
[3] Violet Purple (white-hot → deep purple)
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
|
||||||
|
|
||||||
|
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
|
||||||
|
|
||||||
### ntfy.sh
|
### ntfy.sh
|
||||||
|
|
||||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||||
|
|||||||
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
# Color Scheme Switcher Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||||
|
|
||||||
|
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Purpose | Change Type |
|
||||||
|
|------|---------|------------|
|
||||||
|
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||||
|
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||||
|
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||||
|
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||||
|
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||||
|
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Theme Data Module
|
||||||
|
|
||||||
|
### Task 1: Create themes.py with Theme class and registry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `engine/themes.py`
|
||||||
|
- Test: `tests/test_themes.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for Theme class**
|
||||||
|
|
||||||
|
Create `tests/test_themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Test color themes and registry."""
|
||||||
|
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_construction():
|
||||||
|
"""Theme stores name and gradient lists."""
|
||||||
|
main = ["\033[1;38;5;231m"] * 12
|
||||||
|
msg = ["\033[1;38;5;225m"] * 12
|
||||||
|
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||||
|
|
||||||
|
assert theme.name == "Test Green"
|
||||||
|
assert theme.main_gradient == main
|
||||||
|
assert theme.message_gradient == msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_gradient_length():
|
||||||
|
"""Each gradient must have exactly 12 ANSI codes."""
|
||||||
|
for theme_id, theme in THEME_REGISTRY.items():
|
||||||
|
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||||
|
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes():
|
||||||
|
"""Registry contains green, orange, purple."""
|
||||||
|
assert len(THEME_REGISTRY) == 3
|
||||||
|
assert "green" in THEME_REGISTRY
|
||||||
|
assert "orange" in THEME_REGISTRY
|
||||||
|
assert "purple" in THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_valid():
|
||||||
|
"""get_theme returns Theme object for valid ID."""
|
||||||
|
theme = get_theme("green")
|
||||||
|
assert isinstance(theme, Theme)
|
||||||
|
assert theme.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_theme_invalid():
|
||||||
|
"""get_theme raises KeyError for invalid ID."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
get_theme("invalid_theme")
|
||||||
|
|
||||||
|
|
||||||
|
def test_green_theme_unchanged():
|
||||||
|
"""Green theme uses original green → magenta colors."""
|
||||||
|
green_theme = get_theme("green")
|
||||||
|
# First color should be white (bold)
|
||||||
|
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||||
|
# Last deep green
|
||||||
|
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||||
|
# Message gradient is magenta
|
||||||
|
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: FAIL (module doesn't exist)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||||
|
|
||||||
|
Create `engine/themes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Color theme definitions and registry."""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
"""Initialize theme with display name and gradient lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Display name (e.g., "Verdant Green")
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||||
|
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||||
|
# Each gradient: white → primary/complementary, 12 steps total
|
||||||
|
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||||
|
|
||||||
|
_GREEN_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_GREEN_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;215m", # pale orange-white
|
||||||
|
"\033[38;5;209m", # bright orange
|
||||||
|
"\033[38;5;208m", # vibrant orange
|
||||||
|
"\033[38;5;202m", # orange
|
||||||
|
"\033[38;5;166m", # dark orange
|
||||||
|
"\033[38;5;130m", # burnt orange
|
||||||
|
"\033[38;5;94m", # rust
|
||||||
|
"\033[38;5;58m", # dark rust
|
||||||
|
"\033[38;5;94m", # rust (hold)
|
||||||
|
"\033[2;38;5;94m", # dim rust
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_ORANGE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;33m", # bright blue
|
||||||
|
"\033[38;5;27m", # blue
|
||||||
|
"\033[38;5;21m", # deep blue
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;21m", # deep blue (hold)
|
||||||
|
"\033[38;5;18m", # navy
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[38;5;18m", # navy (hold)
|
||||||
|
"\033[2;38;5;18m", # dim navy
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale purple-white
|
||||||
|
"\033[38;5;177m", # bright purple
|
||||||
|
"\033[38;5;171m", # vibrant purple
|
||||||
|
"\033[38;5;165m", # purple
|
||||||
|
"\033[38;5;135m", # medium purple
|
||||||
|
"\033[38;5;129m", # purple
|
||||||
|
"\033[38;5;93m", # dark purple
|
||||||
|
"\033[38;5;57m", # deep purple
|
||||||
|
"\033[38;5;57m", # deep purple (hold)
|
||||||
|
"\033[2;38;5;57m", # dim deep purple
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
_PURPLE_MESSAGE = [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;226m", # pale yellow-white
|
||||||
|
"\033[38;5;226m", # bright yellow
|
||||||
|
"\033[38;5;220m", # yellow
|
||||||
|
"\033[38;5;220m", # yellow (hold)
|
||||||
|
"\033[38;5;184m", # dark yellow
|
||||||
|
"\033[38;5;184m", # dark yellow (hold)
|
||||||
|
"\033[38;5;178m", # olive-yellow
|
||||||
|
"\033[38;5;178m", # olive-yellow (hold)
|
||||||
|
"\033[38;5;172m", # golden
|
||||||
|
"\033[2;38;5;172m", # dim golden
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme(
|
||||||
|
name="Verdant Green",
|
||||||
|
main_gradient=_GREEN_MAIN,
|
||||||
|
message_gradient=_GREEN_MESSAGE,
|
||||||
|
),
|
||||||
|
"orange": Theme(
|
||||||
|
name="Molten Orange",
|
||||||
|
main_gradient=_ORANGE_MAIN,
|
||||||
|
message_gradient=_ORANGE_MESSAGE,
|
||||||
|
),
|
||||||
|
"purple": Theme(
|
||||||
|
name="Violet Purple",
|
||||||
|
main_gradient=_PURPLE_MAIN,
|
||||||
|
message_gradient=_PURPLE_MESSAGE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id: str) -> Theme:
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id not found in registry
|
||||||
|
"""
|
||||||
|
if theme_id not in THEME_REGISTRY:
|
||||||
|
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_themes.py -v`
|
||||||
|
Expected: PASS (all 6 tests)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/themes.py tests/test_themes.py
|
||||||
|
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||||
|
|
||||||
|
- Define Theme class to encapsulate name and main/message gradients
|
||||||
|
- Create THEME_REGISTRY with green, orange, purple themes
|
||||||
|
- Each gradient has 12 ANSI 256-color codes finalized
|
||||||
|
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||||
|
- Add get_theme() lookup with error handling
|
||||||
|
- Add comprehensive unit tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Config Integration
|
||||||
|
|
||||||
|
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/config.py:1-30`
|
||||||
|
- Test: `tests/test_config.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for config changes**
|
||||||
|
|
||||||
|
Add to `tests/test_config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_active_theme_initially_none():
|
||||||
|
"""ACTIVE_THEME is None before initialization."""
|
||||||
|
# This test may fail if config is already initialized
|
||||||
|
# We'll set it to None first for testing
|
||||||
|
import engine.config
|
||||||
|
engine.config.ACTIVE_THEME = None
|
||||||
|
assert engine.config.ACTIVE_THEME is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_green():
|
||||||
|
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
set_active_theme("green")
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME is not None
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
assert config.ACTIVE_THEME == get_theme("green")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_default():
|
||||||
|
"""set_active_theme() with no args defaults to green."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
set_active_theme()
|
||||||
|
|
||||||
|
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_theme_invalid():
|
||||||
|
"""set_active_theme() with invalid ID raises KeyError."""
|
||||||
|
from engine.config import set_active_theme
|
||||||
|
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
set_active_theme("invalid")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
Expected: FAIL (functions don't exist yet)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||||
|
|
||||||
|
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: One of "green", "orange", "purple"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is invalid
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DELETED:
|
||||||
|
# GRAD_COLS = [...]
|
||||||
|
# MSG_GRAD_COLS = [...]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||||
|
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 4 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify existing config tests still pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_config.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (all existing + new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/config.py tests/test_config.py
|
||||||
|
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||||
|
|
||||||
|
- Add ACTIVE_THEME global (initialized to None)
|
||||||
|
- Add set_active_theme(theme_id) function with green default
|
||||||
|
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||||
|
- Add comprehensive tests for theme setting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Render Pipeline Integration
|
||||||
|
|
||||||
|
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/render.py:15-220`
|
||||||
|
- Test: `tests/test_render.py` (expand existing)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||||
|
|
||||||
|
Add to `tests/test_render.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||||
|
from engine import config, render
|
||||||
|
from engine.themes import get_theme
|
||||||
|
|
||||||
|
# Set orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
# Create simple rows
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Result should start with first color from orange main gradient
|
||||||
|
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||||
|
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
# Clear active theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
rows = ["test row"]
|
||||||
|
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||||
|
|
||||||
|
# Should not crash and should return something
|
||||||
|
assert result is not None
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_green_gradient_length():
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
from engine import render
|
||||||
|
|
||||||
|
colors = render._default_green_gradient()
|
||||||
|
assert len(colors) == 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Expected: FAIL (function signature doesn't match)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||||
|
|
||||||
|
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
"""
|
||||||
|
Render rows through a left-to-right color sweep.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text rows to colorize
|
||||||
|
offset: Gradient position offset (for animation)
|
||||||
|
cols: Optional list of color codes. If None, uses active theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of colorized rows
|
||||||
|
"""
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient()
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... rest of function unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||||
|
|
||||||
|
Add to `engine/render.py` before `lr_gradient()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (original colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||||
|
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||||
|
|
||||||
|
Expected: PASS (all 3 new tests)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run full render test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_render.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing tests may need adjustment for mocking)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/render.py tests/test_render.py
|
||||||
|
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||||
|
|
||||||
|
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||||
|
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||||
|
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||||
|
- Add tests for theme-aware and fallback gradient rendering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Message Gradient Integration
|
||||||
|
|
||||||
|
### Task 4: Update scroll.py to use message gradient from config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/scroll.py:85-95`
|
||||||
|
- Test: existing `tests/test_scroll.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||||
|
|
||||||
|
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||||
|
|
||||||
|
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||||
|
|
||||||
|
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||||
|
|
||||||
|
Old code:
|
||||||
|
```python
|
||||||
|
# Some variation of:
|
||||||
|
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
```
|
||||||
|
|
||||||
|
New code:
|
||||||
|
```python
|
||||||
|
from engine import config
|
||||||
|
msg_cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else render._default_magenta_gradient()
|
||||||
|
)
|
||||||
|
rows = lr_gradient(rows, offset, msg_cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (
|
||||||
|
config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in scroll.py:
|
||||||
|
```python
|
||||||
|
rows = render.msg_gradient(rows, offset)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run existing scroll tests**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_scroll.py -v`
|
||||||
|
|
||||||
|
Expected: PASS (existing functionality unchanged)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/scroll.py engine/render.py
|
||||||
|
git commit -m "feat: update scroll.py to use theme message gradient
|
||||||
|
|
||||||
|
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||||
|
- Use fallback magenta gradient when theme not initialized
|
||||||
|
- Ensure ntfy messages render in complementary color from selected theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Color Picker UI
|
||||||
|
|
||||||
|
### Task 5: Create pick_color_theme() function in app.py
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `engine/app.py:1-300`
|
||||||
|
- Test: manual/integration (interactive)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu."""
|
||||||
|
import sys
|
||||||
|
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create pick_color_theme() function**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, add after helper function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use green
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||||
|
|
||||||
|
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... existing signal handler setup ...
|
||||||
|
|
||||||
|
pick_color_theme() # NEW LINE - before font picker
|
||||||
|
pick_font_face()
|
||||||
|
|
||||||
|
# ... rest of main unchanged ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- See color theme picker menu before font picker
|
||||||
|
- Can navigate with ↑/↓ or j/k
|
||||||
|
- Can select with Enter or q
|
||||||
|
- Selected theme applies to scrolling headlines
|
||||||
|
- Can select different themes and see colors change
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||||
|
|
||||||
|
Run: `echo "" | python3 mainline.py`
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- No color picker menu shown
|
||||||
|
- Defaults to green theme
|
||||||
|
- App runs without error
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add engine/app.py
|
||||||
|
git commit -m "feat: add pick_color_theme() UI and integration
|
||||||
|
|
||||||
|
- Create _draw_color_picker() to render menu
|
||||||
|
- Create pick_color_theme() function mirroring font picker pattern
|
||||||
|
- Integrate into main() before font picker
|
||||||
|
- Fallback to green theme in non-interactive environments
|
||||||
|
- Support arrow keys and j/k navigation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: Integration & Validation
|
||||||
|
|
||||||
|
### Task 6: End-to-end testing and cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: All modified files
|
||||||
|
- Verify: App functionality
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full test suite**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
|
||||||
|
Expected: PASS (all tests, including new ones)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
Run: `ruff check engine/ mainline.py`
|
||||||
|
|
||||||
|
Expected: No errors (fix any style issues)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual integration test - green theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Verdant Green" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in green → deep green
|
||||||
|
- ntfy messages render in magenta gradient
|
||||||
|
- Both work correctly during streaming
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual integration test - orange theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Molten Orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in orange → deep orange
|
||||||
|
- ntfy messages render in blue gradient
|
||||||
|
- Colors are visually distinct from green
|
||||||
|
|
||||||
|
- [ ] **Step 5: Manual integration test - purple theme**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py`
|
||||||
|
|
||||||
|
Then select "Violet Purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Headlines render in purple → deep purple
|
||||||
|
- ntfy messages render in yellow gradient
|
||||||
|
- Colors are visually distinct from green and orange
|
||||||
|
|
||||||
|
- [ ] **Step 6: Test poetry mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --poetry`
|
||||||
|
|
||||||
|
Then select "orange" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Poetry mode works with color picker
|
||||||
|
- Colors apply to poetry rendering
|
||||||
|
|
||||||
|
- [ ] **Step 7: Test code mode with color picker**
|
||||||
|
|
||||||
|
Run: `python3 mainline.py --code`
|
||||||
|
|
||||||
|
Then select "purple" from picker.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
- Code mode works with color picker
|
||||||
|
- Colors apply to code rendering
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify acceptance criteria**
|
||||||
|
|
||||||
|
✓ Color picker displays 3 theme options at startup
|
||||||
|
✓ Selection applies to all headline and message gradients
|
||||||
|
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
✓ No persistence between runs (each run picks fresh)
|
||||||
|
✓ Non-TTY environments default to green without error
|
||||||
|
✓ Architecture supports future random/animation modes
|
||||||
|
✓ All gradient color codes finalized with no TBD values
|
||||||
|
|
||||||
|
- [ ] **Step 9: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: color scheme switcher implementation complete
|
||||||
|
|
||||||
|
Closes color-pick feature with:
|
||||||
|
- Three selectable color themes (green, orange, purple)
|
||||||
|
- Interactive menu at startup (mirrors font picker UI)
|
||||||
|
- Complementary colors for ntfy message queue
|
||||||
|
- Fallback to green in non-interactive environments
|
||||||
|
- All tests passing, manual validation complete"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 10: Create feature branch PR summary**
|
||||||
|
|
||||||
|
```
|
||||||
|
## Color Scheme Switcher
|
||||||
|
|
||||||
|
Implements interactive color theme selection for Mainline news ticker.
|
||||||
|
|
||||||
|
### What's New
|
||||||
|
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||||
|
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||||
|
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||||
|
- Fresh theme selection each run (no persistence)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||||
|
- `engine/render.py` (theme-aware gradients)
|
||||||
|
- `engine/scroll.py` (message gradient integration)
|
||||||
|
- `engine/app.py` (pick_color_theme UI)
|
||||||
|
- `tests/test_themes.py` (new theme tests)
|
||||||
|
- `README.md` (documentation)
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
All met. App fully tested and ready for merge.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||||
|
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||||
|
- [ ] Full suite: `pytest tests/ -v`
|
||||||
|
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||||
|
- [ ] Manual: Green theme selection
|
||||||
|
- [ ] Manual: Orange theme selection
|
||||||
|
- [ ] Manual: Purple theme selection
|
||||||
|
- [ ] Manual: Poetry mode with colors
|
||||||
|
- [ ] Manual: Code mode with colors
|
||||||
|
- [ ] Manual: Non-TTY fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||||
|
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||||
|
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||||
|
- Message gradients use complementary colors; lookup in scroll.py
|
||||||
|
- Each gradient has 12 colors; verify length in tests
|
||||||
|
- No persistence; fresh picker each run
|
||||||
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Color Scheme Switcher Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-16
|
||||||
|
**Status:** Revised after review
|
||||||
|
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||||
|
|
||||||
|
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**Functional:**
|
||||||
|
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||||
|
2. Main headline gradient uses the selected primary color (white → color)
|
||||||
|
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||||
|
4. Selection is fresh each run (no persistence)
|
||||||
|
5. Design supports future "random rotation" mode without refactoring
|
||||||
|
|
||||||
|
**Complementary colors (precise opposites):**
|
||||||
|
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||||
|
- Orange (38;5;208) → Blue (38;5;21)
|
||||||
|
- Purple (38;5;129) → Yellow (38;5;226)
|
||||||
|
|
||||||
|
**Non-functional:**
|
||||||
|
- Reuse the existing font picker pattern for UI consistency
|
||||||
|
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||||
|
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||||
|
- Font picker UI remains hardcoded green for visual continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New Module: `engine/themes.py`
|
||||||
|
|
||||||
|
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Theme:
|
||||||
|
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient # white → primary color
|
||||||
|
self.message_gradient = message_gradient # white → complementary
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Registry:**
|
||||||
|
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||||
|
|
||||||
|
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||||
|
```
|
||||||
|
[
|
||||||
|
"\033[1;38;5;231m", # white (bold)
|
||||||
|
"\033[1;38;5;195m", # pale white-tint
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright color
|
||||||
|
"\033[38;5;40m", # color
|
||||||
|
"\033[38;5;34m", # medium color
|
||||||
|
"\033[38;5;28m", # dark color
|
||||||
|
"\033[38;5;22m", # deep color
|
||||||
|
"\033[2;38;5;22m", # dim deep color
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finalized color codes:**
|
||||||
|
|
||||||
|
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||||
|
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||||
|
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||||
|
|
||||||
|
**Orange (primary: 208, complementary: 21)**
|
||||||
|
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||||
|
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||||
|
|
||||||
|
**Purple (primary: 129, complementary: 226)**
|
||||||
|
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||||
|
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||||
|
|
||||||
|
**Public API:**
|
||||||
|
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||||
|
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/config.py`
|
||||||
|
|
||||||
|
**New globals:**
|
||||||
|
```python
|
||||||
|
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||||
|
```
|
||||||
|
|
||||||
|
**New function:**
|
||||||
|
```python
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Called by `app.pick_color_theme()` with user selection
|
||||||
|
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||||
|
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||||
|
|
||||||
|
**Removal:**
|
||||||
|
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/render.py`
|
||||||
|
|
||||||
|
**Updated gradient access in existing functions:**
|
||||||
|
|
||||||
|
Current pattern (will be removed):
|
||||||
|
```python
|
||||||
|
GRAD_COLS = [...] # hardcoded green
|
||||||
|
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||||
|
```
|
||||||
|
|
||||||
|
New pattern — update `lr_gradient()` function:
|
||||||
|
```python
|
||||||
|
def lr_gradient(rows, offset, cols=None):
|
||||||
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.main_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_green_gradient())
|
||||||
|
# ... rest of function unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Define fallback:**
|
||||||
|
```python
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Fallback green gradient (current colors)."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||||
|
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||||
|
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||||
|
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message gradient handling:**
|
||||||
|
|
||||||
|
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||||
|
```python
|
||||||
|
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
# Use:
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
or define a helper:
|
||||||
|
```python
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||||
|
from engine import config
|
||||||
|
cols = (config.ACTIVE_THEME.message_gradient
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient())
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modified: `engine/app.py`
|
||||||
|
|
||||||
|
**New function: `pick_color_theme()`**
|
||||||
|
|
||||||
|
Mirrors `pick_font_face()` pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||||
|
import sys
|
||||||
|
from engine import config, themes
|
||||||
|
|
||||||
|
# Non-interactive fallback: use default
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker (similar to font picker)
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||||
|
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Placement in `main()`:**
|
||||||
|
```python
|
||||||
|
def main():
|
||||||
|
# ... signal handler setup ...
|
||||||
|
pick_color_theme() # NEW — before title/subtitle
|
||||||
|
pick_font_face()
|
||||||
|
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User starts: mainline.py
|
||||||
|
↓
|
||||||
|
main() called
|
||||||
|
↓
|
||||||
|
pick_color_theme()
|
||||||
|
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||||
|
→ If not TTY: silently call config.set_active_theme("green")
|
||||||
|
↓
|
||||||
|
pick_font_face() — renders in hardcoded green UI colors
|
||||||
|
↓
|
||||||
|
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||||
|
↓
|
||||||
|
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||||
|
↓
|
||||||
|
On exit: no persistence
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Initialization Guarantee
|
||||||
|
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||||
|
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||||
|
2. It's called before any rendering happens
|
||||||
|
3. Default fallback ensures non-TTY environments don't crash
|
||||||
|
|
||||||
|
### Module Independence
|
||||||
|
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||||
|
|
||||||
|
### Color Code Finalization
|
||||||
|
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||||
|
|
||||||
|
### Theme ID Naming
|
||||||
|
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||||
|
|
||||||
|
### Terminal Resize Handling
|
||||||
|
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||||
|
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||||
|
2. Once a theme is selected, the menu closes and rendering begins
|
||||||
|
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||||
|
|
||||||
|
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
1. **Unit tests** (`tests/test_themes.py`):
|
||||||
|
- Verify Theme class construction
|
||||||
|
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||||
|
- Confirm gradient lists have correct length (12)
|
||||||
|
|
||||||
|
2. **Integration tests** (`tests/test_render.py`):
|
||||||
|
- Mock `config.ACTIVE_THEME` to each theme
|
||||||
|
- Verify `lr_gradient()` uses correct colors
|
||||||
|
- Verify fallback works when `ACTIVE_THEME` is None
|
||||||
|
|
||||||
|
3. **Existing tests:**
|
||||||
|
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||||
|
- Use pytest fixtures to set theme per test case
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
- `engine/themes.py` (new)
|
||||||
|
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||||
|
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||||
|
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||||
|
- `tests/test_themes.py` (new unit tests)
|
||||||
|
- `tests/test_render.py` (update mocking strategy)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. ✓ Color picker displays 3 theme options at startup
|
||||||
|
2. ✓ Selection applies to all headline and message gradients
|
||||||
|
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||||
|
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||||
|
5. ✓ No persistence between runs
|
||||||
|
6. ✓ Non-TTY environments default to green without error
|
||||||
|
7. ✓ Architecture supports future random/animation modes
|
||||||
|
8. ✓ All gradient color codes finalized with no TBD values
|
||||||
@@ -10,7 +10,7 @@ import termios
|
|||||||
import time
|
import time
|
||||||
import tty
|
import tty
|
||||||
|
|
||||||
from engine import config, render
|
from engine import config, render, themes
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
from engine.mic import MicMonitor
|
from engine.mic import MicMonitor
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
@@ -65,6 +65,30 @@ def _read_picker_key():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_color_picker(themes_list, selected):
|
||||||
|
"""Draw the color theme picker menu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
||||||
|
selected: Index of currently selected theme (0-2)
|
||||||
|
"""
|
||||||
|
print(CLR, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
||||||
|
)
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
||||||
|
|
||||||
|
for i, (theme_id, theme) in enumerate(themes_list):
|
||||||
|
prefix = " ▶ " if i == selected else " "
|
||||||
|
color = G_HI if i == selected else ""
|
||||||
|
reset = "" if i == selected else W_GHOST
|
||||||
|
print(f"{prefix}{color}{theme.name}{reset}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
def _normalize_preview_rows(rows):
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
non_empty = [r for r in rows if r.strip()]
|
non_empty = [r for r in rows if r.strip()]
|
||||||
@@ -131,6 +155,50 @@ def _draw_font_picker(faces, selected):
|
|||||||
print(f" {shown}")
|
print(f" {shown}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_color_theme():
|
||||||
|
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
||||||
|
|
||||||
|
Displays a menu of available themes and lets user select with arrow keys.
|
||||||
|
Non-interactive environments (piped stdin, CI) silently default to green.
|
||||||
|
"""
|
||||||
|
# Non-interactive fallback
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
config.set_active_theme("green")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Interactive picker
|
||||||
|
themes_list = list(themes.THEME_REGISTRY.items())
|
||||||
|
selected = 0
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_color_picker(themes_list, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(themes_list) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_theme_id = themes_list[selected][0]
|
||||||
|
config.set_active_theme(selected_theme_id)
|
||||||
|
|
||||||
|
theme_name = themes_list[selected][1].name
|
||||||
|
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
def pick_font_face():
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
if not config.FONT_PICKER:
|
if not config.FONT_PICKER:
|
||||||
@@ -262,6 +330,7 @@ def main():
|
|||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_color_theme()
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
@@ -274,7 +343,7 @@ def main():
|
|||||||
print()
|
print()
|
||||||
_subtitle = {
|
_subtitle = {
|
||||||
"poetry": "literary consciousness stream",
|
"poetry": "literary consciousness stream",
|
||||||
"code": "source consciousness stream",
|
"code": "source consciousness stream",
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
}.get(config.MODE, "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}")
|
||||||
@@ -298,6 +367,7 @@ def main():
|
|||||||
save_cache(items)
|
save_cache(items)
|
||||||
elif config.MODE == "code":
|
elif config.MODE == "code":
|
||||||
from engine.fetch_code import fetch_code
|
from engine.fetch_code import fetch_code
|
||||||
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -189,8 +189,10 @@ HEADLINE_LIMIT = 1000
|
|||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = (
|
MODE = (
|
||||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
"poetry"
|
||||||
else "code" if "--code" in sys.argv
|
if "--poetry" in sys.argv or "-p" in sys.argv
|
||||||
|
else "code"
|
||||||
|
if "--code" in sys.argv
|
||||||
else "news"
|
else "news"
|
||||||
)
|
)
|
||||||
FIREHOSE = "--firehose" in sys.argv
|
FIREHOSE = "--firehose" in sys.argv
|
||||||
@@ -235,3 +237,26 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier ("green", "orange", or "purple")
|
||||||
|
Defaults to "green"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
|||||||
131
engine/render.py
131
engine/render.py
@@ -15,38 +15,72 @@ 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 ─────────────────────────────────────────────
|
|
||||||
# Left → right: white-hot leading edge fades to near-black
|
|
||||||
GRAD_COLS = [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
MSG_GRAD_COLS = [
|
def _color_codes_to_ansi(color_codes):
|
||||||
"\033[1;38;5;231m", # white
|
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
Args:
|
||||||
"\033[38;5;201m", # bright magenta
|
color_codes: List of 12 integers (256-color palette codes)
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
Returns:
|
||||||
"\033[38;5;125m", # dark magenta
|
List of ANSI escape code strings
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
"""
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
if not color_codes or len(color_codes) != 12:
|
||||||
"\033[2;38;5;235m", # near black
|
# Fallback to default green if invalid
|
||||||
]
|
return _default_green_gradient()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, code in enumerate(color_codes):
|
||||||
|
if i < 2:
|
||||||
|
# Bold for first 2 (bright leading edge)
|
||||||
|
result.append(f"\033[1;38;5;{code}m")
|
||||||
|
elif i < 10:
|
||||||
|
# Normal for middle 8
|
||||||
|
result.append(f"\033[38;5;{code}m")
|
||||||
|
else:
|
||||||
|
# Dim for last 2 (dark trailing edge)
|
||||||
|
result.append(f"\033[2;38;5;{code}m")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _default_green_gradient():
|
||||||
|
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_magenta_gradient():
|
||||||
|
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
||||||
|
return [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
@@ -189,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
def lr_gradient(rows, offset=0.0, cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
cols = grad_cols or GRAD_COLS
|
if cols is None:
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
if config.ACTIVE_THEME:
|
||||||
|
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
||||||
|
else:
|
||||||
|
cols = _default_green_gradient()
|
||||||
n = len(cols)
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -213,7 +253,30 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
|||||||
|
|
||||||
def lr_gradient_opposite(rows, offset=0.0):
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
return lr_gradient(rows, offset, _default_magenta_gradient())
|
||||||
|
|
||||||
|
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||||
|
|
||||||
|
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||||
|
falling back to default magenta if no theme is set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text strings to colorize
|
||||||
|
offset: Gradient offset (0.0-1.0) for animation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows with ANSI color codes applied
|
||||||
|
"""
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
cols = (
|
||||||
|
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||||
|
if config.ACTIVE_THEME
|
||||||
|
else _default_magenta_gradient()
|
||||||
|
)
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
|
|||||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Theme definitions with color gradients for terminal rendering.
|
||||||
|
|
||||||
|
This module is data-only and does not import config or render
|
||||||
|
to prevent circular dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Represents a color theme with two gradients."""
|
||||||
|
|
||||||
|
def __init__(self, name, main_gradient, message_gradient):
|
||||||
|
"""Initialize a theme with name and color gradients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Theme identifier string
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||||
|
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||||
|
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||||
|
|
||||||
|
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||||
|
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||||
|
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id):
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object matching the ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in registry
|
||||||
|
"""
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
301
tests/test_render.py
Normal file
301
tests/test_render.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.render module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import config, render
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultGradients:
|
||||||
|
"""Tests for default gradient fallback functions."""
|
||||||
|
|
||||||
|
def test_default_green_gradient_length(self):
|
||||||
|
"""_default_green_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_green_gradient_is_list(self):
|
||||||
|
"""_default_green_gradient returns a list."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_green_gradient_all_strings(self):
|
||||||
|
"""_default_green_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_green_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_length(self):
|
||||||
|
"""_default_magenta_gradient returns 12 colors."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert len(gradient) == 12
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_is_list(self):
|
||||||
|
"""_default_magenta_gradient returns a list."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert isinstance(gradient, list)
|
||||||
|
|
||||||
|
def test_default_magenta_gradient_all_strings(self):
|
||||||
|
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||||
|
gradient = render._default_magenta_gradient()
|
||||||
|
assert all(isinstance(code, str) for code in gradient)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientUsesActiveTheme:
|
||||||
|
"""Tests for lr_gradient using active theme."""
|
||||||
|
|
||||||
|
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||||
|
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (cols=None)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_fallback_when_no_theme(self):
|
||||||
|
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["text"]
|
||||||
|
|
||||||
|
# Call without cols parameter (should use fallback)
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should not raise and should return colored output
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes (no plain "text")
|
||||||
|
assert result[0] != "text"
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||||
|
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||||
|
# Custom gradient
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
# Should use the provided cols
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||||
|
"""lr_gradient accepts cols as keyword argument."""
|
||||||
|
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||||
|
|
||||||
|
rows = ["xy"]
|
||||||
|
# Call with cols as keyword
|
||||||
|
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLrGradientBasicFunctionality:
|
||||||
|
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||||
|
|
||||||
|
def test_lr_gradient_colors_non_space_chars(self):
|
||||||
|
"""lr_gradient colors non-space characters."""
|
||||||
|
rows = ["hello"]
|
||||||
|
|
||||||
|
# Set a theme for the test
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Result should have ANSI codes
|
||||||
|
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_preserves_spaces(self):
|
||||||
|
"""lr_gradient preserves spaces in output."""
|
||||||
|
rows = ["a b c"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved (not colored)
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_empty_rows(self):
|
||||||
|
"""lr_gradient handles empty rows correctly."""
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_lr_gradient_multiple_rows(self):
|
||||||
|
"""lr_gradient handles multiple rows."""
|
||||||
|
rows = ["row1", "row2", "row3"]
|
||||||
|
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result = render.lr_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
assert len(result) == 3
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
|
||||||
|
class TestMsgGradient:
|
||||||
|
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||||
|
|
||||||
|
def test_msg_gradient_uses_active_theme(self):
|
||||||
|
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using theme's message_gradient
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes from the message gradient
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_fallback_when_no_theme(self):
|
||||||
|
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clear the theme
|
||||||
|
config.ACTIVE_THEME = None
|
||||||
|
|
||||||
|
# Create simple test data
|
||||||
|
rows = ["MESSAGE"]
|
||||||
|
|
||||||
|
# Call msg_gradient
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Should return colored output using default magenta
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
# Should have ANSI codes
|
||||||
|
assert result[0] != "MESSAGE"
|
||||||
|
assert "\033[" in result[0]
|
||||||
|
finally:
|
||||||
|
# Restore original state
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_returns_colored_rows(self):
|
||||||
|
"""msg_gradient returns properly colored rows with animation offset."""
|
||||||
|
# Save original state
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set a theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
|
||||||
|
rows = ["NTFY", "ALERT"]
|
||||||
|
|
||||||
|
# Call with offset
|
||||||
|
result = render.msg_gradient(rows, offset=0.5)
|
||||||
|
|
||||||
|
# Should return same number of rows
|
||||||
|
assert len(result) == 2
|
||||||
|
# Both should be colored
|
||||||
|
assert all("\033[" in r for r in result)
|
||||||
|
# Should not be the original text
|
||||||
|
assert result != rows
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||||
|
"""msg_gradient produces different colors for different themes."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = ["TEST"]
|
||||||
|
|
||||||
|
# Get result with green theme
|
||||||
|
config.set_active_theme("green")
|
||||||
|
result_green = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Get result with orange theme
|
||||||
|
config.set_active_theme("orange")
|
||||||
|
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Results should be different (different message gradients)
|
||||||
|
assert result_green != result_orange
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_preserves_spacing(self):
|
||||||
|
"""msg_gradient preserves spaces in rows."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("purple")
|
||||||
|
rows = ["M E S S A G E"]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Spaces should be preserved
|
||||||
|
assert " " in result[0]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
|
|
||||||
|
def test_msg_gradient_empty_rows(self):
|
||||||
|
"""msg_gradient handles empty rows correctly."""
|
||||||
|
original_theme = config.ACTIVE_THEME
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.set_active_theme("green")
|
||||||
|
rows = [""]
|
||||||
|
|
||||||
|
result = render.msg_gradient(rows, offset=0.0)
|
||||||
|
|
||||||
|
# Empty row should stay empty
|
||||||
|
assert result == [""]
|
||||||
|
finally:
|
||||||
|
config.ACTIVE_THEME = original_theme
|
||||||
169
tests/test_themes.py
Normal file
169
tests/test_themes.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.themes module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeConstruction:
|
||||||
|
"""Tests for Theme class initialization."""
|
||||||
|
|
||||||
|
def test_theme_construction(self):
|
||||||
|
"""Theme stores name and gradients correctly."""
|
||||||
|
main_grad = ["color1", "color2", "color3"]
|
||||||
|
msg_grad = ["msg1", "msg2", "msg3"]
|
||||||
|
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||||
|
|
||||||
|
assert theme.name == "test_theme"
|
||||||
|
assert theme.main_gradient == main_grad
|
||||||
|
assert theme.message_gradient == msg_grad
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradientLength:
|
||||||
|
"""Tests for gradient length validation."""
|
||||||
|
|
||||||
|
def test_gradient_length_green(self):
|
||||||
|
"""Green theme has exactly 12 colors in each gradient."""
|
||||||
|
green = themes.THEME_REGISTRY["green"]
|
||||||
|
assert len(green.main_gradient) == 12
|
||||||
|
assert len(green.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_orange(self):
|
||||||
|
"""Orange theme has exactly 12 colors in each gradient."""
|
||||||
|
orange = themes.THEME_REGISTRY["orange"]
|
||||||
|
assert len(orange.main_gradient) == 12
|
||||||
|
assert len(orange.message_gradient) == 12
|
||||||
|
|
||||||
|
def test_gradient_length_purple(self):
|
||||||
|
"""Purple theme has exactly 12 colors in each gradient."""
|
||||||
|
purple = themes.THEME_REGISTRY["purple"]
|
||||||
|
assert len(purple.main_gradient) == 12
|
||||||
|
assert len(purple.message_gradient) == 12
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeRegistry:
|
||||||
|
"""Tests for THEME_REGISTRY dictionary."""
|
||||||
|
|
||||||
|
def test_theme_registry_has_three_themes(self):
|
||||||
|
"""Registry contains exactly three themes: green, orange, purple."""
|
||||||
|
assert len(themes.THEME_REGISTRY) == 3
|
||||||
|
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||||
|
|
||||||
|
def test_registry_values_are_themes(self):
|
||||||
|
"""All registry values are Theme instances."""
|
||||||
|
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||||
|
assert isinstance(theme, themes.Theme)
|
||||||
|
assert theme.name == theme_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTheme:
|
||||||
|
"""Tests for get_theme function."""
|
||||||
|
|
||||||
|
def test_get_theme_valid_green(self):
|
||||||
|
"""get_theme('green') returns correct green Theme."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert isinstance(green, themes.Theme)
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
def test_get_theme_valid_orange(self):
|
||||||
|
"""get_theme('orange') returns correct orange Theme."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert isinstance(orange, themes.Theme)
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
def test_get_theme_valid_purple(self):
|
||||||
|
"""get_theme('purple') returns correct purple Theme."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert isinstance(purple, themes.Theme)
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
def test_get_theme_invalid(self):
|
||||||
|
"""get_theme with invalid ID raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme("invalid_theme")
|
||||||
|
|
||||||
|
def test_get_theme_invalid_none(self):
|
||||||
|
"""get_theme with None raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
themes.get_theme(None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGreenTheme:
|
||||||
|
"""Tests for green theme specific values."""
|
||||||
|
|
||||||
|
def test_green_theme_unchanged(self):
|
||||||
|
"""Green theme maintains original color sequence."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||||
|
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||||
|
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
assert green.main_gradient == expected_main
|
||||||
|
assert green.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_green_theme_name(self):
|
||||||
|
"""Green theme has correct name."""
|
||||||
|
green = themes.get_theme("green")
|
||||||
|
assert green.name == "green"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrangeTheme:
|
||||||
|
"""Tests for orange theme specific values."""
|
||||||
|
|
||||||
|
def test_orange_theme_unchanged(self):
|
||||||
|
"""Orange theme maintains original color sequence."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||||
|
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||||
|
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
assert orange.main_gradient == expected_main
|
||||||
|
assert orange.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_orange_theme_name(self):
|
||||||
|
"""Orange theme has correct name."""
|
||||||
|
orange = themes.get_theme("orange")
|
||||||
|
assert orange.name == "orange"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurpleTheme:
|
||||||
|
"""Tests for purple theme specific values."""
|
||||||
|
|
||||||
|
def test_purple_theme_unchanged(self):
|
||||||
|
"""Purple theme maintains original color sequence."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
|
||||||
|
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||||
|
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||||
|
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
assert purple.main_gradient == expected_main
|
||||||
|
assert purple.message_gradient == expected_msg
|
||||||
|
|
||||||
|
def test_purple_theme_name(self):
|
||||||
|
"""Purple theme has correct name."""
|
||||||
|
purple = themes.get_theme("purple")
|
||||||
|
assert purple.name == "purple"
|
||||||
|
|
||||||
|
|
||||||
|
class TestThemeDataOnly:
|
||||||
|
"""Tests to ensure themes module has no problematic imports."""
|
||||||
|
|
||||||
|
def test_themes_module_imports(self):
|
||||||
|
"""themes module should be data-only without config/render imports."""
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(themes)
|
||||||
|
# Verify no imports of config or render (look for actual import statements)
|
||||||
|
lines = source.split('\n')
|
||||||
|
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||||
|
# Filter out empty and comment lines
|
||||||
|
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||||
|
# Should have no import lines
|
||||||
|
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||||
Reference in New Issue
Block a user