5 Commits

8 changed files with 695 additions and 0 deletions

178
Refactor mainline.md Normal file
View File

@@ -0,0 +1,178 @@
# 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

1
engine/__init__.py Normal file
View File

@@ -0,0 +1 @@
# engine — modular internals for mainline

31
engine/config.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Configuration constants, CLI flags, and glyph tables.
"""
import sys
# ─── RUNTIME ──────────────────────────────────────────────
HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
FIREHOSE = '--firehose' in sys.argv
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1"
NTFY_POLL_INTERVAL = 15 # seconds between polls
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
# ─── FONT RENDERING ──────────────────────────────────────
FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf"
FONT_SZ = 60
RENDER_H = 8 # terminal rows per rendered text line
# ─── SCROLL / FRAME ──────────────────────────────────────
SCROLL_DUR = 3.75 # seconds per headline
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
FIREHOSE_H = 12 # firehose zone height (terminal rows)
# ─── GLYPHS ───────────────────────────────────────────────
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"

60
engine/filter.py Normal file
View File

@@ -0,0 +1,60 @@
"""
HTML stripping and content filter (sports, vapid, insipid).
No internal dependencies.
"""
import re
from html import unescape
from html.parser import HTMLParser
# ─── HTML STRIPPING ───────────────────────────────────────
class _Strip(HTMLParser):
def __init__(self):
super().__init__()
self._t = []
def handle_data(self, d):
self._t.append(d)
def text(self):
return "".join(self._t).strip()
def strip_tags(html):
s = _Strip()
s.feed(unescape(html or ""))
return s.text()
# ─── CONTENT FILTER ───────────────────────────────────────
_SKIP_RE = re.compile(
r'\b(?:'
# ── sports ──
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
r'hockey|lacrosse|volleyball|badminton|'
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
r'premier league|champions league|la liga|serie a|bundesliga|'
r'world cup|super bowl|world series|stanley cup|'
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
r'slam dunk|home run|grand slam|offside|halftime|'
r'batting|wicket|innings|'
r'formula 1|nascar|motogp|'
r'boxing|ufc|mma|'
r'marathon|tour de france|'
r'transfer window|draft pick|relegation|'
# ── vapid / insipid ──
r'kardashian|jenner|reality tv|reality show|'
r'influencer|viral video|tiktok|instagram|'
r'best dressed|worst dressed|red carpet|'
r'horoscope|zodiac|gossip|bikini|selfie|'
r'you won.t believe|what happened next|'
r'celebrity couple|celebrity feud|baby bump'
r')\b',
re.IGNORECASE
)
def skip(title):
"""Return True if headline is sports, vapid, or insipid."""
return bool(_SKIP_RE.search(title))

191
engine/render.py Normal file
View File

@@ -0,0 +1,191 @@
"""
OTF → terminal half-block rendering pipeline.
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
Depends on: config, terminal, sources, translate.
"""
import re
import random
from PIL import Image, ImageDraw, ImageFont
from engine import config
from engine.terminal import RST, W_COOL
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
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
]
# ─── FONT LOADING ─────────────────────────────────────────
_FONT_OBJ = None
_FONT_CACHE = {}
def font():
"""Lazy-load the primary OTF font."""
global _FONT_OBJ
if _FONT_OBJ is None:
_FONT_OBJ = ImageFont.truetype(config.FONT_PATH, config.FONT_SZ)
return _FONT_OBJ
def font_for_lang(lang=None):
"""Get appropriate font for a language."""
if lang is None or lang not in SCRIPT_FONTS:
return font()
if lang not in _FONT_CACHE:
try:
_FONT_CACHE[lang] = ImageFont.truetype(SCRIPT_FONTS[lang], config.FONT_SZ)
except Exception:
_FONT_CACHE[lang] = font()
return _FONT_CACHE[lang]
# ─── RASTERIZATION ────────────────────────────────────────
def render_line(text, fnt=None):
"""Render a line of text as terminal rows using OTF font + half-blocks."""
if fnt is None:
fnt = font()
bbox = fnt.getbbox(text)
if not bbox or bbox[2] <= bbox[0]:
return [""]
pad = 4
img_w = bbox[2] - bbox[0] + pad * 2
img_h = bbox[3] - bbox[1] + pad * 2
img = Image.new('L', (img_w, img_h), 0)
draw = ImageDraw.Draw(img)
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
pix_h = config.RENDER_H * 2
scale = pix_h / max(img_h, 1)
new_w = max(1, int(img_w * scale))
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
data = img.tobytes()
thr = 80
rows = []
for y in range(0, pix_h, 2):
row = []
for x in range(new_w):
top = data[y * new_w + x] > thr
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
while rows and not rows[-1].strip():
rows.pop()
while rows and not rows[0].strip():
rows.pop(0)
return rows if rows else [""]
def big_wrap(text, max_w, fnt=None):
"""Word-wrap text and render with OTF font."""
if fnt is None:
fnt = font()
words = text.split()
lines, cur = [], ""
for word in words:
test = f"{cur} {word}".strip() if cur else word
bbox = fnt.getbbox(test)
if bbox:
img_h = bbox[3] - bbox[1] + 8
pix_h = config.RENDER_H * 2
scale = pix_h / max(img_h, 1)
term_w = int((bbox[2] - bbox[0] + 8) * scale)
else:
term_w = 0
if term_w > max_w - 4 and cur:
lines.append(cur)
cur = word
else:
cur = test
if cur:
lines.append(cur)
out = []
for i, ln in enumerate(lines):
out.extend(render_line(ln, fnt))
if i < len(lines) - 1:
out.append("")
return out
def lr_gradient(rows):
"""Color each non-space block character with a left-to-right gradient."""
n = len(GRAD_COLS)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = []
for row in rows:
if not row.strip():
out.append(row)
continue
buf = []
for x, ch in enumerate(row):
if ch == ' ':
buf.append(' ')
else:
idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1)
buf.append(f"{GRAD_COLS[idx]}{ch}\033[0m")
out.append("".join(buf))
return out
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
def make_block(title, src, ts, w):
"""Render a headline into a content block with color."""
target_lang = (SOURCE_LANGS.get(src) or detect_location_language(title)) if config.MODE == 'news' else None
lang_font = font_for_lang(target_lang)
if target_lang:
title = translate_headline(title, target_lang)
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
if target_lang and target_lang in NO_UPPER:
title_up = re.sub(r"\s+", " ", title)
else:
title_up = re.sub(r"\s+", " ", title.upper())
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
title_up = title_up.replace(old, new)
big_rows = big_wrap(title_up, w - 4, lang_font)
big_rows = lr_gradient(big_rows)
hc = random.choice([
"\033[38;5;46m", # matrix green
"\033[38;5;34m", # dark green
"\033[38;5;82m", # lime
"\033[38;5;48m", # sea green
"\033[38;5;37m", # teal
"\033[38;5;44m", # cyan
"\033[38;5;87m", # sky
"\033[38;5;117m", # ice blue
"\033[38;5;250m", # cool white
"\033[38;5;156m", # pale green
"\033[38;5;120m", # mint
"\033[38;5;80m", # dark cyan
"\033[38;5;108m", # grey-green
"\033[38;5;115m", # sage
"\033[1;38;5;46m", # bold green
"\033[1;38;5;250m", # bold white
])
content = [" " + r for r in big_rows]
content.append("")
meta = f"\u2591 {src} \u00b7 {ts}"
content.append(" " * max(2, w - len(meta) - 2) + meta)
return content, hc, len(content) - 1 # (rows, color, meta_row_index)

115
engine/sources.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Data sources: feed URLs, poetry sources, language mappings, script fonts.
Pure data — no logic, no dependencies.
"""
# ─── RSS FEEDS ────────────────────────────────────────────
FEEDS = {
# Science & Technology
"Nature": "https://www.nature.com/nature.rss",
"Science Daily": "https://www.sciencedaily.com/rss/all.xml",
"Phys.org": "https://phys.org/rss-feed/",
"NASA": "https://www.nasa.gov/news-release/feed/",
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
"New Scientist": "https://www.newscientist.com/section/news/feed/",
"Quanta": "https://api.quantamagazine.org/feed/",
"BBC Science": "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
"MIT Tech Review": "https://www.technologyreview.com/feed/",
# Economics & Business
"BBC Business": "http://feeds.bbci.co.uk/news/business/rss.xml",
"MarketWatch": "https://feeds.marketwatch.com/marketwatch/topstories/",
"Economist": "https://www.economist.com/finance-and-economics/rss.xml",
# World & Politics
"BBC World": "http://feeds.bbci.co.uk/news/world/rss.xml",
"NPR": "https://feeds.npr.org/1001/rss.xml",
"Al Jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
"Guardian World": "https://www.theguardian.com/world/rss",
"DW": "https://rss.dw.com/rdf/rss-en-all",
"France24": "https://www.france24.com/en/rss",
"ABC Australia": "https://www.abc.net.au/news/feed/2942460/rss.xml",
"Japan Times": "https://www.japantimes.co.jp/feed/",
"The Hindu": "https://www.thehindu.com/news/national/feeder/default.rss",
"SCMP": "https://www.scmp.com/rss/91/feed",
"Der Spiegel": "https://www.spiegel.de/international/index.rss",
# Culture & Ideas
"Guardian Culture": "https://www.theguardian.com/culture/rss",
"Aeon": "https://aeon.co/feed.rss",
"Smithsonian": "https://www.smithsonianmag.com/rss/latest_articles/",
"The Marginalian": "https://www.themarginalian.org/feed/",
"Nautilus": "https://nautil.us/feed/",
"Wired": "https://www.wired.com/feed/rss",
"The Conversation": "https://theconversation.com/us/articles.atom",
"Longreads": "https://longreads.com/feed/",
"Literary Hub": "https://lithub.com/feed/",
"Atlas Obscura": "https://www.atlasobscura.com/feeds/latest",
}
# ─── POETRY / LITERATURE ─────────────────────────────────
# Public domain via Project Gutenberg
POETRY_SOURCES = {
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
}
# ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
# Headlines from these outlets render in their cultural home language
SOURCE_LANGS = {
"Der Spiegel": "de",
"DW": "de",
"France24": "fr",
"Japan Times": "ja",
"The Hindu": "hi",
"SCMP": "zh-cn",
"Al Jazeera": "ar",
}
# ─── LOCATION → LANGUAGE ─────────────────────────────────
LOCATION_LANGS = {
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
r'\b(?:spain|spanish|madrid)\b': 'es',
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
r'\b(?:greece|greek|athens)\b': 'el',
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
r'\b(?:iran|iranian|tehran)\b': 'fa',
r'\b(?:thailand|thai|bangkok)\b': 'th',
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
}
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
SCRIPT_FONTS = {
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
'ar': '/System/Library/Fonts/GeezaPro.ttc',
'fa': '/System/Library/Fonts/GeezaPro.ttc',
'hi': '/System/Library/Fonts/Kohinoor.ttc',
'th': '/System/Library/Fonts/ThonburiUI.ttc',
}
# Scripts that have no uppercase
NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}

78
engine/terminal.py Normal file
View File

@@ -0,0 +1,78 @@
"""
ANSI escape codes, terminal size helpers, and text output primitives.
No internal dependencies.
"""
import os
import sys
import random
import time
# ─── ANSI ─────────────────────────────────────────────────
RST = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
# Matrix greens
G_HI = "\033[38;5;46m"
G_MID = "\033[38;5;34m"
G_LO = "\033[38;5;22m"
G_DIM = "\033[2;38;5;34m"
# THX-1138 sterile tones
W_COOL = "\033[38;5;250m"
W_DIM = "\033[2;38;5;245m"
W_GHOST = "\033[2;38;5;238m"
C_DIM = "\033[2;38;5;37m"
# Terminal control
CLR = "\033[2J\033[H"
CURSOR_OFF = "\033[?25l"
CURSOR_ON = "\033[?25h"
# ─── TERMINAL SIZE ────────────────────────────────────────
def tw():
try:
return os.get_terminal_size().columns
except Exception:
return 80
def th():
try:
return os.get_terminal_size().lines
except Exception:
return 24
# ─── TEXT OUTPUT ──────────────────────────────────────────
def type_out(text, color=G_HI):
i = 0
while i < len(text):
if random.random() < 0.3:
b = random.randint(2, 5)
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
i += b
else:
sys.stdout.write(f"{color}{text[i]}{RST}")
i += 1
sys.stdout.flush()
time.sleep(random.uniform(0.004, 0.018))
def slow_print(text, color=G_DIM, delay=0.015):
for ch in text:
sys.stdout.write(f"{color}{ch}{RST}")
sys.stdout.flush()
time.sleep(delay)
def boot_ln(label, status, ok=True):
dots = max(3, min(30, tw() - len(label) - len(status) - 8))
sys.stdout.write(f" {G_DIM}>{RST} {W_DIM}{label} ")
sys.stdout.flush()
for _ in range(dots):
sys.stdout.write(f"{G_LO}.")
sys.stdout.flush()
time.sleep(random.uniform(0.006, 0.025))
c = G_MID if ok else "\033[2;38;5;196m"
print(f" {c}{status}{RST}")
time.sleep(random.uniform(0.02, 0.1))

41
engine/translate.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Google Translate wrapper and location→language detection.
Depends on: sources (for LOCATION_LANGS).
"""
import re
import json
import urllib.request
import urllib.parse
from engine.sources import LOCATION_LANGS
_TRANSLATE_CACHE = {}
def detect_location_language(title):
"""Detect if headline mentions a location, return target language."""
title_lower = title.lower()
for pattern, lang in LOCATION_LANGS.items():
if re.search(pattern, title_lower):
return lang
return None
def translate_headline(title, target_lang):
"""Translate headline via Google Translate API (zero dependencies)."""
key = (title, target_lang)
if key in _TRANSLATE_CACHE:
return _TRANSLATE_CACHE[key]
try:
q = urllib.parse.quote(title)
url = ("https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read())
result = "".join(p[0] for p in data[0] if p[0]) or title
except Exception:
result = title
_TRANSLATE_CACHE[key] = result
return result