#

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
