32 Commits

Author SHA1 Message Date
ac9b47f668 Merge pull request 'fix: use theme-aware msg_gradient for ntfy messages' (#31) from feat/color-pick into main
Reviewed-on: #31
2026-03-16 10:29:46 +00:00
b149825bcb Merge branch 'main' into feat/color-pick 2026-03-16 10:29:28 +00:00
1b29e91f9d fix: use theme-aware msg_gradient for ntfy messages
Replace lr_gradient_opposite() with msg_gradient() in render_message_overlay().
Messages now render in complementary colors matching the active theme:
- Green theme → magenta messages
- Orange theme → blue messages
- Purple theme → yellow messages
2026-03-16 03:25:52 -07:00
001158214c Merge pull request 'feat/color-pick' (#30) from feat/color-pick into main
Reviewed-on: #30
2026-03-16 10:13:47 +00:00
31f5d9f171 Merge origin/main into feat/color-pick
Resolved conflicts in tests/test_config.py by keeping TestActiveTheme tests.
Main branch has new architecture components (controller, events, layers, effects).
Color scheme feature preserved and compatible.
2026-03-16 03:13:13 -07:00
bc20a35ea9 refactor: Fix import ordering and code formatting with ruff
Organize imports in render.py and scroll.py to meet ruff style requirements.
Add blank lines for code formatting compliance.
2026-03-16 03:01:22 -07:00
d4d0344a12 feat: add pick_color_theme() UI and integration 2026-03-16 02:58:18 -07:00
84cb16d463 feat: update scroll.py to use theme message gradient
Add msg_gradient() helper function to render.py that applies message
(ntfy) gradient using the active theme's message_gradient property.
This replaces hardcoded magenta gradient in scroll.py with a theme-aware
approach that uses complementary colors from the active theme.

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

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

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

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

Each step includes exact code, test commands, and expected output.
2026-03-16 02:47:25 -07:00
6daea90b0a docs: add color scheme feature documentation to README
- Update opening description to mention selectable color gradients
- Add new 'Color Schemes' section with picker usage instructions
- Document three available themes (Green, Orange, Purple)
- Clarify that boot UI uses hardcoded green, not theme colors
2026-03-16 02:44:59 -07:00
9d9172ef0d docs: add terminal resize handling clarification 2026-03-16 02:42:59 -07:00
667bef2685 docs: revise color scheme design spec to address review feedback
- Clarify boot messages use hardcoded green, not theme gradients
- Finalize all gradient ANSI color codes (no TBD)
- Add initialization guarantee for ACTIVE_THEME
- Resolve circular import risk with data-only themes.py
- Update theme ID naming to match menu labels
- Expand test strategy and mock approach
2026-03-16 02:42:19 -07:00
f085042dee docs: add color scheme switcher design spec 2026-03-16 02:40:32 -07:00
8b696c96ce Merge pull request 'feat/code-scroll' (#29) from feat/code-scroll into main
Reviewed-on: #29
2026-03-16 09:16:53 +00:00
72d21459ca Merge branch 'main' into feat/code-scroll 2026-03-16 09:16:36 +00:00
58dbbbdba7 Merge pull request 'plugin-based effects architecture, daemon mode with command-and-control (C&C), and display abstraction' (#25) from david/Mainline:effects_plugins into main
Reviewed-on: #25
2026-03-16 09:13:22 +00:00
7ff78c66ed Merge pull request 'refactor to improve testability and modularization of the mainline terminal application' (#24) from david/Mainline:testability_modularization into main
Reviewed-on: #24
2026-03-16 09:11:13 +00:00
2229ccdea4 feat: introduce a 'code' mode to display source code lines, add new font assets, and include dedicated tests for code fetching. 2026-03-16 02:09:56 -07:00
f13e89f823 docs: add code-scroll mode design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 01:48:18 -07:00
4228400c43 feat(daemon): add display abstraction and daemon mode with C&C 2026-03-15 19:17:23 -07:00
05cc475858 feat(cmdline): add command-line interface for mainline control 2026-03-15 19:17:10 -07:00
cfd7e8931e feat(effects): add plugin architecture with performance monitoring 2026-03-15 19:17:10 -07:00
15de46722a refactor: phase 4 - event-driven architecture foundation
- Add EventBus class with pub/sub messaging (thread-safe)
- Add emitter Protocol classes (EventEmitter, Startable, Stoppable)
- Add event emission to NtfyPoller (NtfyMessageEvent)
- Add event emission to MicMonitor (MicLevelEvent)
- Update StreamController to publish stream start/end events
- Add comprehensive tests for eventbus and emitters modules
2026-03-15 19:15:08 -07:00
35e5c8d38b refactor: phase 3 - API efficiency improvements
Add typed dataclasses for tuple returns:
- types.py: HeadlineItem, FetchResult, Block dataclasses with legacy tuple converters
- fetch.py: Add type hints and HeadlineTuple type alias

Add pyright for static type checking:
- Add pyright to dependencies
- Verify type coverage with pyright (0 errors in core modules)

This enables:
- Named types instead of raw tuples (better IDE support, self-documenting)
- Type-safe APIs across modules
- Backward compatibility via to_tuple/from_tuple methods

Note: Lazy imports skipped for render.py - startup impact is minimal.
2026-03-15 19:13:32 -07:00
cdc8094de2 refactor: phase 2 - modularization of scroll engine
Split monolithic scroll.py into focused modules:
- viewport.py: terminal size (tw/th), ANSI positioning helpers
- frame.py: FrameTimer class, scroll step calculation
- layers.py: message overlay, ticker zone, firehose rendering
- scroll.py: simplified orchestrator, imports from new modules

Add stream controller and event types for future event-driven architecture:
- controller.py: StreamController for source initialization and stream lifecycle
- events.py: EventType enum and event dataclasses (HeadlineEvent, FrameTickEvent, etc.)

Added tests for new modules:
- test_viewport.py: 8 tests for viewport utilities
- test_frame.py: 10 tests for frame timing
- test_layers.py: 13 tests for layer compositing
- test_events.py: 11 tests for event types
- test_controller.py: 6 tests for stream controller

This enables:
- Testable chunks with clear responsibilities
- Reusable viewport utilities across modules
- Better separation of concerns in render pipeline
- Foundation for future event-driven architecture

Also includes Phase 1 documentation updates in code comments.
2026-03-15 19:13:32 -07:00
f170143939 refactor: phase 1 - testability improvements
- Add Config dataclass with get_config()/set_config() for injection
- Add Config.from_args() for CLI argument parsing (testable)
- Add platform font path detection (Darwin/Linux)
- Bound translate cache with @lru_cache(maxsize=500)
- Add fixtures for external dependencies (network, feeds, config)
- Add 15 tests for Config class, from_args, and platform detection

This enables testability by:
- Allowing config injection instead of global mutable state
- Supporting custom argv in from_args() for testing
- Providing reusable fixtures for mocking network/filesystem
- Preventing unbounded memory growth in translation cache

Fixes: _arg_value/_arg_int not accepting custom argv
2026-03-15 19:13:32 -07:00
19fb4bc4fe Merge pull request 'docs/update-readme' (#23) from docs/update-readme into main
Reviewed-on: #23
2026-03-16 00:09:10 +00:00
ae10fd78ca refactor: Restructure README, add uv and mise commands, and detail component extension and development workflows. 2026-03-15 17:08:32 -07:00
4afab642f7 docs: add README update design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:56:58 -07:00
21 changed files with 2499 additions and 621 deletions

1
.gitignore vendored
View File

@@ -9,4 +9,3 @@ htmlcov/
.coverage .coverage
.pytest_cache/ .pytest_cache/
*.egg-info/ *.egg-info/
coverage.xml

159
AGENTS.md
View File

@@ -22,37 +22,13 @@ uv sync
### Available Commands ### Available Commands
```bash ```bash
# Development
mise run test # Run tests mise run test # Run tests
mise run test-v # Run tests verbose mise run test-v # Run tests verbose
mise run test-cov # Run tests with coverage report mise run test-cov # Run tests with coverage report
mise run lint # Run ruff linter mise run lint # Run ruff linter
mise run lint-fix # Run ruff with auto-fix mise run lint-fix # Run ruff with auto-fix
mise run format # Run ruff formatter mise run format # Run ruff formatter
mise run ci # Full CI pipeline mise run ci # Full CI pipeline (sync + test + coverage)
# Runtime
mise run run # Interactive terminal mode (news)
mise run run-poetry # Interactive terminal mode (poetry)
mise run run-firehose # Dense headline mode
# Daemon mode (recommended for long-running)
mise run daemon # Start mainline in background
mise run daemon-stop # Stop daemon
mise run daemon-restart # Restart daemon
# Command & Control
mise run cmd # Interactive CLI
mise run cmd "/cmd" # Send single command
mise run cmd-stats # Watch performance stats
mise run topics-init # Initialize ntfy topics
# Environment
mise run install # Install dependencies
mise run sync # Sync dependencies
mise run sync-all # Sync with all extras
mise run clean # Clean cache files
mise run clobber # Aggressive cleanup (git clean -fdx + caches)
``` ```
## Git Hooks ## Git Hooks
@@ -132,136 +108,3 @@ The project uses pytest with strict marker enforcement. Test configuration is in
- **eventbus.py** provides thread-safe event publishing for decoupled communication - **eventbus.py** provides thread-safe event publishing for decoupled communication
- **controller.py** coordinates ntfy/mic monitoring - **controller.py** coordinates ntfy/mic monitoring
- The render pipeline: fetch → render → effects → scroll → terminal output - The render pipeline: fetch → render → effects → scroll → terminal output
- **display.py** provides swappable display backends (TerminalDisplay, NullDisplay)
## Operating Modes
Mainline can run in two modes:
### 1. Standalone Mode (Original)
Run directly as a terminal application with interactive pickers:
```bash
mise run run # news stream
mise run run-poetry # poetry mode
mise run run-firehose # dense headline mode
```
This runs the full interactive experience with font picker and effects picker at startup.
### 2. Daemon + Command Mode (Recommended for Long-Running)
The recommended approach for persistent displays:
```bash
# Start the daemon (headless rendering)
mise run daemon
# Send commands via ntfy
mise run cmd "/effects list"
mise run cmd "/effects noise off"
mise run cmd "/effects stats"
# Watch mode (continuous stats polling)
mise run cmd-stats
# Stop the daemon
mise run daemon-stop
```
#### How It Works
- **Daemon**: Runs `mainline.py` in the background, renders to terminal
- **C&C Topics**: Uses separate ntfy topics (like UART serial):
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
- **Topics are auto-warmed** on first daemon start
#### Available Commands
```
/effects list - List all effects and status
/effects <name> on - Enable an effect
/effects <name> off - Disable an effect
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
/effects stats - Show performance statistics
```
## Effects Plugin System
The effects system is implemented as a plugin architecture in `engine/effects/`.
### Core Components
| Module | Purpose |
|--------|---------|
| `effects/types.py` | `EffectConfig`, `EffectContext` dataclasses and `EffectPlugin` protocol |
| `effects/registry.py` | Plugin discovery and management (`EffectRegistry`) |
| `effects/chain.py` | Ordered pipeline execution (`EffectChain`) |
| `effects_plugins/*.py` | Externalized effect plugins |
### Creating a New Effect
Create a file in `effects_plugins/` with a class ending in `Effect`:
```python
from engine.effects.types import EffectConfig, EffectContext
class MyEffect:
name = "myeffect"
config = EffectConfig(enabled=True, intensity=1.0)
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
# Process buffer and return modified buffer
return buf
def configure(self, config: EffectConfig) -> None:
self.config = config
```
### NTFY Commands
Send commands via `cmdline.py` or directly to the C&C topic:
```bash
# Using cmdline tool (recommended)
mise run cmd "/effects list"
mise run cmd "/effects noise on"
mise run cmd "/effects noise intensity 0.5"
mise run cmd "/effects reorder noise,glitch,fade,firehose"
# Or directly via curl
curl -d "/effects list" https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd
```
The cmdline tool polls the response topic for the daemon's reply.
## Conventional Commits
Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that don't affect code meaning (formatting)
- `refactor`: Code change that neither fixes a bug nor adds a feature
- `test`: Adding or updating tests
- `chore`: Changes to build process, dependencies, etc.
### Examples
```
feat(effects): add plugin architecture for visual effects
fix(layers): resolve glitch effect not applying on empty buffer
docs(AGENTS.md): add effects plugin system documentation
test(effects): add tests for EffectChain pipeline ordering
```

229
README.md
View File

@@ -2,11 +2,13 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.* > *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
--- ---
## Run ## Using
### Run
```bash ```bash
python3 mainline.py # news stream python3 mainline.py # news stream
@@ -20,51 +22,15 @@ python3 mainline.py --font-dir ~/fonts # scan a different font folder
python3 mainline.py --font-index 1 # select face index within a collection python3 mainline.py --font-index 1 # select face index within a collection
``` ```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. Or with uv:
---
## Daemon Mode (Recommended for Long-Running)
For persistent displays (e.g., always-on terminal), use daemon mode with command-and-control over ntfy:
```bash ```bash
# Start the daemon (runs in background, auto-warms ntfy topics) uv run mainline.py
mise run daemon
# Send commands via cmdline
mise run cmd "/effects list"
mise run cmd "/effects noise off"
mise run cmd "/effects noise intensity 0.5"
# Watch performance stats continuously
mise run cmd-stats
# Stop the daemon
mise run daemon-stop
``` ```
### How It Works First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
- **Topics**: Uses separate ntfy topics for serial-like communication: ### Config
- `klubhaus_terminal_mainline_cc_cmd` - commands TO mainline
- `klubhaus_terminal_mainline_cc_resp` - responses FROM mainline
- Topics are automatically created on first daemon start
### Available Commands
```
/effects list - List all effects and status
/effects <name> on - Enable an effect
/effects <name> off - Disable an effect
/effects <name> intensity 0.5 - Set effect intensity (0.0-1.0)
/effects reorder noise,fade,glitch,firehose - Reorder pipeline
/effects stats - Show performance statistics
```
---
## Config
All constants live in `engine/config.py`: All constants live in `engine/config.py`:
@@ -84,23 +50,56 @@ All constants live in `engine/config.py`:
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) | | `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) | | `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) | | `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll | | `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls | | `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen | | `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
--- ### Feeds
## Fonts ~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py``FEEDS`.
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size. **Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py``POETRY_SOURCES`.
### Fonts
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session. Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face. To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
### Color Schemes
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
On startup, an interactive picker presents all available color schemes:
```
[1] Verdant Green (white-hot → deep green)
[2] Molten Orange (white-hot → deep orange)
[3] Violet Purple (white-hot → deep purple)
```
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
### ntfy.sh
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.
To push a message:
```bash
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
```
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
--- ---
## How it works ## Internals
### How it works
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream - On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts - Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
@@ -109,11 +108,9 @@ To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or po
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts - Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame - The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically - The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
- An ntfy.sh poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired - An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
--- ### Architecture
## Architecture
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package: `mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
@@ -127,15 +124,7 @@ engine/
filter.py HTML stripping, content filter filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient) render.py OTF → half-block pipeline (SSAA, gradient)
effects/ plugin-based effects system effects.py noise, glitch_bar, fade, firehose
types.py EffectConfig, EffectContext, EffectPlugin protocol
registry.py Plugin discovery and management
chain.py Ordered pipeline execution
performance.py Performance monitoring
controller.py NTFY command handler
legacy.py Original effects (noise, glitch, fade, firehose)
effects_plugins/ External effect plugins (noise, glitch, fade, firehose)
display.py Swappable display backends (TerminalDisplay, NullDisplay)
fetch.py RSS/Gutenberg fetching + cache load/save fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback mic.py MicMonitor — standalone, graceful fallback
@@ -144,7 +133,7 @@ engine/
frame.py scroll step calculation, timing frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay rendering layers.py ticker zone, firehose, message overlay rendering
eventbus.py thread-safe event publishing for decoupled communication eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses types.py type definitions and dataclasses
@@ -154,37 +143,108 @@ engine/
--- ---
## Feeds ## Extending
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py``FEEDS`. `ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py``POETRY_SOURCES`. ### NtfyPoller
---
## ntfy.sh Integration
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.
To push a message:
```bash
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
```
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
```python ```python
from engine.ntfy import NtfyPoller from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
poller.start() poller.start()
# in render loop:
msg = poller.get_active_message() # returns (title, body, timestamp) or None # in your render loop:
msg = poller.get_active_message() # → (title, body, timestamp) or None
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
``` ```
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
### MicMonitor
```python
from engine.mic import MicMonitor
mic = MicMonitor(threshold_db=50)
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
if result:
excess = mic.excess # dB above threshold, clamped to 0
db = mic.db # raw RMS dB level
```
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
### Render pipeline
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
```python
# planned — serve.py does not yet exist
from engine.render import render_line, big_wrap
from engine.fetch import fetch_all
headlines = fetch_all()
for h in headlines:
rows = big_wrap(h.text, font, width=800) # list of half-block rows
# threshold to 1-bit, pack bytes, serve over HTTP
```
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
--- ---
## Ideas / Future ## Development
### Setup
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # minimal (no mic)
uv sync --all-extras # with mic support (sounddevice + numpy)
uv sync --all-extras --group dev # full dev environment
```
### Tasks
With [mise](https://mise.jdx.dev/):
```bash
mise run test # run test suite
mise run test-cov # run with coverage report
mise run lint # ruff check
mise run lint-fix # ruff check --fix
mise run format # ruff format
mise run run # uv run mainline.py
mise run run-poetry # uv run mainline.py --poetry
mise run run-firehose # uv run mainline.py --firehose
```
### Testing
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
```bash
uv run pytest
uv run pytest --cov=engine --cov-report=term-missing
```
### Linting
```bash
uv run ruff check engine/ mainline.py
uv run ruff format engine/ mainline.py
```
Pre-commit hooks run lint automatically via `hk`.
---
## Roadmap
### Performance ### Performance
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed - **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
@@ -211,5 +271,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
--- ---
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.* *macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
# test

View File

@@ -0,0 +1,894 @@
# Color Scheme Switcher Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
---
## File Structure
| File | Purpose | Change Type |
|------|---------|------------|
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
| `engine/scroll.py` | Update message gradient call | Modify |
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
---
## Chunk 1: Theme Data Module
### Task 1: Create themes.py with Theme class and registry
**Files:**
- Create: `engine/themes.py`
- Test: `tests/test_themes.py`
- [ ] **Step 1: Write failing test for Theme class**
Create `tests/test_themes.py`:
```python
"""Test color themes and registry."""
from engine.themes import Theme, THEME_REGISTRY, get_theme
def test_theme_construction():
"""Theme stores name and gradient lists."""
main = ["\033[1;38;5;231m"] * 12
msg = ["\033[1;38;5;225m"] * 12
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
assert theme.name == "Test Green"
assert theme.main_gradient == main
assert theme.message_gradient == msg
def test_gradient_length():
"""Each gradient must have exactly 12 ANSI codes."""
for theme_id, theme in THEME_REGISTRY.items():
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
def test_theme_registry_has_three_themes():
"""Registry contains green, orange, purple."""
assert len(THEME_REGISTRY) == 3
assert "green" in THEME_REGISTRY
assert "orange" in THEME_REGISTRY
assert "purple" in THEME_REGISTRY
def test_get_theme_valid():
"""get_theme returns Theme object for valid ID."""
theme = get_theme("green")
assert isinstance(theme, Theme)
assert theme.name == "Verdant Green"
def test_get_theme_invalid():
"""get_theme raises KeyError for invalid ID."""
with pytest.raises(KeyError):
get_theme("invalid_theme")
def test_green_theme_unchanged():
"""Green theme uses original green → magenta colors."""
green_theme = get_theme("green")
# First color should be white (bold)
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
# Last deep green
assert green_theme.main_gradient[9] == "\033[38;5;22m"
# Message gradient is magenta
assert green_theme.message_gradient[9] == "\033[38;5;89m"
```
Run: `pytest tests/test_themes.py -v`
Expected: FAIL (module doesn't exist)
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
Create `engine/themes.py`:
```python
"""Color theme definitions and registry."""
from typing import Optional
class Theme:
"""Encapsulates a color scheme: name, main gradient, message gradient."""
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
"""Initialize theme with display name and gradient lists.
Args:
name: Display name (e.g., "Verdant Green")
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
message_gradient: List of 12 ANSI codes (white → complementary color)
"""
self.name = name
self.main_gradient = main_gradient
self.message_gradient = message_gradient
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
# Each gradient: white → primary/complementary, 12 steps total
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
_GREEN_MAIN = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;195m", # pale white-tint
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
_GREEN_MESSAGE = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink
"\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta
"\033[38;5;201m", # bright magenta
"\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta
"\033[38;5;125m", # dark magenta
"\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black
]
_ORANGE_MAIN = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;215m", # pale orange-white
"\033[38;5;209m", # bright orange
"\033[38;5;208m", # vibrant orange
"\033[38;5;202m", # orange
"\033[38;5;166m", # dark orange
"\033[38;5;130m", # burnt orange
"\033[38;5;94m", # rust
"\033[38;5;58m", # dark rust
"\033[38;5;94m", # rust (hold)
"\033[2;38;5;94m", # dim rust
"\033[2;38;5;235m", # near black
]
_ORANGE_MESSAGE = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;33m", # bright blue
"\033[38;5;27m", # blue
"\033[38;5;21m", # deep blue
"\033[38;5;21m", # deep blue (hold)
"\033[38;5;21m", # deep blue (hold)
"\033[38;5;18m", # navy
"\033[38;5;18m", # navy (hold)
"\033[38;5;18m", # navy (hold)
"\033[2;38;5;18m", # dim navy
"\033[2;38;5;235m", # near black
]
_PURPLE_MAIN = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;225m", # pale purple-white
"\033[38;5;177m", # bright purple
"\033[38;5;171m", # vibrant purple
"\033[38;5;165m", # purple
"\033[38;5;135m", # medium purple
"\033[38;5;129m", # purple
"\033[38;5;93m", # dark purple
"\033[38;5;57m", # deep purple
"\033[38;5;57m", # deep purple (hold)
"\033[2;38;5;57m", # dim deep purple
"\033[2;38;5;235m", # near black
]
_PURPLE_MESSAGE = [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;226m", # pale yellow-white
"\033[38;5;226m", # bright yellow
"\033[38;5;220m", # yellow
"\033[38;5;220m", # yellow (hold)
"\033[38;5;184m", # dark yellow
"\033[38;5;184m", # dark yellow (hold)
"\033[38;5;178m", # olive-yellow
"\033[38;5;178m", # olive-yellow (hold)
"\033[38;5;172m", # golden
"\033[2;38;5;172m", # dim golden
"\033[2;38;5;235m", # near black
]
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
THEME_REGISTRY = {
"green": Theme(
name="Verdant Green",
main_gradient=_GREEN_MAIN,
message_gradient=_GREEN_MESSAGE,
),
"orange": Theme(
name="Molten Orange",
main_gradient=_ORANGE_MAIN,
message_gradient=_ORANGE_MESSAGE,
),
"purple": Theme(
name="Violet Purple",
main_gradient=_PURPLE_MAIN,
message_gradient=_PURPLE_MESSAGE,
),
}
def get_theme(theme_id: str) -> Theme:
"""Retrieve a theme by ID.
Args:
theme_id: One of "green", "orange", "purple"
Returns:
Theme object
Raises:
KeyError: If theme_id not found in registry
"""
if theme_id not in THEME_REGISTRY:
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
return THEME_REGISTRY[theme_id]
```
- [ ] **Step 3: Run tests to verify they pass**
Run: `pytest tests/test_themes.py -v`
Expected: PASS (all 6 tests)
- [ ] **Step 4: Commit**
```bash
git add engine/themes.py tests/test_themes.py
git commit -m "feat: create Theme class and registry with finalized color gradients
- Define Theme class to encapsulate name and main/message gradients
- Create THEME_REGISTRY with green, orange, purple themes
- Each gradient has 12 ANSI 256-color codes finalized
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
- Add get_theme() lookup with error handling
- Add comprehensive unit tests"
```
---
## Chunk 2: Config Integration
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
**Files:**
- Modify: `engine/config.py:1-30`
- Test: `tests/test_config.py` (expand existing)
- [ ] **Step 1: Write failing tests for config changes**
Add to `tests/test_config.py`:
```python
def test_active_theme_initially_none():
"""ACTIVE_THEME is None before initialization."""
# This test may fail if config is already initialized
# We'll set it to None first for testing
import engine.config
engine.config.ACTIVE_THEME = None
assert engine.config.ACTIVE_THEME is None
def test_set_active_theme_green():
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
from engine.config import set_active_theme
from engine.themes import get_theme
set_active_theme("green")
assert config.ACTIVE_THEME is not None
assert config.ACTIVE_THEME.name == "Verdant Green"
assert config.ACTIVE_THEME == get_theme("green")
def test_set_active_theme_default():
"""set_active_theme() with no args defaults to green."""
from engine.config import set_active_theme
set_active_theme()
assert config.ACTIVE_THEME.name == "Verdant Green"
def test_set_active_theme_invalid():
"""set_active_theme() with invalid ID raises KeyError."""
from engine.config import set_active_theme
with pytest.raises(KeyError):
set_active_theme("invalid")
```
Run: `pytest tests/test_config.py -v`
Expected: FAIL (functions don't exist yet)
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
```python
# ─── COLOR THEME ──────────────────────────────────────────────────────────
ACTIVE_THEME = None # set by set_active_theme() after picker
def set_active_theme(theme_id: str = "green"):
"""Set the active color theme. Defaults to 'green' if not specified.
Args:
theme_id: One of "green", "orange", "purple"
Raises:
KeyError: If theme_id is invalid
"""
global ACTIVE_THEME
from engine import themes
ACTIVE_THEME = themes.get_theme(theme_id)
```
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
```python
# DELETED:
# GRAD_COLS = [...]
# MSG_GRAD_COLS = [...]
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
Expected: PASS (all 4 new tests)
- [ ] **Step 5: Verify existing config tests still pass**
Run: `pytest tests/test_config.py -v`
Expected: PASS (all existing + new tests)
- [ ] **Step 6: Commit**
```bash
git add engine/config.py tests/test_config.py
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
- Add ACTIVE_THEME global (initialized to None)
- Add set_active_theme(theme_id) function with green default
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
- Add comprehensive tests for theme setting"
```
---
## Chunk 3: Render Pipeline Integration
### Task 3: Update render.py to use config.ACTIVE_THEME
**Files:**
- Modify: `engine/render.py:15-220`
- Test: `tests/test_render.py` (expand existing)
- [ ] **Step 1: Write failing test for lr_gradient with theme**
Add to `tests/test_render.py`:
```python
def test_lr_gradient_uses_active_theme(monkeypatch):
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
from engine import config, render
from engine.themes import get_theme
# Set orange theme
config.set_active_theme("orange")
# Create simple rows
rows = ["test row"]
result = render.lr_gradient(rows, offset=0, cols=None)
# Result should start with first color from orange main gradient
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
from engine import config, render
# Clear active theme
config.ACTIVE_THEME = None
rows = ["test row"]
result = render.lr_gradient(rows, offset=0, cols=None)
# Should not crash and should return something
assert result is not None
assert len(result) > 0
def test_default_green_gradient_length():
"""_default_green_gradient returns 12 colors."""
from engine import render
colors = render._default_green_gradient()
assert len(colors) == 12
```
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
Expected: FAIL (function signature doesn't match)
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
```python
def lr_gradient(rows, offset, cols=None):
"""
Render rows through a left-to-right color sweep.
Args:
rows: List of text rows to colorize
offset: Gradient position offset (for animation)
cols: Optional list of color codes. If None, uses active theme.
Returns:
List of colorized rows
"""
if cols is None:
from engine import config
cols = (
config.ACTIVE_THEME.main_gradient
if config.ACTIVE_THEME
else _default_green_gradient()
)
# ... rest of function unchanged ...
```
- [ ] **Step 3: Add _default_green_gradient() fallback function**
Add to `engine/render.py` before `lr_gradient()`:
```python
def _default_green_gradient():
"""Fallback green gradient (original colors) for initialization."""
return [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;195m", # pale white-tint
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
def _default_magenta_gradient():
"""Fallback magenta gradient (original message colors) for initialization."""
return [
"\033[1;38;5;231m", # white (bold)
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink
"\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta
"\033[38;5;201m", # bright magenta
"\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta
"\033[38;5;125m", # dark magenta
"\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black
]
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
Expected: PASS (all 3 new tests)
- [ ] **Step 5: Run full render test suite**
Run: `pytest tests/test_render.py -v`
Expected: PASS (existing tests may need adjustment for mocking)
- [ ] **Step 6: Commit**
```bash
git add engine/render.py tests/test_render.py
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
- Fallback used when ACTIVE_THEME is None (non-interactive init)
- Add tests for theme-aware and fallback gradient rendering"
```
---
## Chunk 4: Message Gradient Integration
### Task 4: Update scroll.py to use message gradient from config
**Files:**
- Modify: `engine/scroll.py:85-95`
- Test: existing `tests/test_scroll.py`
- [ ] **Step 1: Locate message gradient calls in scroll.py**
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
- [ ] **Step 2: Update scroll.py to use theme message gradient**
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
Old code:
```python
# Some variation of:
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
```
New code:
```python
from engine import config
msg_cols = (
config.ACTIVE_THEME.message_gradient
if config.ACTIVE_THEME
else render._default_magenta_gradient()
)
rows = lr_gradient(rows, offset, msg_cols)
```
Or use the helper approach (create `msg_gradient()` in render.py):
```python
def msg_gradient(rows, offset):
"""Apply message (ntfy) gradient using theme complementary colors."""
from engine import config
cols = (
config.ACTIVE_THEME.message_gradient
if config.ACTIVE_THEME
else _default_magenta_gradient()
)
return lr_gradient(rows, offset, cols)
```
Then in scroll.py:
```python
rows = render.msg_gradient(rows, offset)
```
- [ ] **Step 3: Run existing scroll tests**
Run: `pytest tests/test_scroll.py -v`
Expected: PASS (existing functionality unchanged)
- [ ] **Step 4: Commit**
```bash
git add engine/scroll.py engine/render.py
git commit -m "feat: update scroll.py to use theme message gradient
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
- Use fallback magenta gradient when theme not initialized
- Ensure ntfy messages render in complementary color from selected theme"
```
---
## Chunk 5: Color Picker UI
### Task 5: Create pick_color_theme() function in app.py
**Files:**
- Modify: `engine/app.py:1-300`
- Test: manual/integration (interactive)
- [ ] **Step 1: Write helper functions for color picker UI**
Edit `engine/app.py`, add before `pick_font_face()` function:
```python
def _draw_color_picker(themes_list, selected):
"""Draw the color theme picker menu."""
import sys
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
print(CLR, end="")
print()
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
print(f" {W_GHOST}{'' * (tw() - 4)}\n")
for i, (theme_id, theme) in enumerate(themes_list):
prefix = "" if i == selected else " "
color = G_HI if i == selected else ""
reset = "" if i == selected else W_GHOST
print(f"{prefix}{color}{theme.name}{reset}")
print()
```
- [ ] **Step 2: Create pick_color_theme() function**
Edit `engine/app.py`, add after helper function:
```python
def pick_color_theme():
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
import sys
import termios
import tty
from engine import config, themes
# Non-interactive fallback: use green
if not sys.stdin.isatty():
config.set_active_theme("green")
return
themes_list = list(themes.THEME_REGISTRY.items())
selected = 0
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
_draw_color_picker(themes_list, selected)
key = _read_picker_key()
if key == "up":
selected = max(0, selected - 1)
elif key == "down":
selected = min(len(themes_list) - 1, selected + 1)
elif key == "enter":
break
elif key == "interrupt":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
selected_theme_id = themes_list[selected][0]
config.set_active_theme(selected_theme_id)
theme_name = themes_list[selected][1].name
print(f" {G_DIM}> using {theme_name}{RST}")
time.sleep(0.8)
print(CLR, end="")
print(CURSOR_OFF, end="")
print()
```
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
```python
def main():
# ... existing signal handler setup ...
pick_color_theme() # NEW LINE - before font picker
pick_font_face()
# ... rest of main unchanged ...
```
- [ ] **Step 4: Manual test - run in interactive terminal**
Run: `python3 mainline.py`
Expected:
- See color theme picker menu before font picker
- Can navigate with ↑/↓ or j/k
- Can select with Enter or q
- Selected theme applies to scrolling headlines
- Can select different themes and see colors change
- [ ] **Step 5: Manual test - run in non-interactive environment**
Run: `echo "" | python3 mainline.py`
Expected:
- No color picker menu shown
- Defaults to green theme
- App runs without error
- [ ] **Step 6: Commit**
```bash
git add engine/app.py
git commit -m "feat: add pick_color_theme() UI and integration
- Create _draw_color_picker() to render menu
- Create pick_color_theme() function mirroring font picker pattern
- Integrate into main() before font picker
- Fallback to green theme in non-interactive environments
- Support arrow keys and j/k navigation"
```
---
## Chunk 6: Integration & Validation
### Task 6: End-to-end testing and cleanup
**Files:**
- Test: All modified files
- Verify: App functionality
- [ ] **Step 1: Run full test suite**
Run: `pytest tests/ -v`
Expected: PASS (all tests, including new ones)
- [ ] **Step 2: Run linter**
Run: `ruff check engine/ mainline.py`
Expected: No errors (fix any style issues)
- [ ] **Step 3: Manual integration test - green theme**
Run: `python3 mainline.py`
Then select "Verdant Green" from picker.
Expected:
- Headlines render in green → deep green
- ntfy messages render in magenta gradient
- Both work correctly during streaming
- [ ] **Step 4: Manual integration test - orange theme**
Run: `python3 mainline.py`
Then select "Molten Orange" from picker.
Expected:
- Headlines render in orange → deep orange
- ntfy messages render in blue gradient
- Colors are visually distinct from green
- [ ] **Step 5: Manual integration test - purple theme**
Run: `python3 mainline.py`
Then select "Violet Purple" from picker.
Expected:
- Headlines render in purple → deep purple
- ntfy messages render in yellow gradient
- Colors are visually distinct from green and orange
- [ ] **Step 6: Test poetry mode with color picker**
Run: `python3 mainline.py --poetry`
Then select "orange" from picker.
Expected:
- Poetry mode works with color picker
- Colors apply to poetry rendering
- [ ] **Step 7: Test code mode with color picker**
Run: `python3 mainline.py --code`
Then select "purple" from picker.
Expected:
- Code mode works with color picker
- Colors apply to code rendering
- [ ] **Step 8: Verify acceptance criteria**
✓ Color picker displays 3 theme options at startup
✓ Selection applies to all headline and message gradients
✓ Boot UI (title, status) uses hardcoded green (not theme)
✓ Scrolling headlines and ntfy messages use theme gradients
✓ No persistence between runs (each run picks fresh)
✓ Non-TTY environments default to green without error
✓ Architecture supports future random/animation modes
✓ All gradient color codes finalized with no TBD values
- [ ] **Step 9: Final commit**
```bash
git add -A
git commit -m "feat: color scheme switcher implementation complete
Closes color-pick feature with:
- Three selectable color themes (green, orange, purple)
- Interactive menu at startup (mirrors font picker UI)
- Complementary colors for ntfy message queue
- Fallback to green in non-interactive environments
- All tests passing, manual validation complete"
```
- [ ] **Step 10: Create feature branch PR summary**
```
## Color Scheme Switcher
Implements interactive color theme selection for Mainline news ticker.
### What's New
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
- Complementary gradients for ntfy messages (magenta, blue, yellow)
- Fresh theme selection each run (no persistence)
### Files Changed
- `engine/themes.py` (new)
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
- `engine/render.py` (theme-aware gradients)
- `engine/scroll.py` (message gradient integration)
- `engine/app.py` (pick_color_theme UI)
- `tests/test_themes.py` (new theme tests)
- `README.md` (documentation)
### Acceptance Criteria
All met. App fully tested and ready for merge.
```
---
## Testing Checklist
- [ ] Unit tests: `pytest tests/test_themes.py -v`
- [ ] Unit tests: `pytest tests/test_config.py -v`
- [ ] Unit tests: `pytest tests/test_render.py -v`
- [ ] Full suite: `pytest tests/ -v`
- [ ] Linting: `ruff check engine/ mainline.py`
- [ ] Manual: Green theme selection
- [ ] Manual: Orange theme selection
- [ ] Manual: Purple theme selection
- [ ] Manual: Poetry mode with colors
- [ ] Manual: Code mode with colors
- [ ] Manual: Non-TTY fallback
---
## Notes
- `themes.py` is data-only; never import config or render to prevent cycles
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
- Message gradients use complementary colors; lookup in scroll.py
- Each gradient has 12 colors; verify length in tests
- No persistence; fresh picker each run

View File

@@ -0,0 +1,145 @@
# README Update Design — 2026-03-15
## Goal
Restructure and expand `README.md` to:
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
2. Add extensibility-focused content (`Extending` section)
3. Add developer workflow coverage (`Development` section)
4. Improve navigability via top-level grouping (Approach C)
---
## Proposed Structure
```
# MAINLINE
> tagline + description
## Using
### Run
### Config
### Feeds
### Fonts
### ntfy.sh
## Internals
### How it works
### Architecture
## Extending
### NtfyPoller
### MicMonitor
### Render pipeline
## Development
### Setup
### Tasks
### Testing
### Linting
## Roadmap
---
*footer*
```
---
## Section-by-section design
### Using
All existing content preserved verbatim. Two changes:
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
Subsections moved into Using (currently standalone):
- `Feeds` — it's configuration, not a concept
- `ntfy.sh` (usage half)
### Internals
All existing content preserved verbatim. One change:
- **Architecture**: append `tests/` directory listing to the module tree
### Extending
Entirely new section. Three subsections:
**NtfyPoller**
- Minimal working import + usage example
- Note: stdlib only dependencies
```python
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
poller.start()
# in your render loop:
msg = poller.get_active_message() # → (title, body, timestamp) or None
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
```
**MicMonitor**
- Minimal working import + usage example
- Note: sounddevice/numpy optional, degrades gracefully
```python
from engine.mic import MicMonitor
mic = MicMonitor(threshold_db=50)
if mic.start(): # returns False if sounddevice unavailable
excess = mic.excess # dB above threshold, clamped to 0
db = mic.db # raw RMS dB level
```
**Render pipeline**
- Brief prose about `engine.render` as importable pipeline
- Minimal sketch of serve.py / ESP32 usage pattern
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
### Development
Entirely new section. Four subsections:
**Setup**
- Hard requirements: Python 3.10+, uv
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
**Tasks** (via mise)
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
**Testing**
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
**Linting**
- `uv run ruff check` and `uv run ruff format`
- Note: pre-commit hooks run lint via `hk`
### Roadmap
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
### Footer
Update `Python 3.9+``Python 3.10+`.
---
## Files changed
- `README.md` — restructured and expanded as above
- No other files
---
## What is not changing
- All existing prose, examples, and config table values — preserved verbatim where retained
- The Ideas/Future content — kept intact under the new Roadmap heading
- The cyberpunk voice and terse style of the existing README

View File

@@ -0,0 +1,154 @@
# Code Scroll Mode — Design Spec
**Date:** 2026-03-16
**Branch:** feat/code-scroll
**Status:** Approved
---
## Overview
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
---
## Goals
- Mirror the existing `--poetry` mode pattern as closely as possible
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
- No changes to `scroll.py` or the render pipeline
- The item tuple shape `(text, src, ts)` is unchanged
---
## New Files
### `engine/fetch_code.py`
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
**Algorithm:**
1. Glob `engine/*.py` in sorted order
2. For each file:
a. Read source text
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
c. Iterate source lines (1-indexed). Skip if:
- The stripped line is empty
- The stripped line starts with `#`
d. For each kept line emit:
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py``engine.scroll`
3. Return `(items, len(items), 0)`
**Scope label rules:**
- `FunctionDef` / `AsyncFunctionDef``name()`
- `ClassDef``name` (no parens)
- No enclosing node → `<module>`
**Dependencies:** `ast`, `pathlib` — stdlib only.
---
## Modified Files
### `engine/config.py`
Extend `MODE` detection to recognise `--code`:
```python
MODE = (
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
else "code" if "--code" in sys.argv
else "news"
)
```
### `engine/app.py`
**Subtitle line** — extend the subtitle dict:
```python
_subtitle = {
"poetry": "literary consciousness stream",
"code": "source consciousness stream",
}.get(config.MODE, "digital consciousness stream")
```
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
```python
elif config.MODE == "code":
from engine.fetch_code import fetch_code
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
time.sleep(0.2)
print()
items, line_count, _ = fetch_code()
print()
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
```
No cache save/load — local source files are read instantly and change only on disk writes.
---
## Data Flow
```
engine/*.py (sorted)
fetch_code()
│ ast.parse → scope map
│ filter blank + comment lines
│ emit (line, scope(), engine.module)
items: List[Tuple[str, str, str]]
stream(items, ntfy, mic) ← unchanged
next_headline() shuffles + recycles automatically
```
---
## Error Handling
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
---
## Testing
New file: `tests/test_fetch_code.py`
| Test | Assertion |
|------|-----------|
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
No mocking — tests read the real engine source files, keeping them honest against actual content.
---
## CLI
```bash
python3 mainline.py --code # source consciousness mode
uv run mainline.py --code
```
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
---
## Out of Scope
- Syntax highlighting / token-aware coloring (can be added later)
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
- Caching code items to disk

View 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

View File

@@ -10,9 +10,11 @@ import termios
import time import time
import tty import tty
from engine import config, render from engine import config, render, themes
from engine.controller import StreamController
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
from engine.mic import MicMonitor
from engine.ntfy import NtfyPoller
from engine.scroll import stream
from engine.terminal import ( from engine.terminal import (
CLR, CLR,
CURSOR_OFF, CURSOR_OFF,
@@ -63,6 +65,30 @@ def _read_picker_key():
return None return None
def _draw_color_picker(themes_list, selected):
"""Draw the color theme picker menu.
Args:
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
selected: Index of currently selected theme (0-2)
"""
print(CLR, end="")
print()
print(
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
)
print(f" {W_GHOST}{'' * (tw() - 4)}{RST}\n")
for i, (theme_id, theme) in enumerate(themes_list):
prefix = "" if i == selected else " "
color = G_HI if i == selected else ""
reset = "" if i == selected else W_GHOST
print(f"{prefix}{color}{theme.name}{reset}")
print()
def _normalize_preview_rows(rows): def _normalize_preview_rows(rows):
"""Trim shared left padding and trailing spaces for stable on-screen previews.""" """Trim shared left padding and trailing spaces for stable on-screen previews."""
non_empty = [r for r in rows if r.strip()] non_empty = [r for r in rows if r.strip()]
@@ -129,6 +155,50 @@ def _draw_font_picker(faces, selected):
print(f" {shown}") print(f" {shown}")
def pick_color_theme():
"""Interactive color theme picker. Defaults to 'green' if not TTY.
Displays a menu of available themes and lets user select with arrow keys.
Non-interactive environments (piped stdin, CI) silently default to green.
"""
# Non-interactive fallback
if not sys.stdin.isatty():
config.set_active_theme("green")
return
# Interactive picker
themes_list = list(themes.THEME_REGISTRY.items())
selected = 0
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
_draw_color_picker(themes_list, selected)
key = _read_picker_key()
if key == "up":
selected = max(0, selected - 1)
elif key == "down":
selected = min(len(themes_list) - 1, selected + 1)
elif key == "enter":
break
elif key == "interrupt":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
selected_theme_id = themes_list[selected][0]
config.set_active_theme(selected_theme_id)
theme_name = themes_list[selected][1].name
print(f" {G_DIM}> using {theme_name}{RST}")
time.sleep(0.8)
print(CLR, end="")
print(CURSOR_OFF, end="")
print()
def pick_font_face(): def pick_font_face():
"""Interactive startup picker for selecting a face from repo OTF files.""" """Interactive startup picker for selecting a face from repo OTF files."""
if not config.FONT_PICKER: if not config.FONT_PICKER:
@@ -247,110 +317,6 @@ def pick_font_face():
print() print()
def pick_effects_config():
"""Interactive picker for configuring effects pipeline."""
import effects_plugins
from engine.effects import get_effect_chain, get_registry
effects_plugins.discover_plugins()
registry = get_registry()
chain = get_effect_chain()
chain.set_order(["noise", "fade", "glitch", "firehose"])
effects = list(registry.list_all().values())
if not effects:
return
selected = 0
editing_intensity = False
intensity_value = 1.0
def _draw_effects_picker():
w = tw()
print(CLR, end="")
print("\033[1;1H", end="")
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
print(f" \033[2;38;5;37m{'' * (w - 4)}\033[0m")
print()
for i, effect in enumerate(effects):
prefix = " > " if i == selected else " "
marker = "[*]" if effect.config.enabled else "[ ]"
if editing_intensity and i == selected:
print(
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
)
else:
print(
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
)
print()
print(f" \033[2;38;5;37m{'' * (w - 4)}\033[0m")
print(
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
)
def _read_effects_key():
ch = sys.stdin.read(1)
if ch == "\x03":
return "interrupt"
if ch in ("\r", "\n"):
return "enter"
if ch == " ":
return "toggle"
if ch == "q":
return "quit"
if ch == "+" or ch == "=":
return "up"
if ch == "-" or ch == "_":
return "down"
if ch == "\x1b":
c1 = sys.stdin.read(1)
if c1 != "[":
return None
c2 = sys.stdin.read(1)
if c2 == "A":
return "up"
if c2 == "B":
return "down"
return None
return None
if not sys.stdin.isatty():
return
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
_draw_effects_picker()
key = _read_effects_key()
if key == "quit" or key == "enter":
break
elif key == "up" and editing_intensity:
intensity_value = min(1.0, intensity_value + 0.1)
effects[selected].config.intensity = intensity_value
elif key == "down" and editing_intensity:
intensity_value = max(0.0, intensity_value - 0.1)
effects[selected].config.intensity = intensity_value
elif key == "up":
selected = max(0, selected - 1)
intensity_value = effects[selected].config.intensity
elif key == "down":
selected = min(len(effects) - 1, selected + 1)
intensity_value = effects[selected].config.intensity
elif key == "toggle":
effects[selected].config.enabled = not effects[selected].config.enabled
elif key == "interrupt":
raise KeyboardInterrupt
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
def main(): def main():
atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
@@ -361,13 +327,11 @@ def main():
signal.signal(signal.SIGINT, handle_sigint) signal.signal(signal.SIGINT, handle_sigint)
StreamController.warmup_topics()
w = tw() w = tw()
print(CLR, end="") print(CLR, end="")
print(CURSOR_OFF, end="") print(CURSOR_OFF, end="")
pick_color_theme()
pick_font_face() pick_font_face()
pick_effects_config()
w = tw() w = tw()
print() print()
time.sleep(0.4) time.sleep(0.4)
@@ -377,11 +341,10 @@ def main():
time.sleep(0.07) time.sleep(0.07)
print() print()
_subtitle = ( _subtitle = {
"literary consciousness stream" "poetry": "literary consciousness stream",
if config.MODE == "poetry" "code": "source consciousness stream",
else "digital consciousness stream" }.get(config.MODE, "digital consciousness stream")
)
print(f" {W_DIM}v0.1 · {_subtitle}{RST}") print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
print(f" {W_GHOST}{'' * (w - 4)}{RST}") print(f" {W_GHOST}{'' * (w - 4)}{RST}")
print() print()
@@ -402,6 +365,15 @@ def main():
) )
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}") print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
save_cache(items) save_cache(items)
elif config.MODE == "code":
from engine.fetch_code import fetch_code
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
time.sleep(0.2)
print()
items, line_count, _ = fetch_code()
print()
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
else: else:
slow_print(" > INITIALIZING FEED ARRAY...\n") slow_print(" > INITIALIZING FEED ARRAY...\n")
time.sleep(0.2) time.sleep(0.2)
@@ -419,10 +391,9 @@ def main():
sys.exit(1) sys.exit(1)
print() print()
controller = StreamController() mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
mic_ok, ntfy_ok = controller.initialize_sources() mic_ok = mic.start()
if mic.available:
if controller.mic and controller.mic.available:
boot_ln( boot_ln(
"Microphone", "Microphone",
"ACTIVE" "ACTIVE"
@@ -431,6 +402,12 @@ def main():
bool(mic_ok), bool(mic_ok),
) )
ntfy = NtfyPoller(
config.NTFY_TOPIC,
reconnect_delay=config.NTFY_RECONNECT_DELAY,
display_secs=config.MESSAGE_DISPLAY_SECS,
)
ntfy_ok = ntfy.start()
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok) boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
if config.FIREHOSE: if config.FIREHOSE:
@@ -443,7 +420,7 @@ def main():
print() print()
time.sleep(0.4) time.sleep(0.4)
controller.run(items) stream(items, ntfy, mic)
print() print()
print(f" {W_GHOST}{'' * (tw() - 4)}{RST}") print(f" {W_GHOST}{'' * (tw() - 4)}{RST}")

View File

@@ -105,8 +105,6 @@ class Config:
firehose: bool = False firehose: bool = False
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json" ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
ntfy_reconnect_delay: int = 5 ntfy_reconnect_delay: int = 5
message_display_secs: int = 30 message_display_secs: int = 30
@@ -150,8 +148,6 @@ class Config:
mode="poetry" if "--poetry" in argv or "-p" in argv else "news", mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
firehose="--firehose" in argv, firehose="--firehose" in argv,
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json", ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
ntfy_reconnect_delay=5, ntfy_reconnect_delay=5,
message_display_secs=30, message_display_secs=30,
font_dir=font_dir, font_dir=font_dir,
@@ -192,13 +188,17 @@ def set_config(config: Config) -> None:
HEADLINE_LIMIT = 1000 HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10 FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news" MODE = (
"poetry"
if "--poetry" in sys.argv or "-p" in sys.argv
else "code"
if "--code" in sys.argv
else "news"
)
FIREHOSE = "--firehose" in sys.argv FIREHOSE = "--firehose" in sys.argv
# ─── NTFY MESSAGE QUEUE ────────────────────────────────── # ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json" NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
@@ -237,3 +237,26 @@ def set_font_selection(font_path=None, font_index=None):
FONT_PATH = _resolve_font_path(font_path) FONT_PATH = _resolve_font_path(font_path)
if font_index is not None: if font_index is not None:
FONT_INDEX = max(0, int(font_index)) FONT_INDEX = max(0, int(font_index))
# ─── THEME MANAGEMENT ─────────────────────────────────────────
ACTIVE_THEME = None
def set_active_theme(theme_id: str = "green"):
"""Set the active theme by ID.
Args:
theme_id: Theme identifier ("green", "orange", or "purple")
Defaults to "green"
Raises:
KeyError: If theme_id is not in the theme registry
Side Effects:
Sets the ACTIVE_THEME global variable
"""
global ACTIVE_THEME
from engine import themes
ACTIVE_THEME = themes.get_theme(theme_id)

View File

@@ -3,7 +3,6 @@ Stream controller - manages input sources and orchestrates the render stream.
""" """
from engine.config import Config, get_config from engine.config import Config, get_config
from engine.effects.controller import handle_effects_command
from engine.eventbus import EventBus from engine.eventbus import EventBus
from engine.events import EventType, StreamEvent from engine.events import EventType, StreamEvent
from engine.mic import MicMonitor from engine.mic import MicMonitor
@@ -14,45 +13,11 @@ from engine.scroll import stream
class StreamController: class StreamController:
"""Controls the stream lifecycle - initializes sources and runs the stream.""" """Controls the stream lifecycle - initializes sources and runs the stream."""
_topics_warmed = False
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None): def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
self.config = config or get_config() self.config = config or get_config()
self.event_bus = event_bus self.event_bus = event_bus
self.mic: MicMonitor | None = None self.mic: MicMonitor | None = None
self.ntfy: NtfyPoller | None = None self.ntfy: NtfyPoller | None = None
self.ntfy_cc: NtfyPoller | None = None
@classmethod
def warmup_topics(cls) -> None:
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
if cls._topics_warmed:
return
import urllib.request
topics = [
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
"https://ntfy.sh/klubhaus_terminal_mainline",
]
for topic in topics:
try:
req = urllib.request.Request(
topic,
data=b"init",
headers={
"User-Agent": "mainline/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
cls._topics_warmed = True
def initialize_sources(self) -> tuple[bool, bool]: def initialize_sources(self) -> tuple[bool, bool]:
"""Initialize microphone and ntfy sources. """Initialize microphone and ntfy sources.
@@ -70,38 +35,7 @@ class StreamController:
) )
ntfy_ok = self.ntfy.start() ntfy_ok = self.ntfy.start()
self.ntfy_cc = NtfyPoller( return bool(mic_ok), ntfy_ok
self.config.ntfy_cc_cmd_topic,
reconnect_delay=self.config.ntfy_reconnect_delay,
display_secs=5,
)
self.ntfy_cc.subscribe(self._handle_cc_message)
ntfy_cc_ok = self.ntfy_cc.start()
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
def _handle_cc_message(self, event) -> None:
"""Handle incoming C&C message - like a serial port control interface."""
import urllib.request
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
if not cmd.startswith("/"):
return
response = handle_effects_command(cmd)
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
data = response.encode("utf-8")
req = urllib.request.Request(
topic_url,
data=data,
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=5)
except Exception:
pass
def run(self, items: list) -> None: def run(self, items: list) -> None:
"""Run the stream with initialized sources.""" """Run the stream with initialized sources."""

67
engine/fetch_code.py Normal file
View File

@@ -0,0 +1,67 @@
"""
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
as scroll items. Used by --code mode.
Depends on: nothing (stdlib only).
"""
import ast
from pathlib import Path
_ENGINE_DIR = Path(__file__).resolve().parent
def _scope_map(source: str) -> dict[int, str]:
"""Return {line_number: scope_label} for every line in source.
Nodes are sorted by range size descending so inner scopes overwrite
outer ones, guaranteeing the narrowest enclosing scope wins.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return {}
nodes = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
end = getattr(node, "end_lineno", node.lineno)
span = end - node.lineno
nodes.append((span, node))
# Largest range first → inner scopes overwrite on second pass
nodes.sort(key=lambda x: x[0], reverse=True)
scope = {}
for _, node in nodes:
end = getattr(node, "end_lineno", node.lineno)
if isinstance(node, ast.ClassDef):
label = node.name
else:
label = f"{node.name}()"
for ln in range(node.lineno, end + 1):
scope[ln] = label
return scope
def fetch_code():
"""Read engine/*.py and return (items, line_count, 0).
Each item is (text, src, ts) where:
text = the code line (rstripped, indentation preserved)
src = enclosing function/class name, e.g. 'stream()' or '<module>'
ts = dotted module path, e.g. 'engine.scroll'
"""
items = []
for path in sorted(_ENGINE_DIR.glob("*.py")):
module = f"engine.{path.stem}"
source = path.read_text(encoding="utf-8")
scope = _scope_map(source)
for lineno, raw in enumerate(source.splitlines(), start=1):
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
label = scope.get(lineno, "<module>")
items.append((raw.rstrip(), label, module))
return items, len(items), 0

View File

@@ -18,7 +18,7 @@ from engine.effects import (
noise, noise,
vis_trunc, 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 from engine.terminal import RST, W_COOL
MSG_META = "\033[38;5;245m" MSG_META = "\033[38;5;245m"
@@ -57,7 +57,7 @@ def render_message_overlay(
else: else:
msg_rows = msg_cache[1] msg_rows = msg_cache[1]
msg_rows = lr_gradient_opposite( msg_rows = msg_gradient(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0 msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
) )

View File

@@ -15,38 +15,72 @@ from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
from engine.terminal import RST from engine.terminal import RST
from engine.translate import detect_location_language, translate_headline from engine.translate import detect_location_language, translate_headline
# ─── GRADIENT ─────────────────────────────────────────────
# Left → right: white-hot leading edge fades to near-black
GRAD_COLS = [
"\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
# Complementary sweep for queue messages (opposite hue family from ticker greens) # ─── GRADIENT ─────────────────────────────────────────────
MSG_GRAD_COLS = [ def _color_codes_to_ansi(color_codes):
"\033[1;38;5;231m", # white """Convert a list of 256-color codes to ANSI escape code strings.
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
"\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta Args:
"\033[38;5;201m", # bright magenta color_codes: List of 12 integers (256-color palette codes)
"\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta Returns:
"\033[38;5;125m", # dark magenta List of ANSI escape code strings
"\033[38;5;89m", # deep maroon-magenta """
"\033[2;38;5;89m", # dim deep maroon-magenta if not color_codes or len(color_codes) != 12:
"\033[2;38;5;235m", # near black # Fallback to default green if invalid
] return _default_green_gradient()
result = []
for i, code in enumerate(color_codes):
if i < 2:
# Bold for first 2 (bright leading edge)
result.append(f"\033[1;38;5;{code}m")
elif i < 10:
# Normal for middle 8
result.append(f"\033[38;5;{code}m")
else:
# Dim for last 2 (dark trailing edge)
result.append(f"\033[2;38;5;{code}m")
return result
def _default_green_gradient():
"""Return the default 12-color green gradient for fallback when no theme is active."""
return [
"\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime
"\033[38;5;46m", # bright green
"\033[38;5;40m", # green
"\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black
]
def _default_magenta_gradient():
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
return [
"\033[1;38;5;231m", # white
"\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink
"\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta
"\033[38;5;201m", # bright magenta
"\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta
"\033[38;5;125m", # dark magenta
"\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black
]
# ─── FONT LOADING ───────────────────────────────────────── # ─── FONT LOADING ─────────────────────────────────────────
_FONT_OBJ = None _FONT_OBJ = None
@@ -189,9 +223,15 @@ def big_wrap(text, max_w, fnt=None):
return out return out
def lr_gradient(rows, offset=0.0, grad_cols=None): def lr_gradient(rows, offset=0.0, cols=None):
"""Color each non-space block character with a shifting left-to-right gradient.""" """Color each non-space block character with a shifting left-to-right gradient."""
cols = grad_cols or GRAD_COLS if cols is None:
from engine import config
if config.ACTIVE_THEME:
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
else:
cols = _default_green_gradient()
n = len(cols) n = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1) max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = [] out = []
@@ -213,7 +253,30 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
def lr_gradient_opposite(rows, offset=0.0): def lr_gradient_opposite(rows, offset=0.0):
"""Complementary (opposite wheel) gradient used for queue message panels.""" """Complementary (opposite wheel) gradient used for queue message panels."""
return lr_gradient(rows, offset, MSG_GRAD_COLS) return lr_gradient(rows, offset, _default_magenta_gradient())
def msg_gradient(rows, offset):
"""Apply message (ntfy) gradient using theme complementary colors.
Returns colored rows using ACTIVE_THEME.message_gradient if available,
falling back to default magenta if no theme is set.
Args:
rows: List of text strings to colorize
offset: Gradient offset (0.0-1.0) for animation
Returns:
List of rows with ANSI color codes applied
"""
from engine import config
cols = (
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
if config.ACTIVE_THEME
else _default_magenta_gradient()
)
return lr_gradient(rows, offset, cols)
# ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── # ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────

60
engine/themes.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Theme definitions with color gradients for terminal rendering.
This module is data-only and does not import config or render
to prevent circular dependencies.
"""
class Theme:
"""Represents a color theme with two gradients."""
def __init__(self, name, main_gradient, message_gradient):
"""Initialize a theme with name and color gradients.
Args:
name: Theme identifier string
main_gradient: List of 12 ANSI 256-color codes for main gradient
message_gradient: List of 12 ANSI 256-color codes for message gradient
"""
self.name = name
self.main_gradient = main_gradient
self.message_gradient = message_gradient
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
# Each gradient is 12 ANSI 256-color codes in sequence
# Format: [light...] → [medium...] → [dark...] → [black]
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
THEME_REGISTRY = {
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
}
def get_theme(theme_id):
"""Retrieve a theme by ID.
Args:
theme_id: Theme identifier string
Returns:
Theme object matching the ID
Raises:
KeyError: If theme_id is not in registry
"""
return THEME_REGISTRY[theme_id]

BIN
fonts/Kapiler.otf Normal file

Binary file not shown.

BIN
fonts/Kapiler.ttf Normal file

Binary file not shown.

View File

@@ -25,41 +25,24 @@ run = "uv run mainline.py"
run-poetry = "uv run mainline.py --poetry" run-poetry = "uv run mainline.py --poetry"
run-firehose = "uv run mainline.py --firehose" run-firehose = "uv run mainline.py --firehose"
daemon = "nohup uv run mainline.py > /dev/null 2>&1 &"
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
# =====================
# Command & Control
# =====================
cmd = "uv run cmdline.py"
cmd-stats = "bash -c 'uv run cmdline.py -w \"/effects stats\"';:"
# Initialize ntfy topics (warm up before first use - also done automatically by mainline)
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
# ===================== # =====================
# Environment # Environment
# ===================== # =====================
sync = "uv sync" sync = "uv sync"
sync-all = "uv sync --all-extras" sync-all = "uv sync --all-extras"
install = "mise run sync" install = "uv sync"
install-dev = "mise run sync && uv sync --group dev" install-dev = "uv sync --group dev"
bootstrap = "uv sync && uv run mainline.py --help" bootstrap = "uv sync && uv run mainline.py --help"
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out" clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
# Aggressive cleanup - removes all generated files, caches, and venv
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
# ===================== # =====================
# CI/CD # CI/CD
# ===================== # =====================
ci = "mise run topics-init && uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
ci-lint = "uv run ruff check engine/ mainline.py" ci-lint = "uv run ruff check engine/ mainline.py"
# ===================== # =====================

35
tests/test_fetch_code.py Normal file
View File

@@ -0,0 +1,35 @@
import re
from engine.fetch_code import fetch_code
def test_return_shape():
items, line_count, ignored = fetch_code()
assert isinstance(items, list)
assert line_count == len(items)
assert ignored == 0
def test_items_are_tuples():
items, _, _ = fetch_code()
assert items, "expected at least one code line"
for item in items:
assert isinstance(item, tuple) and len(item) == 3
text, src, ts = item
assert isinstance(text, str)
assert isinstance(src, str)
assert isinstance(ts, str)
def test_blank_and_comment_lines_excluded():
items, _, _ = fetch_code()
for text, _, _ in items:
assert text.strip(), "blank line should have been filtered"
assert not text.strip().startswith("#"), "comment line should have been filtered"
def test_module_path_format():
items, _, _ = fetch_code()
pattern = re.compile(r"^engine\.\w+$")
for _, _, ts in items:
assert pattern.match(ts), f"unexpected module path: {ts!r}"

View File

@@ -1,127 +0,0 @@
"""
Integration tests for ntfy topics.
"""
import json
import time
import urllib.request
class TestNtfyTopics:
def test_cc_cmd_topic_exists_and_writable(self):
"""Verify C&C CMD topic exists and accepts messages."""
from engine.config import NTFY_CC_CMD_TOPIC
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}"
req = urllib.request.Request(
topic_url,
data=test_message.encode("utf-8"),
headers={
"User-Agent": "mainline-test/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
assert resp.status == 200
except Exception as e:
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
def test_cc_resp_topic_exists_and_writable(self):
"""Verify C&C RESP topic exists and accepts messages."""
from engine.config import NTFY_CC_RESP_TOPIC
topic_url = NTFY_CC_RESP_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}"
req = urllib.request.Request(
topic_url,
data=test_message.encode("utf-8"),
headers={
"User-Agent": "mainline-test/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
assert resp.status == 200
except Exception as e:
raise AssertionError(f"Failed to write to C&C RESP topic: {e}") from e
def test_message_topic_exists_and_writable(self):
"""Verify message topic exists and accepts messages."""
from engine.config import NTFY_TOPIC
topic_url = NTFY_TOPIC.replace("/json", "")
test_message = f"test_{int(time.time())}"
req = urllib.request.Request(
topic_url,
data=test_message.encode("utf-8"),
headers={
"User-Agent": "mainline-test/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
assert resp.status == 200
except Exception as e:
raise AssertionError(f"Failed to write to message topic: {e}") from e
def test_cc_cmd_topic_readable(self):
"""Verify we can read messages from C&C CMD topic."""
from engine.config import NTFY_CC_CMD_TOPIC
test_message = f"integration_test_{int(time.time())}"
topic_url = NTFY_CC_CMD_TOPIC.replace("/json", "")
req = urllib.request.Request(
topic_url,
data=test_message.encode("utf-8"),
headers={
"User-Agent": "mainline-test/0.1",
"Content-Type": "text/plain",
},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception as e:
raise AssertionError(f"Failed to write to C&C CMD topic: {e}") from e
time.sleep(1)
poll_url = f"{NTFY_CC_CMD_TOPIC}?poll=1&limit=1"
req = urllib.request.Request(
poll_url,
headers={"User-Agent": "mainline-test/0.1"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode("utf-8")
if body.strip():
data = json.loads(body.split("\n")[0])
assert isinstance(data, dict)
except Exception as e:
raise AssertionError(f"Failed to read from C&C CMD topic: {e}") from e
def test_topics_are_different(self):
"""Verify C&C CMD/RESP and message topics are different."""
from engine.config import NTFY_CC_CMD_TOPIC, NTFY_CC_RESP_TOPIC, NTFY_TOPIC
assert NTFY_CC_CMD_TOPIC != NTFY_TOPIC
assert NTFY_CC_RESP_TOPIC != NTFY_TOPIC
assert NTFY_CC_CMD_TOPIC != NTFY_CC_RESP_TOPIC
assert "_cc_cmd" in NTFY_CC_CMD_TOPIC
assert "_cc_resp" in NTFY_CC_RESP_TOPIC

301
tests/test_render.py Normal file
View File

@@ -0,0 +1,301 @@
"""
Tests for engine.render module.
"""
import pytest
from engine import config, render
class TestDefaultGradients:
"""Tests for default gradient fallback functions."""
def test_default_green_gradient_length(self):
"""_default_green_gradient returns 12 colors."""
gradient = render._default_green_gradient()
assert len(gradient) == 12
def test_default_green_gradient_is_list(self):
"""_default_green_gradient returns a list."""
gradient = render._default_green_gradient()
assert isinstance(gradient, list)
def test_default_green_gradient_all_strings(self):
"""_default_green_gradient returns list of ANSI code strings."""
gradient = render._default_green_gradient()
assert all(isinstance(code, str) for code in gradient)
def test_default_magenta_gradient_length(self):
"""_default_magenta_gradient returns 12 colors."""
gradient = render._default_magenta_gradient()
assert len(gradient) == 12
def test_default_magenta_gradient_is_list(self):
"""_default_magenta_gradient returns a list."""
gradient = render._default_magenta_gradient()
assert isinstance(gradient, list)
def test_default_magenta_gradient_all_strings(self):
"""_default_magenta_gradient returns list of ANSI code strings."""
gradient = render._default_magenta_gradient()
assert all(isinstance(code, str) for code in gradient)
class TestLrGradientUsesActiveTheme:
"""Tests for lr_gradient using active theme."""
def test_lr_gradient_uses_active_theme_when_cols_none(self):
"""lr_gradient uses ACTIVE_THEME.main_gradient when cols=None."""
# Save original state
original_theme = config.ACTIVE_THEME
try:
# Set a theme
config.set_active_theme("green")
# Create simple test data
rows = ["text"]
# Call without cols parameter (cols=None)
result = render.lr_gradient(rows, offset=0.0)
# Should not raise and should return colored output
assert isinstance(result, list)
assert len(result) == 1
# Should have ANSI codes (no plain "text")
assert result[0] != "text"
finally:
# Restore original state
config.ACTIVE_THEME = original_theme
def test_lr_gradient_fallback_when_no_theme(self):
"""lr_gradient uses fallback green when ACTIVE_THEME is None."""
# Save original state
original_theme = config.ACTIVE_THEME
try:
# Clear the theme
config.ACTIVE_THEME = None
# Create simple test data
rows = ["text"]
# Call without cols parameter (should use fallback)
result = render.lr_gradient(rows, offset=0.0)
# Should not raise and should return colored output
assert isinstance(result, list)
assert len(result) == 1
# Should have ANSI codes (no plain "text")
assert result[0] != "text"
finally:
# Restore original state
config.ACTIVE_THEME = original_theme
def test_lr_gradient_explicit_cols_parameter_still_works(self):
"""lr_gradient with explicit cols parameter overrides theme."""
# Custom gradient
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
rows = ["xy"]
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
# Should use the provided cols
assert isinstance(result, list)
assert len(result) == 1
def test_lr_gradient_respects_cols_parameter_name(self):
"""lr_gradient accepts cols as keyword argument."""
custom_cols = ["\033[38;5;1m", "\033[38;5;2m"] * 6
rows = ["xy"]
# Call with cols as keyword
result = render.lr_gradient(rows, offset=0.0, cols=custom_cols)
assert isinstance(result, list)
class TestLrGradientBasicFunctionality:
"""Tests to ensure lr_gradient basic functionality still works."""
def test_lr_gradient_colors_non_space_chars(self):
"""lr_gradient colors non-space characters."""
rows = ["hello"]
# Set a theme for the test
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("green")
result = render.lr_gradient(rows, offset=0.0)
# Result should have ANSI codes
assert any("\033[" in r for r in result), "Expected ANSI codes in result"
finally:
config.ACTIVE_THEME = original_theme
def test_lr_gradient_preserves_spaces(self):
"""lr_gradient preserves spaces in output."""
rows = ["a b c"]
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("green")
result = render.lr_gradient(rows, offset=0.0)
# Spaces should be preserved (not colored)
assert " " in result[0]
finally:
config.ACTIVE_THEME = original_theme
def test_lr_gradient_empty_rows(self):
"""lr_gradient handles empty rows correctly."""
rows = [""]
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("green")
result = render.lr_gradient(rows, offset=0.0)
assert result == [""]
finally:
config.ACTIVE_THEME = original_theme
def test_lr_gradient_multiple_rows(self):
"""lr_gradient handles multiple rows."""
rows = ["row1", "row2", "row3"]
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("green")
result = render.lr_gradient(rows, offset=0.0)
assert len(result) == 3
finally:
config.ACTIVE_THEME = original_theme
class TestMsgGradient:
"""Tests for msg_gradient function (message/ntfy overlay coloring)."""
def test_msg_gradient_uses_active_theme(self):
"""msg_gradient uses ACTIVE_THEME.message_gradient when theme is set."""
# Save original state
original_theme = config.ACTIVE_THEME
try:
# Set a theme
config.set_active_theme("green")
# Create simple test data
rows = ["MESSAGE"]
# Call msg_gradient
result = render.msg_gradient(rows, offset=0.0)
# Should return colored output using theme's message_gradient
assert isinstance(result, list)
assert len(result) == 1
# Should have ANSI codes from the message gradient
assert result[0] != "MESSAGE"
assert "\033[" in result[0]
finally:
# Restore original state
config.ACTIVE_THEME = original_theme
def test_msg_gradient_fallback_when_no_theme(self):
"""msg_gradient uses fallback magenta when ACTIVE_THEME is None."""
# Save original state
original_theme = config.ACTIVE_THEME
try:
# Clear the theme
config.ACTIVE_THEME = None
# Create simple test data
rows = ["MESSAGE"]
# Call msg_gradient
result = render.msg_gradient(rows, offset=0.0)
# Should return colored output using default magenta
assert isinstance(result, list)
assert len(result) == 1
# Should have ANSI codes
assert result[0] != "MESSAGE"
assert "\033[" in result[0]
finally:
# Restore original state
config.ACTIVE_THEME = original_theme
def test_msg_gradient_returns_colored_rows(self):
"""msg_gradient returns properly colored rows with animation offset."""
# Save original state
original_theme = config.ACTIVE_THEME
try:
# Set a theme
config.set_active_theme("orange")
rows = ["NTFY", "ALERT"]
# Call with offset
result = render.msg_gradient(rows, offset=0.5)
# Should return same number of rows
assert len(result) == 2
# Both should be colored
assert all("\033[" in r for r in result)
# Should not be the original text
assert result != rows
finally:
config.ACTIVE_THEME = original_theme
def test_msg_gradient_different_themes_produce_different_results(self):
"""msg_gradient produces different colors for different themes."""
original_theme = config.ACTIVE_THEME
try:
rows = ["TEST"]
# Get result with green theme
config.set_active_theme("green")
result_green = render.msg_gradient(rows, offset=0.0)
# Get result with orange theme
config.set_active_theme("orange")
result_orange = render.msg_gradient(rows, offset=0.0)
# Results should be different (different message gradients)
assert result_green != result_orange
finally:
config.ACTIVE_THEME = original_theme
def test_msg_gradient_preserves_spacing(self):
"""msg_gradient preserves spaces in rows."""
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("purple")
rows = ["M E S S A G E"]
result = render.msg_gradient(rows, offset=0.0)
# Spaces should be preserved
assert " " in result[0]
finally:
config.ACTIVE_THEME = original_theme
def test_msg_gradient_empty_rows(self):
"""msg_gradient handles empty rows correctly."""
original_theme = config.ACTIVE_THEME
try:
config.set_active_theme("green")
rows = [""]
result = render.msg_gradient(rows, offset=0.0)
# Empty row should stay empty
assert result == [""]
finally:
config.ACTIVE_THEME = original_theme

169
tests/test_themes.py Normal file
View File

@@ -0,0 +1,169 @@
"""
Tests for engine.themes module.
"""
import pytest
from engine import themes
class TestThemeConstruction:
"""Tests for Theme class initialization."""
def test_theme_construction(self):
"""Theme stores name and gradients correctly."""
main_grad = ["color1", "color2", "color3"]
msg_grad = ["msg1", "msg2", "msg3"]
theme = themes.Theme("test_theme", main_grad, msg_grad)
assert theme.name == "test_theme"
assert theme.main_gradient == main_grad
assert theme.message_gradient == msg_grad
class TestGradientLength:
"""Tests for gradient length validation."""
def test_gradient_length_green(self):
"""Green theme has exactly 12 colors in each gradient."""
green = themes.THEME_REGISTRY["green"]
assert len(green.main_gradient) == 12
assert len(green.message_gradient) == 12
def test_gradient_length_orange(self):
"""Orange theme has exactly 12 colors in each gradient."""
orange = themes.THEME_REGISTRY["orange"]
assert len(orange.main_gradient) == 12
assert len(orange.message_gradient) == 12
def test_gradient_length_purple(self):
"""Purple theme has exactly 12 colors in each gradient."""
purple = themes.THEME_REGISTRY["purple"]
assert len(purple.main_gradient) == 12
assert len(purple.message_gradient) == 12
class TestThemeRegistry:
"""Tests for THEME_REGISTRY dictionary."""
def test_theme_registry_has_three_themes(self):
"""Registry contains exactly three themes: green, orange, purple."""
assert len(themes.THEME_REGISTRY) == 3
assert set(themes.THEME_REGISTRY.keys()) == {"green", "orange", "purple"}
def test_registry_values_are_themes(self):
"""All registry values are Theme instances."""
for theme_id, theme in themes.THEME_REGISTRY.items():
assert isinstance(theme, themes.Theme)
assert theme.name == theme_id
class TestGetTheme:
"""Tests for get_theme function."""
def test_get_theme_valid_green(self):
"""get_theme('green') returns correct green Theme."""
green = themes.get_theme("green")
assert isinstance(green, themes.Theme)
assert green.name == "green"
def test_get_theme_valid_orange(self):
"""get_theme('orange') returns correct orange Theme."""
orange = themes.get_theme("orange")
assert isinstance(orange, themes.Theme)
assert orange.name == "orange"
def test_get_theme_valid_purple(self):
"""get_theme('purple') returns correct purple Theme."""
purple = themes.get_theme("purple")
assert isinstance(purple, themes.Theme)
assert purple.name == "purple"
def test_get_theme_invalid(self):
"""get_theme with invalid ID raises KeyError."""
with pytest.raises(KeyError):
themes.get_theme("invalid_theme")
def test_get_theme_invalid_none(self):
"""get_theme with None raises KeyError."""
with pytest.raises(KeyError):
themes.get_theme(None)
class TestGreenTheme:
"""Tests for green theme specific values."""
def test_green_theme_unchanged(self):
"""Green theme maintains original color sequence."""
green = themes.get_theme("green")
# Expected main gradient: 231→195→123→118→82→46→40→34→28→22→22(dim)→235
expected_main = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
# Expected msg gradient: 231→225→219→213→207→201→165→161→125→89→89(dim)→235
expected_msg = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
assert green.main_gradient == expected_main
assert green.message_gradient == expected_msg
def test_green_theme_name(self):
"""Green theme has correct name."""
green = themes.get_theme("green")
assert green.name == "green"
class TestOrangeTheme:
"""Tests for orange theme specific values."""
def test_orange_theme_unchanged(self):
"""Orange theme maintains original color sequence."""
orange = themes.get_theme("orange")
# Expected main gradient: 231→215→209→208→202→166→130→94→58→94→94(dim)→235
expected_main = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
# Expected msg gradient: 231→195→33→27→21→21→21→18→18→18→18(dim)→235
expected_msg = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
assert orange.main_gradient == expected_main
assert orange.message_gradient == expected_msg
def test_orange_theme_name(self):
"""Orange theme has correct name."""
orange = themes.get_theme("orange")
assert orange.name == "orange"
class TestPurpleTheme:
"""Tests for purple theme specific values."""
def test_purple_theme_unchanged(self):
"""Purple theme maintains original color sequence."""
purple = themes.get_theme("purple")
# Expected main gradient: 231→225→177→171→165→135→129→93→57→57→57(dim)→235
expected_main = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
# Expected msg gradient: 231→226→226→220→220→184→184→178→178→172→172(dim)→235
expected_msg = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
assert purple.main_gradient == expected_main
assert purple.message_gradient == expected_msg
def test_purple_theme_name(self):
"""Purple theme has correct name."""
purple = themes.get_theme("purple")
assert purple.name == "purple"
class TestThemeDataOnly:
"""Tests to ensure themes module has no problematic imports."""
def test_themes_module_imports(self):
"""themes module should be data-only without config/render imports."""
import inspect
source = inspect.getsource(themes)
# Verify no imports of config or render (look for actual import statements)
lines = source.split('\n')
import_lines = [line for line in lines if line.strip().startswith('import ') or line.strip().startswith('from ')]
# Filter out empty and comment lines
import_lines = [line for line in import_lines if line.strip() and not line.strip().startswith('#')]
# Should have no import lines
assert len(import_lines) == 0, f"Found unexpected imports: {import_lines}"