Compare commits
25 Commits
feat/code-
...
451785df04
| Author | SHA1 | Date | |
|---|---|---|---|
| 451785df04 | |||
| 473d12c756 | |||
| 5c038b1e2f | |||
| dde5d77f43 | |||
| ad45347a75 | |||
| 064eaaee3d | |||
| 0819f8d160 | |||
| edd1416407 | |||
| ac9b47f668 | |||
| b149825bcb | |||
| 1b29e91f9d | |||
| 001158214c | |||
| 31f5d9f171 | |||
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee | |||
| 8b696c96ce |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
|
||||
17
README.md
17
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.
|
||||
|
||||
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
894
docs/superpowers/plans/2026-03-16-color-scheme-implementation.md
Normal file
@@ -0,0 +1,894 @@
|
||||
# Color Scheme Switcher Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
||||
|
||||
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
||||
|
||||
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Purpose | Change Type |
|
||||
|------|---------|------------|
|
||||
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
||||
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
||||
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
||||
| `engine/scroll.py` | Update message gradient call | Modify |
|
||||
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
||||
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Theme Data Module
|
||||
|
||||
### Task 1: Create themes.py with Theme class and registry
|
||||
|
||||
**Files:**
|
||||
- Create: `engine/themes.py`
|
||||
- Test: `tests/test_themes.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test for Theme class**
|
||||
|
||||
Create `tests/test_themes.py`:
|
||||
|
||||
```python
|
||||
"""Test color themes and registry."""
|
||||
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
||||
|
||||
|
||||
def test_theme_construction():
|
||||
"""Theme stores name and gradient lists."""
|
||||
main = ["\033[1;38;5;231m"] * 12
|
||||
msg = ["\033[1;38;5;225m"] * 12
|
||||
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
||||
|
||||
assert theme.name == "Test Green"
|
||||
assert theme.main_gradient == main
|
||||
assert theme.message_gradient == msg
|
||||
|
||||
|
||||
def test_gradient_length():
|
||||
"""Each gradient must have exactly 12 ANSI codes."""
|
||||
for theme_id, theme in THEME_REGISTRY.items():
|
||||
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
||||
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
||||
|
||||
|
||||
def test_theme_registry_has_three_themes():
|
||||
"""Registry contains green, orange, purple."""
|
||||
assert len(THEME_REGISTRY) == 3
|
||||
assert "green" in THEME_REGISTRY
|
||||
assert "orange" in THEME_REGISTRY
|
||||
assert "purple" in THEME_REGISTRY
|
||||
|
||||
|
||||
def test_get_theme_valid():
|
||||
"""get_theme returns Theme object for valid ID."""
|
||||
theme = get_theme("green")
|
||||
assert isinstance(theme, Theme)
|
||||
assert theme.name == "Verdant Green"
|
||||
|
||||
|
||||
def test_get_theme_invalid():
|
||||
"""get_theme raises KeyError for invalid ID."""
|
||||
with pytest.raises(KeyError):
|
||||
get_theme("invalid_theme")
|
||||
|
||||
|
||||
def test_green_theme_unchanged():
|
||||
"""Green theme uses original green → magenta colors."""
|
||||
green_theme = get_theme("green")
|
||||
# First color should be white (bold)
|
||||
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
||||
# Last deep green
|
||||
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
||||
# Message gradient is magenta
|
||||
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_themes.py -v`
|
||||
Expected: FAIL (module doesn't exist)
|
||||
|
||||
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
||||
|
||||
Create `engine/themes.py`:
|
||||
|
||||
```python
|
||||
"""Color theme definitions and registry."""
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Theme:
|
||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||
|
||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||
"""Initialize theme with display name and gradient lists.
|
||||
|
||||
Args:
|
||||
name: Display name (e.g., "Verdant Green")
|
||||
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
||||
message_gradient: List of 12 ANSI codes (white → complementary color)
|
||||
"""
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient
|
||||
self.message_gradient = message_gradient
|
||||
|
||||
|
||||
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
||||
# Each gradient: white → primary/complementary, 12 steps total
|
||||
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
||||
|
||||
_GREEN_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_GREEN_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_ORANGE_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;215m", # pale orange-white
|
||||
"\033[38;5;209m", # bright orange
|
||||
"\033[38;5;208m", # vibrant orange
|
||||
"\033[38;5;202m", # orange
|
||||
"\033[38;5;166m", # dark orange
|
||||
"\033[38;5;130m", # burnt orange
|
||||
"\033[38;5;94m", # rust
|
||||
"\033[38;5;58m", # dark rust
|
||||
"\033[38;5;94m", # rust (hold)
|
||||
"\033[2;38;5;94m", # dim rust
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_ORANGE_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;33m", # bright blue
|
||||
"\033[38;5;27m", # blue
|
||||
"\033[38;5;21m", # deep blue
|
||||
"\033[38;5;21m", # deep blue (hold)
|
||||
"\033[38;5;21m", # deep blue (hold)
|
||||
"\033[38;5;18m", # navy
|
||||
"\033[38;5;18m", # navy (hold)
|
||||
"\033[38;5;18m", # navy (hold)
|
||||
"\033[2;38;5;18m", # dim navy
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_PURPLE_MAIN = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale purple-white
|
||||
"\033[38;5;177m", # bright purple
|
||||
"\033[38;5;171m", # vibrant purple
|
||||
"\033[38;5;165m", # purple
|
||||
"\033[38;5;135m", # medium purple
|
||||
"\033[38;5;129m", # purple
|
||||
"\033[38;5;93m", # dark purple
|
||||
"\033[38;5;57m", # deep purple
|
||||
"\033[38;5;57m", # deep purple (hold)
|
||||
"\033[2;38;5;57m", # dim deep purple
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
_PURPLE_MESSAGE = [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;226m", # pale yellow-white
|
||||
"\033[38;5;226m", # bright yellow
|
||||
"\033[38;5;220m", # yellow
|
||||
"\033[38;5;220m", # yellow (hold)
|
||||
"\033[38;5;184m", # dark yellow
|
||||
"\033[38;5;184m", # dark yellow (hold)
|
||||
"\033[38;5;178m", # olive-yellow
|
||||
"\033[38;5;178m", # olive-yellow (hold)
|
||||
"\033[38;5;172m", # golden
|
||||
"\033[2;38;5;172m", # dim golden
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||
|
||||
THEME_REGISTRY = {
|
||||
"green": Theme(
|
||||
name="Verdant Green",
|
||||
main_gradient=_GREEN_MAIN,
|
||||
message_gradient=_GREEN_MESSAGE,
|
||||
),
|
||||
"orange": Theme(
|
||||
name="Molten Orange",
|
||||
main_gradient=_ORANGE_MAIN,
|
||||
message_gradient=_ORANGE_MESSAGE,
|
||||
),
|
||||
"purple": Theme(
|
||||
name="Violet Purple",
|
||||
main_gradient=_PURPLE_MAIN,
|
||||
message_gradient=_PURPLE_MESSAGE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_theme(theme_id: str) -> Theme:
|
||||
"""Retrieve a theme by ID.
|
||||
|
||||
Args:
|
||||
theme_id: One of "green", "orange", "purple"
|
||||
|
||||
Returns:
|
||||
Theme object
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id not found in registry
|
||||
"""
|
||||
if theme_id not in THEME_REGISTRY:
|
||||
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
||||
return THEME_REGISTRY[theme_id]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_themes.py -v`
|
||||
Expected: PASS (all 6 tests)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/themes.py tests/test_themes.py
|
||||
git commit -m "feat: create Theme class and registry with finalized color gradients
|
||||
|
||||
- Define Theme class to encapsulate name and main/message gradients
|
||||
- Create THEME_REGISTRY with green, orange, purple themes
|
||||
- Each gradient has 12 ANSI 256-color codes finalized
|
||||
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
||||
- Add get_theme() lookup with error handling
|
||||
- Add comprehensive unit tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Config Integration
|
||||
|
||||
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/config.py:1-30`
|
||||
- Test: `tests/test_config.py` (expand existing)
|
||||
|
||||
- [ ] **Step 1: Write failing tests for config changes**
|
||||
|
||||
Add to `tests/test_config.py`:
|
||||
|
||||
```python
|
||||
def test_active_theme_initially_none():
|
||||
"""ACTIVE_THEME is None before initialization."""
|
||||
# This test may fail if config is already initialized
|
||||
# We'll set it to None first for testing
|
||||
import engine.config
|
||||
engine.config.ACTIVE_THEME = None
|
||||
assert engine.config.ACTIVE_THEME is None
|
||||
|
||||
|
||||
def test_set_active_theme_green():
|
||||
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
||||
from engine.config import set_active_theme
|
||||
from engine.themes import get_theme
|
||||
|
||||
set_active_theme("green")
|
||||
|
||||
assert config.ACTIVE_THEME is not None
|
||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||
assert config.ACTIVE_THEME == get_theme("green")
|
||||
|
||||
|
||||
def test_set_active_theme_default():
|
||||
"""set_active_theme() with no args defaults to green."""
|
||||
from engine.config import set_active_theme
|
||||
|
||||
set_active_theme()
|
||||
|
||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
||||
|
||||
|
||||
def test_set_active_theme_invalid():
|
||||
"""set_active_theme() with invalid ID raises KeyError."""
|
||||
from engine.config import set_active_theme
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
set_active_theme("invalid")
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
Expected: FAIL (functions don't exist yet)
|
||||
|
||||
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
||||
|
||||
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
||||
|
||||
```python
|
||||
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
||||
ACTIVE_THEME = None # set by set_active_theme() after picker
|
||||
|
||||
|
||||
def set_active_theme(theme_id: str = "green"):
|
||||
"""Set the active color theme. Defaults to 'green' if not specified.
|
||||
|
||||
Args:
|
||||
theme_id: One of "green", "orange", "purple"
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id is invalid
|
||||
"""
|
||||
global ACTIVE_THEME
|
||||
from engine import themes
|
||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
||||
|
||||
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
||||
|
||||
```python
|
||||
# DELETED:
|
||||
# GRAD_COLS = [...]
|
||||
# MSG_GRAD_COLS = [...]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
||||
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
||||
|
||||
Expected: PASS (all 4 new tests)
|
||||
|
||||
- [ ] **Step 5: Verify existing config tests still pass**
|
||||
|
||||
Run: `pytest tests/test_config.py -v`
|
||||
|
||||
Expected: PASS (all existing + new tests)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/config.py tests/test_config.py
|
||||
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
||||
|
||||
- Add ACTIVE_THEME global (initialized to None)
|
||||
- Add set_active_theme(theme_id) function with green default
|
||||
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
||||
- Add comprehensive tests for theme setting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Render Pipeline Integration
|
||||
|
||||
### Task 3: Update render.py to use config.ACTIVE_THEME
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/render.py:15-220`
|
||||
- Test: `tests/test_render.py` (expand existing)
|
||||
|
||||
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
||||
|
||||
Add to `tests/test_render.py`:
|
||||
|
||||
```python
|
||||
def test_lr_gradient_uses_active_theme(monkeypatch):
|
||||
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
||||
from engine import config, render
|
||||
from engine.themes import get_theme
|
||||
|
||||
# Set orange theme
|
||||
config.set_active_theme("orange")
|
||||
|
||||
# Create simple rows
|
||||
rows = ["test row"]
|
||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||
|
||||
# Result should start with first color from orange main gradient
|
||||
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
||||
|
||||
|
||||
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
||||
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
||||
from engine import config, render
|
||||
|
||||
# Clear active theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
rows = ["test row"]
|
||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
||||
|
||||
# Should not crash and should return something
|
||||
assert result is not None
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_default_green_gradient_length():
|
||||
"""_default_green_gradient returns 12 colors."""
|
||||
from engine import render
|
||||
|
||||
colors = render._default_green_gradient()
|
||||
assert len(colors) == 12
|
||||
```
|
||||
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||
Expected: FAIL (function signature doesn't match)
|
||||
|
||||
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
||||
|
||||
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
||||
|
||||
```python
|
||||
def lr_gradient(rows, offset, cols=None):
|
||||
"""
|
||||
Render rows through a left-to-right color sweep.
|
||||
|
||||
Args:
|
||||
rows: List of text rows to colorize
|
||||
offset: Gradient position offset (for animation)
|
||||
cols: Optional list of color codes. If None, uses active theme.
|
||||
|
||||
Returns:
|
||||
List of colorized rows
|
||||
"""
|
||||
if cols is None:
|
||||
from engine import config
|
||||
cols = (
|
||||
config.ACTIVE_THEME.main_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_green_gradient()
|
||||
)
|
||||
|
||||
# ... rest of function unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
||||
|
||||
Add to `engine/render.py` before `lr_gradient()`:
|
||||
|
||||
```python
|
||||
def _default_green_gradient():
|
||||
"""Fallback green gradient (original colors) for initialization."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
|
||||
def _default_magenta_gradient():
|
||||
"""Fallback magenta gradient (original message colors) for initialization."""
|
||||
return [
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
||||
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
||||
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
||||
|
||||
Expected: PASS (all 3 new tests)
|
||||
|
||||
- [ ] **Step 5: Run full render test suite**
|
||||
|
||||
Run: `pytest tests/test_render.py -v`
|
||||
|
||||
Expected: PASS (existing tests may need adjustment for mocking)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/render.py tests/test_render.py
|
||||
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
||||
|
||||
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
||||
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
||||
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
||||
- Add tests for theme-aware and fallback gradient rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4: Message Gradient Integration
|
||||
|
||||
### Task 4: Update scroll.py to use message gradient from config
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/scroll.py:85-95`
|
||||
- Test: existing `tests/test_scroll.py`
|
||||
|
||||
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
||||
|
||||
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
||||
|
||||
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
||||
|
||||
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
||||
|
||||
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
||||
|
||||
Old code:
|
||||
```python
|
||||
# Some variation of:
|
||||
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
```
|
||||
|
||||
New code:
|
||||
```python
|
||||
from engine import config
|
||||
msg_cols = (
|
||||
config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else render._default_magenta_gradient()
|
||||
)
|
||||
rows = lr_gradient(rows, offset, msg_cols)
|
||||
```
|
||||
|
||||
Or use the helper approach (create `msg_gradient()` in render.py):
|
||||
|
||||
```python
|
||||
def msg_gradient(rows, offset):
|
||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||
from engine import config
|
||||
cols = (
|
||||
config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient()
|
||||
)
|
||||
return lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
Then in scroll.py:
|
||||
```python
|
||||
rows = render.msg_gradient(rows, offset)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing scroll tests**
|
||||
|
||||
Run: `pytest tests/test_scroll.py -v`
|
||||
|
||||
Expected: PASS (existing functionality unchanged)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/scroll.py engine/render.py
|
||||
git commit -m "feat: update scroll.py to use theme message gradient
|
||||
|
||||
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
||||
- Use fallback magenta gradient when theme not initialized
|
||||
- Ensure ntfy messages render in complementary color from selected theme"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5: Color Picker UI
|
||||
|
||||
### Task 5: Create pick_color_theme() function in app.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `engine/app.py:1-300`
|
||||
- Test: manual/integration (interactive)
|
||||
|
||||
- [ ] **Step 1: Write helper functions for color picker UI**
|
||||
|
||||
Edit `engine/app.py`, add before `pick_font_face()` function:
|
||||
|
||||
```python
|
||||
def _draw_color_picker(themes_list, selected):
|
||||
"""Draw the color theme picker menu."""
|
||||
import sys
|
||||
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
||||
|
||||
print(CLR, end="")
|
||||
print()
|
||||
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
||||
|
||||
for i, (theme_id, theme) in enumerate(themes_list):
|
||||
prefix = " ▶ " if i == selected else " "
|
||||
color = G_HI if i == selected else ""
|
||||
reset = "" if i == selected else W_GHOST
|
||||
print(f"{prefix}{color}{theme.name}{reset}")
|
||||
|
||||
print()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create pick_color_theme() function**
|
||||
|
||||
Edit `engine/app.py`, add after helper function:
|
||||
|
||||
```python
|
||||
def pick_color_theme():
|
||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
from engine import config, themes
|
||||
|
||||
# Non-interactive fallback: use green
|
||||
if not sys.stdin.isatty():
|
||||
config.set_active_theme("green")
|
||||
return
|
||||
|
||||
themes_list = list(themes.THEME_REGISTRY.items())
|
||||
selected = 0
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_color_picker(themes_list, selected)
|
||||
key = _read_picker_key()
|
||||
if key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
elif key == "down":
|
||||
selected = min(len(themes_list) - 1, selected + 1)
|
||||
elif key == "enter":
|
||||
break
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
selected_theme_id = themes_list[selected][0]
|
||||
config.set_active_theme(selected_theme_id)
|
||||
|
||||
theme_name = themes_list[selected][1].name
|
||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
||||
|
||||
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
||||
|
||||
```python
|
||||
def main():
|
||||
# ... existing signal handler setup ...
|
||||
|
||||
pick_color_theme() # NEW LINE - before font picker
|
||||
pick_font_face()
|
||||
|
||||
# ... rest of main unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual test - run in interactive terminal**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Expected:
|
||||
- See color theme picker menu before font picker
|
||||
- Can navigate with ↑/↓ or j/k
|
||||
- Can select with Enter or q
|
||||
- Selected theme applies to scrolling headlines
|
||||
- Can select different themes and see colors change
|
||||
|
||||
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
||||
|
||||
Run: `echo "" | python3 mainline.py`
|
||||
|
||||
Expected:
|
||||
- No color picker menu shown
|
||||
- Defaults to green theme
|
||||
- App runs without error
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add engine/app.py
|
||||
git commit -m "feat: add pick_color_theme() UI and integration
|
||||
|
||||
- Create _draw_color_picker() to render menu
|
||||
- Create pick_color_theme() function mirroring font picker pattern
|
||||
- Integrate into main() before font picker
|
||||
- Fallback to green theme in non-interactive environments
|
||||
- Support arrow keys and j/k navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6: Integration & Validation
|
||||
|
||||
### Task 6: End-to-end testing and cleanup
|
||||
|
||||
**Files:**
|
||||
- Test: All modified files
|
||||
- Verify: App functionality
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `pytest tests/ -v`
|
||||
|
||||
Expected: PASS (all tests, including new ones)
|
||||
|
||||
- [ ] **Step 2: Run linter**
|
||||
|
||||
Run: `ruff check engine/ mainline.py`
|
||||
|
||||
Expected: No errors (fix any style issues)
|
||||
|
||||
- [ ] **Step 3: Manual integration test - green theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Verdant Green" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in green → deep green
|
||||
- ntfy messages render in magenta gradient
|
||||
- Both work correctly during streaming
|
||||
|
||||
- [ ] **Step 4: Manual integration test - orange theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Molten Orange" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in orange → deep orange
|
||||
- ntfy messages render in blue gradient
|
||||
- Colors are visually distinct from green
|
||||
|
||||
- [ ] **Step 5: Manual integration test - purple theme**
|
||||
|
||||
Run: `python3 mainline.py`
|
||||
|
||||
Then select "Violet Purple" from picker.
|
||||
|
||||
Expected:
|
||||
- Headlines render in purple → deep purple
|
||||
- ntfy messages render in yellow gradient
|
||||
- Colors are visually distinct from green and orange
|
||||
|
||||
- [ ] **Step 6: Test poetry mode with color picker**
|
||||
|
||||
Run: `python3 mainline.py --poetry`
|
||||
|
||||
Then select "orange" from picker.
|
||||
|
||||
Expected:
|
||||
- Poetry mode works with color picker
|
||||
- Colors apply to poetry rendering
|
||||
|
||||
- [ ] **Step 7: Test code mode with color picker**
|
||||
|
||||
Run: `python3 mainline.py --code`
|
||||
|
||||
Then select "purple" from picker.
|
||||
|
||||
Expected:
|
||||
- Code mode works with color picker
|
||||
- Colors apply to code rendering
|
||||
|
||||
- [ ] **Step 8: Verify acceptance criteria**
|
||||
|
||||
✓ Color picker displays 3 theme options at startup
|
||||
✓ Selection applies to all headline and message gradients
|
||||
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||
✓ Scrolling headlines and ntfy messages use theme gradients
|
||||
✓ No persistence between runs (each run picks fresh)
|
||||
✓ Non-TTY environments default to green without error
|
||||
✓ Architecture supports future random/animation modes
|
||||
✓ All gradient color codes finalized with no TBD values
|
||||
|
||||
- [ ] **Step 9: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: color scheme switcher implementation complete
|
||||
|
||||
Closes color-pick feature with:
|
||||
- Three selectable color themes (green, orange, purple)
|
||||
- Interactive menu at startup (mirrors font picker UI)
|
||||
- Complementary colors for ntfy message queue
|
||||
- Fallback to green in non-interactive environments
|
||||
- All tests passing, manual validation complete"
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Create feature branch PR summary**
|
||||
|
||||
```
|
||||
## Color Scheme Switcher
|
||||
|
||||
Implements interactive color theme selection for Mainline news ticker.
|
||||
|
||||
### What's New
|
||||
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
||||
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
||||
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
||||
- Fresh theme selection each run (no persistence)
|
||||
|
||||
### Files Changed
|
||||
- `engine/themes.py` (new)
|
||||
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
||||
- `engine/render.py` (theme-aware gradients)
|
||||
- `engine/scroll.py` (message gradient integration)
|
||||
- `engine/app.py` (pick_color_theme UI)
|
||||
- `tests/test_themes.py` (new theme tests)
|
||||
- `README.md` (documentation)
|
||||
|
||||
### Acceptance Criteria
|
||||
All met. App fully tested and ready for merge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
||||
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
||||
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
||||
- [ ] Full suite: `pytest tests/ -v`
|
||||
- [ ] Linting: `ruff check engine/ mainline.py`
|
||||
- [ ] Manual: Green theme selection
|
||||
- [ ] Manual: Orange theme selection
|
||||
- [ ] Manual: Purple theme selection
|
||||
- [ ] Manual: Poetry mode with colors
|
||||
- [ ] Manual: Code mode with colors
|
||||
- [ ] Manual: Non-TTY fallback
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `themes.py` is data-only; never import config or render to prevent cycles
|
||||
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
||||
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
||||
- Message gradients use complementary colors; lookup in scroll.py
|
||||
- Each gradient has 12 colors; verify length in tests
|
||||
- No persistence; fresh picker each run
|
||||
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
1110
docs/superpowers/plans/2026-03-19-figment-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
299
docs/superpowers/specs/2026-03-16-color-scheme-design.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Color Scheme Switcher Design
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Status:** Revised after review
|
||||
**Scope:** Interactive color theme selection for Mainline news ticker
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
||||
|
||||
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**Functional:**
|
||||
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
||||
2. Main headline gradient uses the selected primary color (white → color)
|
||||
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
||||
4. Selection is fresh each run (no persistence)
|
||||
5. Design supports future "random rotation" mode without refactoring
|
||||
|
||||
**Complementary colors (precise opposites):**
|
||||
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
||||
- Orange (38;5;208) → Blue (38;5;21)
|
||||
- Purple (38;5;129) → Yellow (38;5;226)
|
||||
|
||||
**Non-functional:**
|
||||
- Reuse the existing font picker pattern for UI consistency
|
||||
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
||||
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
||||
- Font picker UI remains hardcoded green for visual continuity
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Module: `engine/themes.py`
|
||||
|
||||
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
||||
|
||||
```python
|
||||
class Theme:
|
||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
||||
|
||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient # white → primary color
|
||||
self.message_gradient = message_gradient # white → complementary
|
||||
```
|
||||
|
||||
**Theme Registry:**
|
||||
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
||||
|
||||
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
||||
```
|
||||
[
|
||||
"\033[1;38;5;231m", # white (bold)
|
||||
"\033[1;38;5;195m", # pale white-tint
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright color
|
||||
"\033[38;5;40m", # color
|
||||
"\033[38;5;34m", # medium color
|
||||
"\033[38;5;28m", # dark color
|
||||
"\033[38;5;22m", # deep color
|
||||
"\033[2;38;5;22m", # dim deep color
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
```
|
||||
|
||||
**Finalized color codes:**
|
||||
|
||||
**Green (primary: 22, complementary: 89)** — unchanged from current
|
||||
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
||||
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
||||
|
||||
**Orange (primary: 208, complementary: 21)**
|
||||
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
||||
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
||||
|
||||
**Purple (primary: 129, complementary: 226)**
|
||||
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
||||
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
||||
|
||||
**Public API:**
|
||||
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
||||
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/config.py`
|
||||
|
||||
**New globals:**
|
||||
```python
|
||||
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
||||
```
|
||||
|
||||
**New function:**
|
||||
```python
|
||||
def set_active_theme(theme_id: str = "green"):
|
||||
"""Set the active theme. Defaults to 'green' if not specified."""
|
||||
global ACTIVE_THEME
|
||||
from engine import themes
|
||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Called by `app.pick_color_theme()` with user selection
|
||||
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
||||
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
||||
|
||||
**Removal:**
|
||||
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/render.py`
|
||||
|
||||
**Updated gradient access in existing functions:**
|
||||
|
||||
Current pattern (will be removed):
|
||||
```python
|
||||
GRAD_COLS = [...] # hardcoded green
|
||||
MSG_GRAD_COLS = [...] # hardcoded magenta
|
||||
```
|
||||
|
||||
New pattern — update `lr_gradient()` function:
|
||||
```python
|
||||
def lr_gradient(rows, offset, cols=None):
|
||||
if cols is None:
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.main_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_green_gradient())
|
||||
# ... rest of function unchanged
|
||||
```
|
||||
|
||||
**Define fallback:**
|
||||
```python
|
||||
def _default_green_gradient():
|
||||
"""Fallback green gradient (current colors)."""
|
||||
return [
|
||||
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
||||
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
||||
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
||||
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
||||
]
|
||||
```
|
||||
|
||||
**Message gradient handling:**
|
||||
|
||||
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
||||
```python
|
||||
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
# Use:
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient())
|
||||
lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
or define a helper:
|
||||
```python
|
||||
def msg_gradient(rows, offset):
|
||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
||||
from engine import config
|
||||
cols = (config.ACTIVE_THEME.message_gradient
|
||||
if config.ACTIVE_THEME
|
||||
else _default_magenta_gradient())
|
||||
return lr_gradient(rows, offset, cols)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Modified: `engine/app.py`
|
||||
|
||||
**New function: `pick_color_theme()`**
|
||||
|
||||
Mirrors `pick_font_face()` pattern:
|
||||
|
||||
```python
|
||||
def pick_color_theme():
|
||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
||||
import sys
|
||||
from engine import config, themes
|
||||
|
||||
# Non-interactive fallback: use default
|
||||
if not sys.stdin.isatty():
|
||||
config.set_active_theme("green")
|
||||
return
|
||||
|
||||
# Interactive picker (similar to font picker)
|
||||
themes_list = list(themes.THEME_REGISTRY.items())
|
||||
selected = 0
|
||||
|
||||
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
||||
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
||||
```
|
||||
|
||||
**Placement in `main()`:**
|
||||
```python
|
||||
def main():
|
||||
# ... signal handler setup ...
|
||||
pick_color_theme() # NEW — before title/subtitle
|
||||
pick_font_face()
|
||||
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
||||
```
|
||||
|
||||
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User starts: mainline.py
|
||||
↓
|
||||
main() called
|
||||
↓
|
||||
pick_color_theme()
|
||||
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
||||
→ If not TTY: silently call config.set_active_theme("green")
|
||||
↓
|
||||
pick_font_face() — renders in hardcoded green UI colors
|
||||
↓
|
||||
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
||||
↓
|
||||
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
||||
↓
|
||||
On exit: no persistence
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Initialization Guarantee
|
||||
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
||||
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
||||
2. It's called before any rendering happens
|
||||
3. Default fallback ensures non-TTY environments don't crash
|
||||
|
||||
### Module Independence
|
||||
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
||||
|
||||
### Color Code Finalization
|
||||
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
||||
|
||||
### Theme ID Naming
|
||||
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
||||
|
||||
### Terminal Resize Handling
|
||||
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
||||
1. The picker completes quickly (< 5 seconds typical interaction)
|
||||
2. Once a theme is selected, the menu closes and rendering begins
|
||||
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
||||
|
||||
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
||||
|
||||
### Testing Strategy
|
||||
1. **Unit tests** (`tests/test_themes.py`):
|
||||
- Verify Theme class construction
|
||||
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
||||
- Confirm gradient lists have correct length (12)
|
||||
|
||||
2. **Integration tests** (`tests/test_render.py`):
|
||||
- Mock `config.ACTIVE_THEME` to each theme
|
||||
- Verify `lr_gradient()` uses correct colors
|
||||
- Verify fallback works when `ACTIVE_THEME` is None
|
||||
|
||||
3. **Existing tests:**
|
||||
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
||||
- Use pytest fixtures to set theme per test case
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
- `engine/themes.py` (new)
|
||||
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
||||
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
||||
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
||||
- `tests/test_themes.py` (new unit tests)
|
||||
- `tests/test_render.py` (update mocking strategy)
|
||||
|
||||
## Acceptance Criteria
|
||||
1. ✓ Color picker displays 3 theme options at startup
|
||||
2. ✓ Selection applies to all headline and message gradients
|
||||
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
||||
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
||||
5. ✓ No persistence between runs
|
||||
6. ✓ Non-TTY environments default to green without error
|
||||
7. ✓ Architecture supports future random/animation modes
|
||||
8. ✓ All gradient color codes finalized with no TBD values
|
||||
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
308
docs/superpowers/specs/2026-03-19-figment-mode-design.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# Figment Mode Design Spec
|
||||
|
||||
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
||||
|
||||
## Overview
|
||||
|
||||
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
||||
|
||||
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
||||
|
||||
## Goals
|
||||
|
||||
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
||||
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
||||
- Random color from existing theme gradients (green, orange, purple)
|
||||
- Configurable interval and duration via C&C
|
||||
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Multi-figment simultaneous display (one at a time)
|
||||
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
||||
- Custom color palettes beyond existing themes
|
||||
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
||||
|
||||
## Architecture: Hybrid Plugin + Overlay
|
||||
|
||||
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
||||
|
||||
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
+-------------------+
|
||||
| FigmentTrigger | (Protocol)
|
||||
| - NtfyTrigger | (v1)
|
||||
| - MqttTrigger | (future)
|
||||
| - SerialTrigger | (future)
|
||||
+--------+----------+
|
||||
|
|
||||
| FigmentCommand
|
||||
v
|
||||
+------------------+ +-----------------+ +----------------------+
|
||||
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
||||
| .py | | (EffectPlugin) | | overlay() in |
|
||||
| | | | | layers.py |
|
||||
| SVG -> PIL -> | | Timer, state | | |
|
||||
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
||||
| | | selection | | commands for overlay |
|
||||
+------------------+ +-----------------+ +----------------------+
|
||||
|
|
||||
| get_figment_state()
|
||||
v
|
||||
+-------------------+
|
||||
| scroll.py |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
## Section 1: SVG Rasterization
|
||||
|
||||
**File: `engine/figment_render.py`**
|
||||
|
||||
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
||||
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
||||
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
||||
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
||||
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
||||
|
||||
### Dependency
|
||||
|
||||
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
||||
|
||||
### Key Function
|
||||
|
||||
```python
|
||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
||||
```
|
||||
|
||||
## Section 2: Figment Overlay Rendering
|
||||
|
||||
**Integration point: `engine/layers.py`**
|
||||
|
||||
New function following the `render_message_overlay()` pattern.
|
||||
|
||||
### FigmentState Dataclass
|
||||
|
||||
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class FigmentState:
|
||||
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
||||
progress: float # 0.0 to 1.0 within current phase
|
||||
rows: list[str] # rasterized half-block rows (uncolored)
|
||||
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
||||
center_row: int # top row for centering in viewport
|
||||
center_col: int # left column for centering in viewport
|
||||
```
|
||||
|
||||
### Function Signature
|
||||
|
||||
```python
|
||||
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
||||
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
||||
```
|
||||
|
||||
### Animation Phases (~4.5 seconds total)
|
||||
|
||||
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
||||
|
||||
| Phase | Duration | Behavior |
|
||||
|-------|----------|----------|
|
||||
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
||||
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
||||
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
||||
|
||||
### Color
|
||||
|
||||
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
||||
|
||||
### Positioning
|
||||
|
||||
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
||||
|
||||
## Section 3: FigmentEffect (Effect Plugin)
|
||||
|
||||
**File: `effects_plugins/figment.py`**
|
||||
|
||||
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
||||
|
||||
### Chain Exclusion
|
||||
|
||||
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
||||
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
||||
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
||||
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
||||
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
idle ──(timer fires or trigger received)──> reveal
|
||||
reveal ──(progress >= 1.0)──> hold
|
||||
hold ──(progress >= 1.0)──> dissolve
|
||||
dissolve ──(progress >= 1.0)──> idle
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
||||
|
||||
```python
|
||||
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
||||
"""Tick the state machine and return current state, or None if idle."""
|
||||
```
|
||||
|
||||
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
||||
|
||||
### Scroll Loop Access
|
||||
|
||||
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
||||
|
||||
```python
|
||||
from effects_plugins.figment import FigmentEffect
|
||||
|
||||
plugin = registry.get("figment")
|
||||
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
||||
```
|
||||
|
||||
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
||||
|
||||
### EffectConfig
|
||||
|
||||
- `enabled`: bool (default `False` — opt-in)
|
||||
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
||||
- `params`:
|
||||
- `interval_secs`: 60 (time between figments)
|
||||
- `display_secs`: 4.5 (total animation duration)
|
||||
- `figment_dir`: "figments" (SVG source directory)
|
||||
|
||||
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
||||
|
||||
## Section 4: Input Abstraction (FigmentTrigger)
|
||||
|
||||
**File: `engine/figment_trigger.py`**
|
||||
|
||||
### Protocol
|
||||
|
||||
```python
|
||||
class FigmentTrigger(Protocol):
|
||||
def poll(self) -> FigmentCommand | None: ...
|
||||
```
|
||||
|
||||
### FigmentCommand
|
||||
|
||||
```python
|
||||
class FigmentAction(Enum):
|
||||
TRIGGER = "trigger"
|
||||
SET_INTENSITY = "set_intensity"
|
||||
SET_INTERVAL = "set_interval"
|
||||
SET_COLOR = "set_color"
|
||||
STOP = "stop"
|
||||
|
||||
@dataclass
|
||||
class FigmentCommand:
|
||||
action: FigmentAction
|
||||
value: float | str | None = None
|
||||
```
|
||||
|
||||
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
||||
|
||||
### Adapters
|
||||
|
||||
| Adapter | Transport | Dependency | Status |
|
||||
|---------|-----------|------------|--------|
|
||||
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
||||
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
||||
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
||||
|
||||
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
||||
|
||||
### Integration
|
||||
|
||||
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
||||
|
||||
### EventBus Bridge
|
||||
|
||||
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
||||
|
||||
## Section 5: Scroll Loop Integration
|
||||
|
||||
Minimal change to `engine/scroll.py`:
|
||||
|
||||
```python
|
||||
# In stream() setup (with safe import):
|
||||
try:
|
||||
from effects_plugins.figment import FigmentEffect
|
||||
_plugin = registry.get("figment")
|
||||
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
||||
except ImportError:
|
||||
figment = None
|
||||
|
||||
# In frame loop, after effects processing, before ntfy message overlay:
|
||||
if figment and figment.config.enabled:
|
||||
figment_state = figment.get_figment_state(frame_number)
|
||||
if figment_state is not None:
|
||||
figment_overlay = render_figment_overlay(figment_state, w, h)
|
||||
buf.extend(figment_overlay)
|
||||
```
|
||||
|
||||
### Overlay Priority
|
||||
|
||||
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
||||
- Ntfy messages always appear on top of figments (higher priority)
|
||||
- Existing glitch/noise effects run over the ticker underneath the figment
|
||||
|
||||
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
||||
|
||||
## Section 6: Error Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
||||
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
||||
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
||||
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
||||
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
||||
|
||||
## Section 7: File Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
||||
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
||||
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
||||
| `figments/` | SVG source directory (ships with sample SVGs) |
|
||||
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
||||
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
||||
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
||||
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
||||
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
||||
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
||||
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
||||
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
||||
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
||||
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
||||
@@ -5,6 +5,7 @@ PLUGIN_DIR = Path(__file__).parent
|
||||
|
||||
def discover_plugins():
|
||||
from engine.effects.registry import get_registry
|
||||
from engine.effects.types import EffectPlugin
|
||||
|
||||
registry = get_registry()
|
||||
imported = {}
|
||||
@@ -22,8 +23,8 @@ def discover_plugins():
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and hasattr(attr, "name")
|
||||
and hasattr(attr, "process")
|
||||
and issubclass(attr, EffectPlugin)
|
||||
and attr is not EffectPlugin
|
||||
and attr_name.endswith("Effect")
|
||||
):
|
||||
plugin = attr()
|
||||
|
||||
@@ -3,7 +3,7 @@ import random
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class FadeEffect:
|
||||
class FadeEffect(EffectPlugin):
|
||||
name = "fade"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class FirehoseEffect:
|
||||
class FirehoseEffect(EffectPlugin):
|
||||
name = "firehose"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||
|
||||
|
||||
class GlitchEffect:
|
||||
class GlitchEffect(EffectPlugin):
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class NoiseEffect:
|
||||
class NoiseEffect(EffectPlugin):
|
||||
name = "noise"
|
||||
config = EffectConfig(enabled=True, intensity=0.15)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -8,10 +9,11 @@ class EffectContext:
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
mic_excess: float
|
||||
grad_offset: float
|
||||
frame_number: int
|
||||
has_message: bool
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
items: list = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -22,15 +24,42 @@ class EffectConfig:
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EffectPlugin:
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
raise NotImplementedError
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
raise NotImplementedError
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None: ...
|
||||
|
||||
|
||||
def create_effect_context(
|
||||
terminal_width: int = 80,
|
||||
terminal_height: int = 24,
|
||||
scroll_cam: int = 0,
|
||||
ticker_height: int = 0,
|
||||
camera_x: int = 0,
|
||||
mic_excess: float = 0.0,
|
||||
grad_offset: float = 0.0,
|
||||
frame_number: int = 0,
|
||||
has_message: bool = False,
|
||||
items: list | None = None,
|
||||
) -> EffectContext:
|
||||
"""Factory function to create EffectContext with sensible defaults."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_height,
|
||||
camera_x=camera_x,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -18,6 +18,7 @@ class EventType(Enum):
|
||||
NTFY_MESSAGE = auto()
|
||||
STREAM_START = auto()
|
||||
STREAM_END = auto()
|
||||
FIGMENT_TRIGGER = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -65,3 +66,12 @@ class StreamEvent:
|
||||
event_type: EventType
|
||||
headline_count: int = 0
|
||||
timestamp: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FigmentTriggerEvent:
|
||||
"""Event emitted when a figment is triggered."""
|
||||
|
||||
action: str
|
||||
value: float | str | None = None
|
||||
timestamp: datetime | None = None
|
||||
|
||||
@@ -18,7 +18,7 @@ from engine.effects import (
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
||||
from engine.terminal import RST, W_COOL
|
||||
|
||||
MSG_META = "\033[38;5;245m"
|
||||
@@ -57,7 +57,7 @@ def render_message_overlay(
|
||||
else:
|
||||
msg_rows = msg_cache[1]
|
||||
|
||||
msg_rows = lr_gradient_opposite(
|
||||
msg_rows = msg_gradient(
|
||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
)
|
||||
|
||||
|
||||
131
engine/render.py
131
engine/render.py
@@ -15,38 +15,72 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||
from engine.terminal import RST
|
||||
from engine.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 ─────────────────────────────
|
||||
|
||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Theme definitions with color gradients for terminal rendering.
|
||||
|
||||
This module is data-only and does not import config or render
|
||||
to prevent circular dependencies.
|
||||
"""
|
||||
|
||||
|
||||
class Theme:
|
||||
"""Represents a color theme with two gradients."""
|
||||
|
||||
def __init__(self, name, main_gradient, message_gradient):
|
||||
"""Initialize a theme with name and color gradients.
|
||||
|
||||
Args:
|
||||
name: Theme identifier string
|
||||
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||
"""
|
||||
self.name = name
|
||||
self.main_gradient = main_gradient
|
||||
self.message_gradient = message_gradient
|
||||
|
||||
|
||||
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||
|
||||
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||
|
||||
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||
|
||||
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||
|
||||
|
||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||
|
||||
THEME_REGISTRY = {
|
||||
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||
}
|
||||
|
||||
|
||||
def get_theme(theme_id):
|
||||
"""Retrieve a theme by ID.
|
||||
|
||||
Args:
|
||||
theme_id: Theme identifier string
|
||||
|
||||
Returns:
|
||||
Theme object matching the ID
|
||||
|
||||
Raises:
|
||||
KeyError: If theme_id is not in registry
|
||||
"""
|
||||
return THEME_REGISTRY[theme_id]
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 577.362 577.362"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_21_">
|
||||
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
|
||||
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
|
||||
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
|
||||
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
|
||||
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
|
||||
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
|
||||
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
|
||||
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
|
||||
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
|
||||
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
|
||||
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
|
||||
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
|
||||
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
|
||||
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
|
||||
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
|
||||
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
|
||||
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
|
||||
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
|
||||
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
|
||||
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
|
||||
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 559.731 559.731"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_36_">
|
||||
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
|
||||
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
|
||||
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
|
||||
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
|
||||
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
|
||||
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
|
||||
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
|
||||
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
|
||||
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
|
||||
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
|
||||
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
|
||||
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
|
||||
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
|
||||
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
|
||||
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
|
||||
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
|
||||
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
|
||||
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
|
||||
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
|
||||
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
|
||||
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
|
||||
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
|
||||
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
|
||||
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
|
||||
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
|
||||
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
|
||||
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
|
||||
/>
|
||||
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
|
||||
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
|
||||
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
|
||||
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
|
||||
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
|
||||
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
|
||||
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
|
||||
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
|
||||
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
|
||||
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
|
||||
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
|
||||
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
|
||||
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
|
||||
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
|
||||
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
|
||||
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
|
||||
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
|
||||
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
|
||||
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
|
||||
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
|
||||
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="800px" height="800px" viewBox="0 0 589.748 589.748"
|
||||
xml:space="preserve">
|
||||
<g>
|
||||
<g id="Layer_2_2_">
|
||||
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
|
||||
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
|
||||
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
|
||||
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
|
||||
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
|
||||
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
|
||||
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
|
||||
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
|
||||
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
|
||||
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
|
||||
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
|
||||
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
|
||||
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
|
||||
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
|
||||
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
|
||||
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
|
||||
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
|
||||
/>
|
||||
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
|
||||
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
|
||||
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
|
||||
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
|
||||
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
|
||||
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
|
||||
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
|
||||
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
|
||||
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
|
||||
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
|
||||
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
|
||||
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
|
||||
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
|
||||
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
|
||||
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
|
||||
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
|
||||
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
|
||||
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
|
||||
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
|
||||
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
|
||||
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
|
||||
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
|
||||
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
|
||||
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
|
||||
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
|
||||
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
|
||||
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
|
||||
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
|
||||
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
|
||||
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
|
||||
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
|
||||
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
|
||||
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
|
||||
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
|
||||
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
|
||||
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
|
||||
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
|
||||
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
|
||||
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
|
||||
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
|
||||
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
|
||||
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
|
||||
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
|
||||
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
|
||||
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
|
||||
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
|
||||
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
|
||||
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
|
||||
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
|
||||
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
|
||||
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
|
||||
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
|
||||
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
|
||||
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
|
||||
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
|
||||
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
|
||||
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
|
||||
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
|
||||
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
|
||||
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
|
||||
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
|
||||
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
|
||||
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
|
||||
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
|
||||
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
|
||||
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
|
||||
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
|
||||
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
|
||||
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
|
||||
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
|
||||
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
|
||||
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
|
||||
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
|
||||
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
|
||||
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
|
||||
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
|
||||
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
|
||||
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
|
||||
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
|
||||
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
|
||||
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -1,3 +1,7 @@
|
||||
[env]
|
||||
_.path = ["/opt/homebrew/lib"]
|
||||
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
|
||||
|
||||
[tools]
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
|
||||
@@ -30,6 +30,9 @@ mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
figment = [
|
||||
"cairosvg>=2.7.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
|
||||
3
tests/fixtures/test.svg
vendored
Normal file
3
tests/fixtures/test.svg
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 155 B |
301
tests/test_render.py
Normal file
301
tests/test_render.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Tests for engine.render module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import config, render
|
||||
|
||||
|
||||
class TestDefaultGradients:
|
||||
"""Tests for default gradient fallback functions."""
|
||||
|
||||
def test_default_green_gradient_length(self):
|
||||
"""_default_green_gradient returns 12 colors."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert len(gradient) == 12
|
||||
|
||||
def test_default_green_gradient_is_list(self):
|
||||
"""_default_green_gradient returns a list."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert isinstance(gradient, list)
|
||||
|
||||
def test_default_green_gradient_all_strings(self):
|
||||
"""_default_green_gradient returns list of ANSI code strings."""
|
||||
gradient = render._default_green_gradient()
|
||||
assert all(isinstance(code, str) for code in gradient)
|
||||
|
||||
def test_default_magenta_gradient_length(self):
|
||||
"""_default_magenta_gradient returns 12 colors."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert len(gradient) == 12
|
||||
|
||||
def test_default_magenta_gradient_is_list(self):
|
||||
"""_default_magenta_gradient returns a list."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert isinstance(gradient, list)
|
||||
|
||||
def test_default_magenta_gradient_all_strings(self):
|
||||
"""_default_magenta_gradient returns list of ANSI code strings."""
|
||||
gradient = render._default_magenta_gradient()
|
||||
assert all(isinstance(code, str) for code in gradient)
|
||||
|
||||
|
||||
class TestLrGradientUsesActiveTheme:
|
||||
"""Tests for lr_gradient using active theme."""
|
||||
|
||||
def test_lr_gradient_uses_active_theme_when_cols_none(self):
|
||||
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("green")
|
||||
|
||||
# Create simple test data
|
||||
rows = ["text"]
|
||||
|
||||
# Call without cols parameter (cols=None)
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Should not raise and should return colored output
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes (no plain "text")
|
||||
assert result[0] != "text"
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_fallback_when_no_theme(self):
|
||||
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Clear the theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
# Create simple test data
|
||||
rows = ["text"]
|
||||
|
||||
# Call without cols parameter (should use fallback)
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Should not raise and should return colored output
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes (no plain "text")
|
||||
assert result[0] != "text"
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_explicit_cols_parameter_still_works(self):
|
||||
"""lr_gradient with explicit cols parameter overrides theme."""
|
||||
# Custom gradient
|
||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||
|
||||
rows = ["xy"]
|
||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||
|
||||
# Should use the provided cols
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_lr_gradient_respects_cols_parameter_name(self):
|
||||
"""lr_gradient accepts cols as keyword argument."""
|
||||
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
|
||||
|
||||
rows = ["xy"]
|
||||
# Call with cols as keyword
|
||||
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
|
||||
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
class TestLrGradientBasicFunctionality:
|
||||
"""Tests to ensure lr_gradient basic functionality still works."""
|
||||
|
||||
def test_lr_gradient_colors_non_space_chars(self):
|
||||
"""lr_gradient colors non-space characters."""
|
||||
rows = ["hello"]
|
||||
|
||||
# Set a theme for the test
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Result should have ANSI codes
|
||||
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_preserves_spaces(self):
|
||||
"""lr_gradient preserves spaces in output."""
|
||||
rows = ["a b c"]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
# Spaces should be preserved (not colored)
|
||||
assert " " in result[0]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_empty_rows(self):
|
||||
"""lr_gradient handles empty rows correctly."""
|
||||
rows = [""]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
assert result == [""]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_lr_gradient_multiple_rows(self):
|
||||
"""lr_gradient handles multiple rows."""
|
||||
rows = ["row1", "row2", "row3"]
|
||||
|
||||
original_theme = config.ACTIVE_THEME
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
result = render.lr_gradient(rows, offset=0.0)
|
||||
|
||||
assert len(result) == 3
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
|
||||
class TestMsgGradient:
|
||||
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
|
||||
|
||||
def test_msg_gradient_uses_active_theme(self):
|
||||
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("green")
|
||||
|
||||
# Create simple test data
|
||||
rows = ["MESSAGE"]
|
||||
|
||||
# Call msg_gradient
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Should return colored output using theme's message_gradient
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes from the message gradient
|
||||
assert result[0] != "MESSAGE"
|
||||
assert "\033[" in result[0]
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_fallback_when_no_theme(self):
|
||||
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Clear the theme
|
||||
config.ACTIVE_THEME = None
|
||||
|
||||
# Create simple test data
|
||||
rows = ["MESSAGE"]
|
||||
|
||||
# Call msg_gradient
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Should return colored output using default magenta
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
# Should have ANSI codes
|
||||
assert result[0] != "MESSAGE"
|
||||
assert "\033[" in result[0]
|
||||
finally:
|
||||
# Restore original state
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_returns_colored_rows(self):
|
||||
"""msg_gradient returns properly colored rows with animation offset."""
|
||||
# Save original state
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
# Set a theme
|
||||
config.set_active_theme("orange")
|
||||
|
||||
rows = ["NTFY", "ALERT"]
|
||||
|
||||
# Call with offset
|
||||
result = render.msg_gradient(rows, offset=0.5)
|
||||
|
||||
# Should return same number of rows
|
||||
assert len(result) == 2
|
||||
# Both should be colored
|
||||
assert all("\033[" in r for r in result)
|
||||
# Should not be the original text
|
||||
assert result != rows
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_different_themes_produce_different_results(self):
|
||||
"""msg_gradient produces different colors for different themes."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
rows = ["TEST"]
|
||||
|
||||
# Get result with green theme
|
||||
config.set_active_theme("green")
|
||||
result_green = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Get result with orange theme
|
||||
config.set_active_theme("orange")
|
||||
result_orange = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Results should be different (different message gradients)
|
||||
assert result_green != result_orange
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_preserves_spacing(self):
|
||||
"""msg_gradient preserves spaces in rows."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
config.set_active_theme("purple")
|
||||
rows = ["M E S S A G E"]
|
||||
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Spaces should be preserved
|
||||
assert " " in result[0]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
|
||||
def test_msg_gradient_empty_rows(self):
|
||||
"""msg_gradient handles empty rows correctly."""
|
||||
original_theme = config.ACTIVE_THEME
|
||||
|
||||
try:
|
||||
config.set_active_theme("green")
|
||||
rows = [""]
|
||||
|
||||
result = render.msg_gradient(rows, offset=0.0)
|
||||
|
||||
# Empty row should stay empty
|
||||
assert result == [""]
|
||||
finally:
|
||||
config.ACTIVE_THEME = original_theme
|
||||
169
tests/test_themes.py
Normal file
169
tests/test_themes.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Tests for engine.themes module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import themes
|
||||
|
||||
|
||||
class TestThemeConstruction:
|
||||
"""Tests for Theme class initialization."""
|
||||
|
||||
def test_theme_construction(self):
|
||||
"""Theme stores name and gradients correctly."""
|
||||
main_grad = ["color1", "color2", "color3"]
|
||||
msg_grad = ["msg1", "msg2", "msg3"]
|
||||
theme = themes.Theme("test_theme", main_grad, msg_grad)
|
||||
|
||||
assert theme.name == "test_theme"
|
||||
assert theme.main_gradient == main_grad
|
||||
assert theme.message_gradient == msg_grad
|
||||
|
||||
|
||||
class TestGradientLength:
|
||||
"""Tests for gradient length validation."""
|
||||
|
||||
def test_gradient_length_green(self):
|
||||
"""Green theme has exactly 12 colors in each gradient."""
|
||||
green = themes.THEME_REGISTRY["green"]
|
||||
assert len(green.main_gradient) == 12
|
||||
assert len(green.message_gradient) == 12
|
||||
|
||||
def test_gradient_length_orange(self):
|
||||
"""Orange theme has exactly 12 colors in each gradient."""
|
||||
orange = themes.THEME_REGISTRY["orange"]
|
||||
assert len(orange.main_gradient) == 12
|
||||
assert len(orange.message_gradient) == 12
|
||||
|
||||
def test_gradient_length_purple(self):
|
||||
"""Purple theme has exactly 12 colors in each gradient."""
|
||||
purple = themes.THEME_REGISTRY["purple"]
|
||||
assert len(purple.main_gradient) == 12
|
||||
assert len(purple.message_gradient) == 12
|
||||
|
||||
|
||||
class TestThemeRegistry:
|
||||
"""Tests for THEME_REGISTRY dictionary."""
|
||||
|
||||
def test_theme_registry_has_three_themes(self):
|
||||
"""Registry contains exactly three themes: green, orange, purple."""
|
||||
assert len(themes.THEME_REGISTRY) == 3
|
||||
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
|
||||
|
||||
def test_registry_values_are_themes(self):
|
||||
"""All registry values are Theme instances."""
|
||||
for theme_id, theme in themes.THEME_REGISTRY.items():
|
||||
assert isinstance(theme, themes.Theme)
|
||||
assert theme.name == theme_id
|
||||
|
||||
|
||||
class TestGetTheme:
|
||||
"""Tests for get_theme function."""
|
||||
|
||||
def test_get_theme_valid_green(self):
|
||||
"""get_theme('green') returns correct green Theme."""
|
||||
green = themes.get_theme("green")
|
||||
assert isinstance(green, themes.Theme)
|
||||
assert green.name == "green"
|
||||
|
||||
def test_get_theme_valid_orange(self):
|
||||
"""get_theme('orange') returns correct orange Theme."""
|
||||
orange = themes.get_theme("orange")
|
||||
assert isinstance(orange, themes.Theme)
|
||||
assert orange.name == "orange"
|
||||
|
||||
def test_get_theme_valid_purple(self):
|
||||
"""get_theme('purple') returns correct purple Theme."""
|
||||
purple = themes.get_theme("purple")
|
||||
assert isinstance(purple, themes.Theme)
|
||||
assert purple.name == "purple"
|
||||
|
||||
def test_get_theme_invalid(self):
|
||||
"""get_theme with invalid ID raises KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
themes.get_theme("invalid_theme")
|
||||
|
||||
def test_get_theme_invalid_none(self):
|
||||
"""get_theme with None raises KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
themes.get_theme(None)
|
||||
|
||||
|
||||
class TestGreenTheme:
|
||||
"""Tests for green theme specific values."""
|
||||
|
||||
def test_green_theme_unchanged(self):
|
||||
"""Green theme maintains original color sequence."""
|
||||
green = themes.get_theme("green")
|
||||
|
||||
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
|
||||
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
|
||||
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||
|
||||
assert green.main_gradient == expected_main
|
||||
assert green.message_gradient == expected_msg
|
||||
|
||||
def test_green_theme_name(self):
|
||||
"""Green theme has correct name."""
|
||||
green = themes.get_theme("green")
|
||||
assert green.name == "green"
|
||||
|
||||
|
||||
class TestOrangeTheme:
|
||||
"""Tests for orange theme specific values."""
|
||||
|
||||
def test_orange_theme_unchanged(self):
|
||||
"""Orange theme maintains original color sequence."""
|
||||
orange = themes.get_theme("orange")
|
||||
|
||||
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
|
||||
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
|
||||
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||
|
||||
assert orange.main_gradient == expected_main
|
||||
assert orange.message_gradient == expected_msg
|
||||
|
||||
def test_orange_theme_name(self):
|
||||
"""Orange theme has correct name."""
|
||||
orange = themes.get_theme("orange")
|
||||
assert orange.name == "orange"
|
||||
|
||||
|
||||
class TestPurpleTheme:
|
||||
"""Tests for purple theme specific values."""
|
||||
|
||||
def test_purple_theme_unchanged(self):
|
||||
"""Purple theme maintains original color sequence."""
|
||||
purple = themes.get_theme("purple")
|
||||
|
||||
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
|
||||
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
|
||||
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||
|
||||
assert purple.main_gradient == expected_main
|
||||
assert purple.message_gradient == expected_msg
|
||||
|
||||
def test_purple_theme_name(self):
|
||||
"""Purple theme has correct name."""
|
||||
purple = themes.get_theme("purple")
|
||||
assert purple.name == "purple"
|
||||
|
||||
|
||||
class TestThemeDataOnly:
|
||||
"""Tests to ensure themes module has no problematic imports."""
|
||||
|
||||
def test_themes_module_imports(self):
|
||||
"""themes module should be data-only without config/render imports."""
|
||||
import inspect
|
||||
source = inspect.getsource(themes)
|
||||
# Verify no imports of config or render (look for actual import statements)
|
||||
lines = source.split('\n')
|
||||
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
|
||||
# Filter out empty and comment lines
|
||||
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
|
||||
# Should have no import lines
|
||||
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"
|
||||
Reference in New Issue
Block a user