diff --git a/README.md b/README.md index 5df0eec..f284838 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md b/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md new file mode 100644 index 0000000..c08017f --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-color-scheme-implementation.md @@ -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[;m" where color is 38;5; + +_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 diff --git a/docs/superpowers/specs/2026-03-16-color-scheme-design.md b/docs/superpowers/specs/2026-03-16-color-scheme-design.md new file mode 100644 index 0000000..56120b5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-color-scheme-design.md @@ -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 diff --git a/engine/app.py b/engine/app.py index 6c22f25..10eea6d 100644 --- a/engine/app.py +++ b/engine/app.py @@ -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() @@ -274,7 +343,7 @@ def main(): print() _subtitle = { "poetry": "literary consciousness stream", - "code": "source consciousness stream", + "code": "source consciousness stream", }.get(config.MODE, "digital consciousness stream") print(f" {W_DIM}v0.1 · {_subtitle}{RST}") print(f" {W_GHOST}{'─' * (w - 4)}{RST}") @@ -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() diff --git a/engine/config.py b/engine/config.py index 1a39a04..c5ce46c 100644 --- a/engine/config.py +++ b/engine/config.py @@ -189,8 +189,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 @@ -235,3 +237,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) diff --git a/engine/render.py b/engine/render.py index 4b24eef..c0ecb7d 100644 --- a/engine/render.py +++ b/engine/render.py @@ -15,38 +15,72 @@ 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 = [ - "\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) -MSG_GRAD_COLS = [ - "\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 -] +# ─── GRADIENT ───────────────────────────────────────────── +def _color_codes_to_ansi(color_codes): + """Convert a list of 256-color codes to ANSI escape code strings. + + Pattern: first 2 are bold, middle 8 are normal, last 2 are dim. + + Args: + color_codes: List of 12 integers (256-color palette codes) + + Returns: + List of ANSI escape code strings + """ + if not color_codes or len(color_codes) != 12: + # Fallback to default green if invalid + return _default_green_gradient() + + result = [] + for i, code in enumerate(color_codes): + if i < 2: + # Bold for first 2 (bright leading edge) + result.append(f"\033[1;38;5;{code}m") + elif i < 10: + # Normal for middle 8 + result.append(f"\033[38;5;{code}m") + else: + # Dim for last 2 (dark trailing edge) + result.append(f"\033[2;38;5;{code}m") + return result + + +def _default_green_gradient(): + """Return the default 12-color green gradient for fallback when no theme is active.""" + return [ + "\033[1;38;5;231m", # white + "\033[1;38;5;195m", # pale cyan-white + "\033[38;5;123m", # bright cyan + "\033[38;5;118m", # bright lime + "\033[38;5;82m", # lime + "\033[38;5;46m", # bright green + "\033[38;5;40m", # green + "\033[38;5;34m", # medium green + "\033[38;5;28m", # dark green + "\033[38;5;22m", # deep green + "\033[2;38;5;22m", # dim deep green + "\033[2;38;5;235m", # near black + ] + + +def _default_magenta_gradient(): + """Return the default 12-color magenta gradient for fallback when no theme is active.""" + return [ + "\033[1;38;5;231m", # white + "\033[1;38;5;225m", # pale pink-white + "\033[38;5;219m", # bright pink + "\033[38;5;213m", # hot pink + "\033[38;5;207m", # magenta + "\033[38;5;201m", # bright magenta + "\033[38;5;165m", # orchid-red + "\033[38;5;161m", # ruby-magenta + "\033[38;5;125m", # dark magenta + "\033[38;5;89m", # deep maroon-magenta + "\033[2;38;5;89m", # dim deep maroon-magenta + "\033[2;38;5;235m", # near black + ] + # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None @@ -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 ───────────────────────────── diff --git a/engine/themes.py b/engine/themes.py new file mode 100644 index 0000000..a6d3432 --- /dev/null +++ b/engine/themes.py @@ -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] diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..20eb63e --- /dev/null +++ b/tests/test_render.py @@ -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 diff --git a/tests/test_themes.py b/tests/test_themes.py new file mode 100644 index 0000000..f6bbdf3 --- /dev/null +++ b/tests/test_themes.py @@ -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}"