# 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 ```warp-runnable-command 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 ```warp-runnable-command 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`, `FIREHOSE` CLI 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`, `KATA` glyph tables \(lines 143\-144\) ### engine/sources\.py Pure data, no logic: * `FEEDS` dict \(lines 102\-140\) * `POETRY_SOURCES` dict \(lines 67\-80\) * `SOURCE_LANGS` dict \(lines 258\-266\) * `_LOCATION_LANGS` dict \(lines 269\-289\) * `_SCRIPT_FONTS` dict \(lines 153\-165\) * `_NO_UPPER` set \(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 * `_Strip` HTML parser class \(lines 205\-214\) * `strip_tags()` \(lines 217\-220\) * `_SKIP_RE` compiled regex \(lines 322\-346\) * `_skip()` predicate \(lines 349\-351\) ### engine/translate\.py * `_TRANSLATE_CACHE` \(line 291\) * `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from 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_COLS` gradient 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: ```python 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: ```python 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: ```python 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: * `TITLE` ASCII 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