10 Commits

Author SHA1 Message Date
bc20a35ea9 refactor: Fix import ordering and code formatting with ruff
Organize imports in render.py and scroll.py to meet ruff style requirements.
Add blank lines for code formatting compliance.
2026-03-16 03:01:22 -07:00
d4d0344a12 feat: add pick_color_theme() UI and integration 2026-03-16 02:58:18 -07:00
84cb16d463 feat: update scroll.py to use theme message gradient
Add msg_gradient() helper function to render.py that applies message
(ntfy) gradient using the active theme's message_gradient property.
This replaces hardcoded magenta gradient in scroll.py with a theme-aware
approach that uses complementary colors from the active theme.

- Add msg_gradient(rows, offset) helper in render.py with fallback to
  default magenta gradient when no theme is active
- Update scroll.py imports to use msg_gradient instead of
  lr_gradient_opposite
- Replace lr_gradient_opposite() call with msg_gradient() in message
  overlay rendering
- Add 6 comprehensive tests for msg_gradient covering theme usage,
  fallback behavior, and edge cases

All tests pass (121 passed), no regressions detected.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-16 02:55:17 -07:00
d67423fe4c feat: update lr_gradient to use config.ACTIVE_THEME
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS module constants
- Add _default_green_gradient() and _default_magenta_gradient() fallback functions
- Add _color_codes_to_ansi() to convert integer color codes from themes to ANSI escape strings
- Update lr_gradient() signature: cols parameter (was grad_cols)
- lr_gradient() now pulls colors from config.ACTIVE_THEME when available
- Falls back to default green gradient when no theme is active
- Existing calls with explicit cols parameter continue to work
- Add comprehensive tests for new functionality

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-16 02:53:22 -07:00
ebe7b04ba5 feat: add ACTIVE_THEME global and set_active_theme() to config 2026-03-16 02:50:36 -07:00
abc4483859 feat: create Theme class and registry with finalized color gradients 2026-03-16 02:49:15 -07:00
d9422b1fec docs: add color scheme implementation plan
Comprehensive plan with 6 chunks, each containing bite-sized TDD tasks:
- Chunk 1: Theme class and registry
- Chunk 2: Config integration
- Chunk 3: Render pipeline
- Chunk 4: Message gradient integration
- Chunk 5: Color picker UI
- Chunk 6: Integration and validation

Each step includes exact code, test commands, and expected output.
2026-03-16 02:47:25 -07:00
6daea90b0a docs: add color scheme feature documentation to README
- Update opening description to mention selectable color gradients
- Add new 'Color Schemes' section with picker usage instructions
- Document three available themes (Green, Orange, Purple)
- Clarify that boot UI uses hardcoded green, not theme colors
2026-03-16 02:44:59 -07:00
9d9172ef0d docs: add terminal resize handling clarification 2026-03-16 02:42:59 -07:00
667bef2685 docs: revise color scheme design spec to address review feedback
- Clarify boot messages use hardcoded green, not theme gradients
- Finalize all gradient ANSI color codes (no TBD)
- Add initialization guarantee for ACTIVE_THEME
- Resolve circular import risk with data-only themes.py
- Update theme ID naming to match menu labels
- Expand test strategy and mock approach
2026-03-16 02:42:19 -07:00
11 changed files with 1839 additions and 93 deletions

View File

@@ -2,7 +2,7 @@
> *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.
### Color Schemes
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
On startup, an interactive picker presents all available color schemes:
```
[1] Verdant Green (white-hot → deep green)
[2] Molten Orange (white-hot → deep orange)
[3] Violet Purple (white-hot → deep purple)
```
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
### ntfy.sh
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.

View 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

View File

@@ -1,7 +1,7 @@
# Color Scheme Switcher Design
**Date:** 2026-03-16
**Status:** Draft
**Status:** Revised after review
**Scope:** Interactive color theme selection for Mainline news ticker
---
@@ -31,7 +31,8 @@ The implementation uses a dedicated `Theme` class to encapsulate gradients and m
**Non-functional:**
- Reuse the existing font picker pattern for UI consistency
- Zero runtime overhead during streaming (theme lookup happens once at startup)
- Boot messages and title render in the selected theme
- **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
---
@@ -39,6 +40,8 @@ The implementation uses a dedicated `Theme` class to encapsulate gradients and m
### 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."""
@@ -50,20 +53,42 @@ class Theme:
```
**Theme Registry:**
Three instances registered by ID: `"verdant"`, `"molten"`, `"violet"`.
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 format in `render.py`:
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 if not found
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
- `THEME_REGISTRY` — dict of all available themes (for picker)
---
@@ -72,49 +97,83 @@ Each gradient is a list of 12 ANSI 256-color codes matching the current format i
**New globals:**
```python
ACTIVE_THEME = None # set by set_active_theme() after picker
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):
"""Set the active theme after user selection."""
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` — these move to `themes.py`
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
---
### Modified: `engine/render.py`
**Updated gradient access:**
**Updated gradient access in existing functions:**
Old pattern:
Current pattern (will be removed):
```python
def lr_gradient(rows, offset, cols=None):
cols = cols or GRAD_COLS # module-level constant
GRAD_COLS = [...] # hardcoded green
MSG_GRAD_COLS = [...] # hardcoded magenta
```
New pattern:
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_GRAD
cols = (config.ACTIVE_THEME.main_gradient
if config.ACTIVE_THEME
else _default_green_gradient())
# ... rest of function unchanged
```
**Fallback:** Define `_DEFAULT_GRAD` (current green) for cases where theme isn't selected yet.
**Message gradient lookup:**
**Define fallback:**
```python
def msg_gradient_rows(rows, offset):
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
return lr_gradient(rows, offset, config.ACTIVE_THEME.message_gradient)
cols = (config.ACTIVE_THEME.message_gradient
if config.ACTIVE_THEME
else _default_magenta_gradient())
return lr_gradient(rows, offset, cols)
```
---
@@ -124,10 +183,25 @@ def msg_gradient_rows(rows, offset):
**New function: `pick_color_theme()`**
Mirrors `pick_font_face()` pattern:
1. Display menu: `[1] Verdant Green [2] Molten Orange [3] Violet Purple`
2. Read arrow keys (↑/↓) or j/k to navigate
3. Return selected theme ID
4. Call `config.set_active_theme(theme_id)`
```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
@@ -135,10 +209,10 @@ def main():
# ... signal handler setup ...
pick_color_theme() # NEW — before title/subtitle
pick_font_face()
# ... rest of boot sequence ...
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
```
This ensures boot messages (title, subtitle, status lines) render in the selected theme color.
**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.
---
@@ -150,15 +224,14 @@ User starts: mainline.py
main() called
pick_color_theme()
Display menu
Read user input
→ config.set_active_theme(theme_id)
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 with active theme
pick_font_face() — renders in hardcoded green UI colors
Boot messages (title, status, etc.) — all use active theme
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
stream() — news + messages use active theme gradients
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
On exit: no persistence
```
@@ -167,38 +240,60 @@ On exit: no persistence
## Implementation Notes
### Color Selection
ANSI 256-color palette is used throughout. Each gradient has 12 steps from white to deep primary/complementary color, matching the current green gradient structure exactly.
### 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
Orange gradient: `231 → 195 → 214 → 208 → 202 → 166 → 130 → 94 → 58 → 94 → 94 (dim) → 235`
Purple gradient: `231 → 225 → 183 → 177 → 171 → 165 → 129 → 93 → 57 → 57 → 57 (dim) → 235`
(Exact codes TBD via manual testing for visual balance)
### 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.
### Future Extensions
The Theme class can be extended with:
- `animation_delay`: for strobing/pulsing themes
- `metadata`: tags like `["cyberpunk", "retro", "ethereal"]`
- `random_pick()`: returns a random theme (enables future `--color=random` flag)
### Color Code Finalization
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
The picker can offer a "Random" option without any code changes to render or config.
### Theme ID Naming
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
### Testing
- Unit test `themes.py` for Theme construction and registry lookup
- Integration test that boot sequence respects active theme
- No changes needed to existing gradient tests (they now pull from config.ACTIVE_THEME)
### 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` (update gradient access to use config.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 messages render in selected theme
4.No persistence between runs
5.Architecture supports future random/animation modes
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

View File

@@ -10,7 +10,7 @@ import termios
import time
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.mic import MicMonitor
from engine.ntfy import NtfyPoller
@@ -65,6 +65,30 @@ def _read_picker_key():
return None
def _draw_color_picker(themes_list, selected):
"""Draw the color theme picker menu.
Args:
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
selected: Index of currently selected theme (0-2)
"""
print(CLR, end="")
print()
print(
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
)
print(f" {W_GHOST}{'' * (tw() - 4)}{RST}\n")
for i, (theme_id, theme) in enumerate(themes_list):
prefix = "" if i == selected else " "
color = G_HI if i == selected else ""
reset = "" if i == selected else W_GHOST
print(f"{prefix}{color}{theme.name}{reset}")
print()
def _normalize_preview_rows(rows):
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
non_empty = [r for r in rows if r.strip()]
@@ -131,6 +155,50 @@ def _draw_font_picker(faces, selected):
print(f" {shown}")
def pick_color_theme():
"""Interactive color theme picker. Defaults to 'green' if not TTY.
Displays a menu of available themes and lets user select with arrow keys.
Non-interactive environments (piped stdin, CI) silently default to green.
"""
# Non-interactive fallback
if not sys.stdin.isatty():
config.set_active_theme("green")
return
# Interactive picker
themes_list = list(themes.THEME_REGISTRY.items())
selected = 0
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
_draw_color_picker(themes_list, selected)
key = _read_picker_key()
if key == "up":
selected = max(0, selected - 1)
elif key == "down":
selected = min(len(themes_list) - 1, selected + 1)
elif key == "enter":
break
elif key == "interrupt":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
selected_theme_id = themes_list[selected][0]
config.set_active_theme(selected_theme_id)
theme_name = themes_list[selected][1].name
print(f" {G_DIM}> using {theme_name}{RST}")
time.sleep(0.8)
print(CLR, end="")
print(CURSOR_OFF, end="")
print()
def pick_font_face():
"""Interactive startup picker for selecting a face from repo OTF files."""
if not config.FONT_PICKER:
@@ -262,6 +330,7 @@ def main():
w = tw()
print(CLR, end="")
print(CURSOR_OFF, end="")
pick_color_theme()
pick_font_face()
w = tw()
print()
@@ -298,6 +367,7 @@ def main():
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()

View File

@@ -58,8 +58,10 @@ HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = (
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
else "code" if "--code" in sys.argv
"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
@@ -104,3 +106,26 @@ def set_font_selection(font_path=None, font_index=None):
FONT_PATH = _resolve_font_path(font_path)
if font_index is not None:
FONT_INDEX = max(0, int(font_index))
# ─── THEME MANAGEMENT ─────────────────────────────────────────
ACTIVE_THEME = None
def set_active_theme(theme_id: str = "green"):
"""Set the active theme by ID.
Args:
theme_id: Theme identifier ("green", "orange", or "purple")
Defaults to "green"
Raises:
KeyError: If theme_id is not in the theme registry
Side Effects:
Sets the ACTIVE_THEME global variable
"""
global ACTIVE_THEME
from engine import themes
ACTIVE_THEME = themes.get_theme(theme_id)

View File

@@ -15,9 +15,40 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
from engine.terminal import RST
from engine.translate import detect_location_language, translate_headline
# ─── GRADIENT ─────────────────────────────────────────────
# Left → right: white-hot leading edge fades to near-black
GRAD_COLS = [
def _color_codes_to_ansi(color_codes):
"""Convert a list of 256-color codes to ANSI escape code strings.
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
Args:
color_codes: List of 12 integers (256-color palette codes)
Returns:
List of ANSI escape code strings
"""
if not color_codes or len(color_codes) != 12:
# Fallback to default green if invalid
return _default_green_gradient()
result = []
for i, code in enumerate(color_codes):
if i < 2:
# Bold for first 2 (bright leading edge)
result.append(f"\033[1;38;5;{code}m")
elif i < 10:
# Normal for middle 8
result.append(f"\033[38;5;{code}m")
else:
# Dim for last 2 (dark trailing edge)
result.append(f"\033[2;38;5;{code}m")
return result
def _default_green_gradient():
"""Return the default 12-color green gradient for fallback when no theme is active."""
return [
"\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan
@@ -30,10 +61,12 @@ GRAD_COLS = [
"\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)
MSG_GRAD_COLS = [
def _default_magenta_gradient():
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
return [
"\033[1;38;5;231m", # white
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink
@@ -46,7 +79,8 @@ MSG_GRAD_COLS = [
"\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black
]
]
# ─── FONT LOADING ─────────────────────────────────────────
_FONT_OBJ = None
@@ -189,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
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."""
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)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = []
@@ -213,7 +253,30 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
def lr_gradient_opposite(rows, offset=0.0):
"""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 ─────────────────────────────

View File

@@ -18,7 +18,7 @@ from engine.effects import (
noise,
vis_trunc,
)
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
from engine.render import big_wrap, lr_gradient, make_block, msg_gradient
from engine.terminal import CLR, RST, W_COOL, th, tw
@@ -86,7 +86,7 @@ def stream(items, ntfy_poller, mic_monitor):
_msg_cache = (cache_key, msg_rows)
else:
msg_rows = _msg_cache[1]
msg_rows = lr_gradient_opposite(
msg_rows = msg_gradient(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
)
# Layout: rendered text + meta + border

60
engine/themes.py Normal file
View 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]

View File

@@ -7,6 +7,8 @@ import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from engine import config
@@ -160,3 +162,55 @@ class TestSetFontSelection:
config.set_font_selection(font_path=None, font_index=None)
assert original_path == config.FONT_PATH
assert original_index == config.FONT_INDEX
class TestActiveTheme:
"""Tests for ACTIVE_THEME global and set_active_theme function."""
def test_active_theme_initially_none(self):
"""ACTIVE_THEME should be None at module start."""
# Reset to None to test initial state
original = config.ACTIVE_THEME
config.ACTIVE_THEME = None
try:
assert config.ACTIVE_THEME is None
finally:
config.ACTIVE_THEME = original
def test_set_active_theme_green(self):
"""Setting green theme works correctly."""
config.set_active_theme("green")
assert config.ACTIVE_THEME is not None
assert config.ACTIVE_THEME.name == "green"
assert len(config.ACTIVE_THEME.main_gradient) == 12
assert len(config.ACTIVE_THEME.message_gradient) == 12
def test_set_active_theme_default(self):
"""Default theme is green when not specified."""
config.set_active_theme()
assert config.ACTIVE_THEME is not None
assert config.ACTIVE_THEME.name == "green"
def test_set_active_theme_invalid(self):
"""Invalid theme_id raises KeyError."""
with pytest.raises(KeyError):
config.set_active_theme("nonexistent")
def test_set_active_theme_all_themes(self):
"""Verify orange and purple themes work."""
# Test orange
config.set_active_theme("orange")
assert config.ACTIVE_THEME.name == "orange"
# Test purple
config.set_active_theme("purple")
assert config.ACTIVE_THEME.name == "purple"
def test_set_active_theme_idempotent(self):
"""Calling set_active_theme multiple times works."""
config.set_active_theme("green")
first_theme = config.ACTIVE_THEME
config.set_active_theme("green")
second_theme = config.ACTIVE_THEME
assert first_theme.name == second_theme.name
assert first_theme.name == "green"

301
tests/test_render.py Normal file
View 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
View 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}"