Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
9.1 KiB
Refactor mainline.py into modular package
Problem
mainline.py is a single 1085-line file with ~10 interleaved concerns. This prevents:
- Reusing the ntfy doorbell interrupt in other visualizers
- Importing the render pipeline from
serve.py(future ESP32 HTTP server) - Testing any concern in isolation
- Porting individual layers to Rust independently
Target structure
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
engine/
__init__.py
config.py # constants, CLI flags, glyph tables
sources.py # FEEDS, POETRY_SOURCES, SOURCE_LANGS, _LOCATION_LANGS
terminal.py # ANSI codes, tw/th, type_out, slow_print, boot_ln
filter.py # HTML stripping, content filter (_SKIP_RE)
translate.py # Google Translate wrapper + location→language detection
render.py # OTF font loading, _render_line, _big_wrap, _lr_gradient, _make_block
effects.py # noise, glitch_bar, _fade_line, _vis_trunc, _firehose_line, _next_headline
fetch.py # RSS/Gutenberg fetching, cache load/save
ntfy.py # NtfyPoller class — standalone, zero internal deps
mic.py # MicMonitor class — standalone
scroll.py # stream() frame loop + message rendering
app.py # main(), TITLE art, boot sequence, signal handler
The package is named engine/ to avoid a naming conflict with the mainline.py entrypoint.
Module dependency graph
config ← (nothing)
sources ← (nothing)
terminal ← (nothing)
filter ← (nothing)
translate ← sources
render ← config, terminal, sources
effects ← config, terminal, sources
fetch ← config, sources, filter, terminal
ntfy ← (nothing — stdlib only, fully standalone)
mic ← (nothing — sounddevice only)
scroll ← config, terminal, render, effects, ntfy, mic
app ← everything above
Critical property: ntfy.py and mic.py have zero internal dependencies, making ntfy reusable by any visualizer.
Module details
mainline.py (entrypoint — slimmed down)
Keeps only the venv bootstrap (lines 10-38) which must run before any third-party imports. After bootstrap, delegates to engine.app.main().
engine/config.py
From current mainline.py:
HEADLINE_LIMIT,FEED_TIMEOUT,MIC_THRESHOLD_DB(lines 55-57)MODE,FIREHOSECLI flag parsing (lines 58-59)NTFY_TOPIC,NTFY_POLL_INTERVAL,MESSAGE_DISPLAY_SECS(lines 62-64)_FONT_PATH,_FONT_SZ,_RENDER_H(lines 147-150)_SCROLL_DUR,_FRAME_DT,FIREHOSE_H(lines 505-507)GLITCH,KATAglyph tables (lines 143-144)
engine/sources.py
Pure data, no logic:
FEEDSdict (lines 102-140)POETRY_SOURCESdict (lines 67-80)SOURCE_LANGSdict (lines 258-266)_LOCATION_LANGSdict (lines 269-289)_SCRIPT_FONTSdict (lines 153-165)_NO_UPPERset (line 167)
engine/terminal.py
ANSI primitives and terminal I/O:
- All ANSI constants:
RST,BOLD,DIM,G_HI,G_MID,G_LO,G_DIM,W_COOL,W_DIM,W_GHOST,C_DIM,CLR,CURSOR_OFF,CURSOR_ON(lines 83-99) tw(),th()(lines 223-234)type_out(),slow_print(),boot_ln()(lines 355-386)
engine/filter.py
_StripHTML parser class (lines 205-214)strip_tags()(lines 217-220)_SKIP_REcompiled regex (lines 322-346)_skip()predicate (lines 349-351)
engine/translate.py
_TRANSLATE_CACHE(line 291)_detect_location_language()(lines 294-300) — imports_LOCATION_LANGSfrom sources_translate_headline()(lines 303-319)
engine/render.py
The OTF→terminal pipeline. This is exactly what serve.py will import to produce 1-bit bitmaps for the ESP32.
_GRAD_COLSgradient table (lines 169-182)_font(),_font_for_lang()with lazy-load + cache (lines 185-202)_render_line()— OTF text → half-block terminal rows (lines 567-605)_big_wrap()— word-wrap + render (lines 608-636)_lr_gradient()— apply left→right color gradient (lines 639-656)_make_block()— composite: translate → render → colorize a headline (lines 718-756). Imports from translate, sources.
engine/effects.py
Visual effects applied during the frame loop:
noise()(lines 237-245)glitch_bar()(lines 248-252)_fade_line()— probabilistic character dissolve (lines 659-680)_vis_trunc()— ANSI-aware width truncation (lines 683-701)_firehose_line()(lines 759-801) — imports config.MODE, sources.FEEDS/POETRY_SOURCES_next_headline()— pool management (lines 704-715)
engine/fetch.py
fetch_feed()(lines 390-396)fetch_all()(lines 399-426) — imports filter._skip, filter.strip_tags, terminal.boot_ln_fetch_gutenberg()(lines 429-456)fetch_poetry()(lines 459-472)_cache_path(),_load_cache(),_save_cache()(lines 476-501)
engine/ntfy.py — standalone, reusable
Refactored from the current globals + thread (lines 531-564) and the message rendering section of stream() (lines 845-909) into a class:
class NtfyPoller:
def __init__(self, topic_url, poll_interval=15, display_secs=30):
...
def start(self):
"""Start background polling thread."""
def get_active_message(self):
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
def dismiss(self):
"""Manually dismiss current message."""
Dependencies: urllib.request, json, threading, time — all stdlib. No internal imports.
Other visualizers use it like:
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
poller.start()
# in render loop:
msg = poller.get_active_message()
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
engine/mic.py — standalone
Refactored from the current globals (lines 508-528) into a class:
class MicMonitor:
def __init__(self, threshold_db=50):
...
def start(self) -> bool:
"""Start background mic stream. Returns False if unavailable."""
def stop(self):
...
@property
def db(self) -> float:
"""Current RMS dB level."""
@property
def excess(self) -> float:
"""dB above threshold (clamped to 0)."""
Dependencies: sounddevice, numpy (both optional — graceful fallback).
engine/scroll.py
The stream() function (lines 804-990). Receives its dependencies via arguments or imports:
stream(items, ntfy_poller, mic_monitor, config)or similar- Message rendering (lines 855-909) stays here since it's terminal-display-specific — a different visualizer would render messages differently
engine/app.py
The orchestrator:
TITLEASCII art (lines 994-1001)main()(lines 1004-1084): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
Execution order
Step 1: Create engine/ package skeleton
Create engine/__init__.py and all empty module files.
Step 2: Extract pure data modules (zero-dep)
Move constants and data dicts into config.py, sources.py. These have no logic dependencies.
Step 3: Extract terminal.py
Move ANSI codes and terminal I/O helpers. No internal deps.
Step 4: Extract filter.py and translate.py
Both are small, self-contained. translate imports from sources.
Step 5: Extract render.py
Font loading + the OTF→half-block pipeline. Imports from config, terminal, sources. This is the module serve.py will later import.
Step 6: Extract effects.py
Visual effects. Imports from config, terminal, sources.
Step 7: Extract fetch.py
Feed/Gutenberg fetching + caching. Imports from config, sources, filter, terminal.
Step 8: Extract ntfy.py and mic.py
Refactor globals+threads into classes. Zero internal deps.
Step 9: Extract scroll.py
The frame loop. Last to extract because it depends on everything above.
Step 10: Extract app.py
The main() function, boot sequence, signal handler. Wire up all modules.
Step 11: Slim down mainline.py
Keep only venv bootstrap + from engine.app import main; main().
Step 12: Verify
Run python3 mainline.py, python3 mainline.py --poetry, and python3 mainline.py --firehose to confirm identical behavior. No behavioral changes in this refactor.
What this enables
- serve.py (future):
from engine.render import _render_line, _big_wrap+from engine.fetch import fetch_all— imports the pipeline directly - Other visualizers:
from engine.ntfy import NtfyPoller— doorbell feature with no coupling to mainline's scroll engine - Rust port: Clear boundaries for what to port first (ntfy client, render pipeline) vs what stays in Python (fetching, caching — the server side)
- Testing: Each module can be unit-tested in isolation