Compare commits
20 Commits
feat/scala
...
9ae4dc2b07
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ae4dc2b07 | |||
| 1ac2dec3b0 | |||
| 757c854584 | |||
| 4844a64203 | |||
| 9201117096 | |||
| d758541156 | |||
| b979621dd4 | |||
| f91cc9844e | |||
| bddbd69371 | |||
| 6e39a2dad2 | |||
| 1ba3848bed | |||
| a986df344a | |||
| c84bd5c05a | |||
| 7b0f886e53 | |||
| 9eeb817dca | |||
| ac80ab23cc | |||
| 516123345e | |||
| 11226872a1 | |||
| e6826c884c | |||
| 0740e34293 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,11 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.mainline_venv/
|
||||
.venv/
|
||||
uv.lock
|
||||
.mainline_cache_*.json
|
||||
.DS_Store
|
||||
htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
27
README.md
27
README.md
@@ -14,6 +14,10 @@ python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||
python3 mainline.py --font-file path.otf # use a specific font file
|
||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||
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.
|
||||
@@ -29,7 +33,10 @@ All constants live in `engine/config.py`:
|
||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||
| `FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
|
||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||
@@ -41,15 +48,24 @@ All constants live in `engine/config.py`:
|
||||
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
|
||||
**Font:** `FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well.
|
||||
---
|
||||
|
||||
## Fonts
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- 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
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black; the gradient sweeps continuously across the full scroll canvas
|
||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||
- 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 viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||
@@ -74,7 +90,7 @@ engine/
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
app.py main(), boot sequence, signal handler
|
||||
app.py main(), font picker TUI, boot sequence, signal handler
|
||||
```
|
||||
|
||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||
@@ -138,4 +154,5 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
||||
|
||||
---
|
||||
|
||||
*macOS only (system font paths hardcoded). 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.9+.*
|
||||
# test
|
||||
|
||||
270
engine/app.py
270
engine/app.py
@@ -2,20 +2,33 @@
|
||||
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import atexit
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
|
||||
from engine import config
|
||||
from engine.terminal import (
|
||||
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
||||
slow_print, boot_ln,
|
||||
)
|
||||
from engine import config, render
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
from engine.terminal import (
|
||||
CLR,
|
||||
CURSOR_OFF,
|
||||
CURSOR_ON,
|
||||
G_DIM,
|
||||
G_HI,
|
||||
G_MID,
|
||||
RST,
|
||||
W_DIM,
|
||||
W_GHOST,
|
||||
boot_ln,
|
||||
slow_print,
|
||||
tw,
|
||||
)
|
||||
|
||||
TITLE = [
|
||||
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||
@@ -27,6 +40,215 @@ TITLE = [
|
||||
]
|
||||
|
||||
|
||||
def _read_picker_key():
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x03":
|
||||
return "interrupt"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
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
|
||||
if ch in ("k", "K"):
|
||||
return "up"
|
||||
if ch in ("j", "J"):
|
||||
return "down"
|
||||
if ch in ("q", "Q"):
|
||||
return "enter"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_preview_rows(rows):
|
||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||
non_empty = [r for r in rows if r.strip()]
|
||||
if not non_empty:
|
||||
return [""]
|
||||
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
||||
out = []
|
||||
for row in rows:
|
||||
if left_pad < len(row):
|
||||
out.append(row[left_pad:].rstrip())
|
||||
else:
|
||||
out.append(row.rstrip())
|
||||
return out
|
||||
|
||||
|
||||
def _draw_font_picker(faces, selected):
|
||||
w = tw()
|
||||
h = 24
|
||||
try:
|
||||
h = os.get_terminal_size().lines
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_preview_w = max(24, w - 8)
|
||||
header_h = 6
|
||||
footer_h = 3
|
||||
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
||||
visible = max(1, h - header_h - preview_h - footer_h)
|
||||
top = max(0, selected - (visible // 2))
|
||||
bottom = min(len(faces), top + visible)
|
||||
top = max(0, bottom - visible)
|
||||
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
||||
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
||||
print()
|
||||
|
||||
for pos in range(top, bottom):
|
||||
face = faces[pos]
|
||||
active = pos == selected
|
||||
pointer = "▶" if active else " "
|
||||
color = G_HI if active else W_DIM
|
||||
print(
|
||||
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
|
||||
)
|
||||
|
||||
if top > 0:
|
||||
print(f" {W_GHOST}… {top} above{RST}")
|
||||
if bottom < len(faces):
|
||||
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print(
|
||||
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
||||
)
|
||||
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
||||
for row in preview_rows:
|
||||
shown = row[:max_preview_w]
|
||||
print(f" {shown}")
|
||||
|
||||
|
||||
def pick_font_face():
|
||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||
if not config.FONT_PICKER:
|
||||
return
|
||||
|
||||
font_files = config.list_repo_font_files()
|
||||
if not font_files:
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
||||
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
||||
time.sleep(1.8)
|
||||
sys.exit(1)
|
||||
|
||||
prepared = []
|
||||
for font_path in font_files:
|
||||
try:
|
||||
faces = render.list_font_faces(font_path, max_faces=64)
|
||||
except Exception:
|
||||
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
||||
faces = [{"index": 0, "name": fallback}]
|
||||
for face in faces:
|
||||
idx = face["index"]
|
||||
name = face["name"]
|
||||
file_name = os.path.basename(font_path)
|
||||
try:
|
||||
fnt = render.load_font_face(font_path, idx)
|
||||
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
||||
except Exception:
|
||||
rows = ["(preview unavailable)"]
|
||||
prepared.append(
|
||||
{
|
||||
"font_path": font_path,
|
||||
"font_index": idx,
|
||||
"name": name,
|
||||
"file_name": file_name,
|
||||
"preview_rows": rows,
|
||||
}
|
||||
)
|
||||
|
||||
if not prepared:
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
||||
time.sleep(1.8)
|
||||
sys.exit(1)
|
||||
|
||||
def _same_path(a, b):
|
||||
try:
|
||||
return os.path.samefile(a, b)
|
||||
except Exception:
|
||||
return os.path.abspath(a) == os.path.abspath(b)
|
||||
|
||||
selected = next(
|
||||
(
|
||||
i
|
||||
for i, f in enumerate(prepared)
|
||||
if _same_path(f["font_path"], config.FONT_PATH)
|
||||
and f["font_index"] == config.FONT_INDEX
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
selected_font = prepared[selected]
|
||||
config.set_font_selection(
|
||||
font_path=selected_font["font_path"],
|
||||
font_index=selected_font["font_index"],
|
||||
)
|
||||
render.clear_font_cache()
|
||||
print(
|
||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
return
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_font_picker(prepared, selected)
|
||||
key = _read_picker_key()
|
||||
if key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
elif key == "down":
|
||||
selected = min(len(prepared) - 1, selected + 1)
|
||||
elif key == "enter":
|
||||
break
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
selected_font = prepared[selected]
|
||||
config.set_font_selection(
|
||||
font_path=selected_font["font_path"],
|
||||
font_index=selected_font["font_index"],
|
||||
)
|
||||
render.clear_font_cache()
|
||||
print(
|
||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
|
||||
@@ -40,6 +262,8 @@ def main():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
@@ -48,23 +272,29 @@ def main():
|
||||
time.sleep(0.07)
|
||||
|
||||
print()
|
||||
_subtitle = "literary consciousness stream" if config.MODE == 'poetry' else "digital consciousness stream"
|
||||
_subtitle = (
|
||||
"literary consciousness stream"
|
||||
if config.MODE == "poetry"
|
||||
else "digital consciousness stream"
|
||||
)
|
||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
cached = load_cache() if '--refresh' not in sys.argv else None
|
||||
cached = load_cache() if "--refresh" not in sys.argv else None
|
||||
if cached:
|
||||
items = cached
|
||||
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||
elif config.MODE == 'poetry':
|
||||
elif config.MODE == "poetry":
|
||||
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, linked, failed = fetch_poetry()
|
||||
print()
|
||||
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}")
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
else:
|
||||
@@ -73,7 +303,9 @@ def main():
|
||||
print()
|
||||
items, linked, failed = fetch_all()
|
||||
print()
|
||||
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}")
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
|
||||
@@ -85,11 +317,17 @@ def main():
|
||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||
mic_ok = mic.start()
|
||||
if mic.available:
|
||||
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", bool(mic_ok))
|
||||
boot_ln(
|
||||
"Microphone",
|
||||
"ACTIVE"
|
||||
if mic_ok
|
||||
else "OFFLINE · check System Settings → Privacy → Microphone",
|
||||
bool(mic_ok),
|
||||
)
|
||||
|
||||
ntfy = NtfyPoller(
|
||||
config.NTFY_TOPIC,
|
||||
poll_interval=config.NTFY_POLL_INTERVAL,
|
||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||
)
|
||||
ntfy_ok = ntfy.start()
|
||||
|
||||
@@ -3,33 +3,100 @@ Configuration constants, CLI flags, and glyph tables.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||
|
||||
|
||||
def _arg_value(flag):
|
||||
"""Get value following a CLI flag, if present."""
|
||||
if flag not in sys.argv:
|
||||
return None
|
||||
i = sys.argv.index(flag)
|
||||
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||
|
||||
|
||||
def _arg_int(flag, default):
|
||||
"""Get int CLI argument with safe fallback."""
|
||||
raw = _arg_value(flag)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _resolve_font_path(raw_path):
|
||||
"""Resolve font path; relative paths are anchored to repo root."""
|
||||
p = Path(raw_path).expanduser()
|
||||
if p.is_absolute():
|
||||
return str(p)
|
||||
return str((_REPO_ROOT / p).resolve())
|
||||
|
||||
|
||||
def _list_font_files(font_dir):
|
||||
"""List supported font files within a font directory."""
|
||||
font_root = Path(font_dir)
|
||||
if not font_root.exists() or not font_root.is_dir():
|
||||
return []
|
||||
return [
|
||||
str(path.resolve())
|
||||
for path in sorted(font_root.iterdir())
|
||||
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS
|
||||
]
|
||||
|
||||
|
||||
def list_repo_font_files():
|
||||
"""Public helper for discovering repository font files."""
|
||||
return _list_font_files(FONT_DIR)
|
||||
|
||||
|
||||
# ─── 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
|
||||
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
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||
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_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts")
|
||||
_FONT_FILE_ARG = _arg_value("--font-file")
|
||||
_FONT_FILES = _list_font_files(FONT_DIR)
|
||||
FONT_PATH = (
|
||||
_resolve_font_path(_FONT_FILE_ARG)
|
||||
if _FONT_FILE_ARG
|
||||
else (_FONT_FILES[0] if _FONT_FILES else "")
|
||||
)
|
||||
FONT_INDEX = max(0, _arg_int("--font-index", 0))
|
||||
FONT_PICKER = "--no-font-picker" not in sys.argv
|
||||
FONT_SZ = 60
|
||||
RENDER_H = 8 # terminal rows per rendered text line
|
||||
RENDER_H = 8 # terminal rows per rendered text line
|
||||
|
||||
# ─── FONT RENDERING (ADVANCED) ────────────────────────────
|
||||
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
||||
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
||||
|
||||
# ─── SCROLL / FRAME ──────────────────────────────────────
|
||||
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||
|
||||
# ─── GLYPHS ───────────────────────────────────────────────
|
||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
|
||||
def set_font_selection(font_path=None, font_index=None):
|
||||
"""Set runtime primary font selection."""
|
||||
global FONT_PATH, FONT_INDEX
|
||||
if font_path is not None:
|
||||
FONT_PATH = _resolve_font_path(font_path)
|
||||
if font_index is not None:
|
||||
FONT_INDEX = max(0, int(font_index))
|
||||
|
||||
@@ -7,8 +7,8 @@ import random
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.terminal import RST, DIM, G_LO, G_DIM, W_GHOST, C_DIM
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
def noise(w):
|
||||
@@ -34,23 +34,23 @@ def fade_line(s, fade):
|
||||
if fade >= 1.0:
|
||||
return s
|
||||
if fade <= 0.0:
|
||||
return ''
|
||||
return ""
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
result.append(s[i:j + 1])
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
elif s[i] == ' ':
|
||||
result.append(' ')
|
||||
elif s[i] == " ":
|
||||
result.append(" ")
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i] if random.random() < fade else ' ')
|
||||
result.append(s[i] if random.random() < fade else " ")
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def vis_trunc(s, w):
|
||||
@@ -61,17 +61,17 @@ def vis_trunc(s, w):
|
||||
while i < len(s):
|
||||
if vw >= w:
|
||||
break
|
||||
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
result.append(s[i:j + 1])
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
else:
|
||||
result.append(s[i])
|
||||
vw += 1
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def next_headline(pool, items, seen):
|
||||
@@ -94,7 +94,7 @@ def firehose_line(items, w):
|
||||
if r < 0.35:
|
||||
# Raw headline text
|
||||
title, src, ts = random.choice(items)
|
||||
text = title[:w - 1]
|
||||
text = title[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||
return f"{color}{text}{RST}"
|
||||
elif r < 0.55:
|
||||
@@ -103,12 +103,13 @@ def firehose_line(items, w):
|
||||
return "".join(
|
||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||
if random.random() < d else " "
|
||||
if random.random() < d
|
||||
else " "
|
||||
for _ in range(w)
|
||||
)
|
||||
elif r < 0.78:
|
||||
# Status / program output
|
||||
sources = FEEDS if config.MODE == 'news' else POETRY_SOURCES
|
||||
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||
src = random.choice(list(sources.keys()))
|
||||
msgs = [
|
||||
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||
@@ -118,16 +119,16 @@ def firehose_line(items, w):
|
||||
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||
]
|
||||
text = random.choice(msgs)[:w - 1]
|
||||
text = random.choice(msgs)[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
else:
|
||||
# Headline fragment with glitch prefix
|
||||
title, _, _ = random.choice(items)
|
||||
start = random.randint(0, max(0, len(title) - 20))
|
||||
frag = title[start:start + random.randint(10, 35)]
|
||||
frag = title[start : start + random.randint(10, 35)]
|
||||
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||
gp = ''.join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
||||
text = (' ' * pad + gp + ' ' + frag)[:w - 1]
|
||||
gp = "".join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
||||
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
|
||||
@@ -3,19 +3,20 @@ RSS feed fetching, Project Gutenberg parsing, and headline caching.
|
||||
Depends on: config, sources, filter, terminal.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
import feedparser
|
||||
|
||||
from engine import config
|
||||
from engine.filter import skip, strip_tags
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.filter import strip_tags, skip
|
||||
from engine.terminal import boot_ln
|
||||
|
||||
|
||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||
def fetch_feed(url):
|
||||
try:
|
||||
@@ -63,26 +64,31 @@ def _fetch_gutenberg(url, label):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
resp = urllib.request.urlopen(req, timeout=15)
|
||||
text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
|
||||
text = (
|
||||
resp.read()
|
||||
.decode("utf-8", errors="replace")
|
||||
.replace("\r\n", "\n")
|
||||
.replace("\r", "\n")
|
||||
)
|
||||
# Strip PG boilerplate
|
||||
m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text)
|
||||
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||
if m:
|
||||
text = text[m.end():]
|
||||
m = re.search(r'\*\*\*\s*END OF', text)
|
||||
text = text[m.end() :]
|
||||
m = re.search(r"\*\*\*\s*END OF", text)
|
||||
if m:
|
||||
text = text[:m.start()]
|
||||
text = text[: m.start()]
|
||||
# Split on blank lines into stanzas/passages
|
||||
blocks = re.split(r'\n{2,}', text.strip())
|
||||
blocks = re.split(r"\n{2,}", text.strip())
|
||||
items = []
|
||||
for blk in blocks:
|
||||
blk = ' '.join(blk.split()) # flatten to one line
|
||||
blk = " ".join(blk.split()) # flatten to one line
|
||||
if len(blk) < 20 or len(blk) > 280:
|
||||
continue
|
||||
if blk.isupper(): # skip all-caps headers
|
||||
if blk.isupper(): # skip all-caps headers
|
||||
continue
|
||||
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals
|
||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
||||
continue
|
||||
items.append((blk, label, ''))
|
||||
items.append((blk, label, ""))
|
||||
return items
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -29,29 +29,29 @@ def strip_tags(html):
|
||||
|
||||
# ─── CONTENT FILTER ───────────────────────────────────────
|
||||
_SKIP_RE = re.compile(
|
||||
r'\b(?:'
|
||||
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|'
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ Gracefully degrades if sounddevice/numpy are unavailable.
|
||||
import atexit
|
||||
|
||||
try:
|
||||
import sounddevice as _sd
|
||||
import numpy as _np
|
||||
import sounddevice as _sd
|
||||
|
||||
_HAS_MIC = True
|
||||
except Exception:
|
||||
_HAS_MIC = False
|
||||
@@ -40,12 +41,15 @@ class MicMonitor:
|
||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||
if not _HAS_MIC:
|
||||
return None
|
||||
|
||||
def _cb(indata, frames, t, status):
|
||||
rms = float(_np.sqrt(_np.mean(indata ** 2)))
|
||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||
|
||||
try:
|
||||
self._stream = _sd.InputStream(
|
||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
|
||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
||||
)
|
||||
self._stream.start()
|
||||
atexit.register(self.stop)
|
||||
return True
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
ntfy.sh message poller — standalone, zero internal dependencies.
|
||||
ntfy.sh SSE stream listener — standalone, zero internal dependencies.
|
||||
Reusable by any visualizer:
|
||||
|
||||
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()
|
||||
# in render loop:
|
||||
msg = poller.get_active_message()
|
||||
@@ -13,24 +13,25 @@ Reusable by any visualizer:
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
|
||||
class NtfyPoller:
|
||||
"""Background poller for ntfy.sh topics."""
|
||||
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||
|
||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||
def __init__(self, topic_url, reconnect_delay=5, display_secs=30):
|
||||
self.topic_url = topic_url
|
||||
self.poll_interval = poll_interval
|
||||
self.reconnect_delay = reconnect_delay
|
||||
self.display_secs = display_secs
|
||||
self._message = None # (title, body, monotonic_timestamp) or None
|
||||
self._message = None # (title, body, monotonic_timestamp) or None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
"""Start background polling thread. Returns True."""
|
||||
t = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
"""Start background stream thread. Returns True."""
|
||||
t = threading.Thread(target=self._stream_loop, daemon=True)
|
||||
t.start()
|
||||
return True
|
||||
|
||||
@@ -50,19 +51,36 @@ class NtfyPoller:
|
||||
with self._lock:
|
||||
self._message = None
|
||||
|
||||
def _poll_loop(self):
|
||||
def _build_url(self, last_id=None):
|
||||
"""Build the stream URL, substituting since= to avoid message replays on reconnect."""
|
||||
parsed = urlparse(self.topic_url)
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
params["since"] = [last_id if last_id else "20s"]
|
||||
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
def _stream_loop(self):
|
||||
last_id = None
|
||||
while True:
|
||||
try:
|
||||
url = self._build_url(last_id)
|
||||
req = urllib.request.Request(
|
||||
self.topic_url, headers={"User-Agent": "mainline/0.1"})
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'):
|
||||
if not line.strip():
|
||||
continue
|
||||
url, headers={"User-Agent": "mainline/0.1"}
|
||||
)
|
||||
# timeout=90 keeps the socket alive through ntfy.sh keepalive heartbeats
|
||||
resp = urllib.request.urlopen(req, timeout=90)
|
||||
while True:
|
||||
line = resp.readline()
|
||||
if not line:
|
||||
break # server closed connection — reconnect
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# Advance cursor on every event (message + keepalive) to
|
||||
# avoid replaying already-seen events after a reconnect.
|
||||
if "id" in data:
|
||||
last_id = data["id"]
|
||||
if data.get("event") == "message":
|
||||
with self._lock:
|
||||
self._message = (
|
||||
@@ -72,4 +90,4 @@ class NtfyPoller:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(self.poll_interval)
|
||||
time.sleep(self.reconnect_delay)
|
||||
|
||||
149
engine/render.py
149
engine/render.py
@@ -4,14 +4,15 @@ Font loading, text rasterization, word-wrap, gradient coloring, headline block a
|
||||
Depends on: config, terminal, sources, translate.
|
||||
"""
|
||||
|
||||
import re
|
||||
import random
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from engine import config
|
||||
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||
from engine.terminal import RST
|
||||
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
|
||||
from engine.translate import detect_location_language, translate_headline
|
||||
|
||||
# ─── GRADIENT ─────────────────────────────────────────────
|
||||
@@ -19,15 +20,15 @@ from engine.translate import detect_location_language, translate_headline
|
||||
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[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
|
||||
]
|
||||
|
||||
@@ -35,31 +36,71 @@ GRAD_COLS = [
|
||||
MSG_GRAD_COLS = [
|
||||
"\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[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_OBJ = None
|
||||
_FONT_OBJ_KEY = 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)
|
||||
"""Lazy-load the primary OTF font (path + face index aware)."""
|
||||
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||
if not config.FONT_PATH:
|
||||
raise FileNotFoundError(
|
||||
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
||||
)
|
||||
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
||||
if _FONT_OBJ is None or key != _FONT_OBJ_KEY:
|
||||
_FONT_OBJ = ImageFont.truetype(
|
||||
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
||||
)
|
||||
_FONT_OBJ_KEY = key
|
||||
return _FONT_OBJ
|
||||
|
||||
|
||||
def clear_font_cache():
|
||||
"""Reset cached font objects after changing primary font selection."""
|
||||
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||
_FONT_OBJ = None
|
||||
_FONT_OBJ_KEY = None
|
||||
|
||||
|
||||
def load_font_face(font_path, font_index=0, size=None):
|
||||
"""Load a specific face from a font file or collection."""
|
||||
font_size = size or config.FONT_SZ
|
||||
return ImageFont.truetype(font_path, font_size, index=font_index)
|
||||
|
||||
|
||||
def list_font_faces(font_path, max_faces=64):
|
||||
"""Return discoverable face indexes + display names from a font file."""
|
||||
faces = []
|
||||
for idx in range(max_faces):
|
||||
try:
|
||||
fnt = load_font_face(font_path, idx)
|
||||
except Exception:
|
||||
if idx == 0:
|
||||
raise
|
||||
break
|
||||
family, style = fnt.getname()
|
||||
display = f"{family} {style}".strip()
|
||||
if not display:
|
||||
display = f"{Path(font_path).stem} [{idx}]"
|
||||
faces.append({"index": idx, "name": display})
|
||||
return faces
|
||||
|
||||
|
||||
def font_for_lang(lang=None):
|
||||
"""Get appropriate font for a language."""
|
||||
if lang is None or lang not in SCRIPT_FONTS:
|
||||
@@ -83,7 +124,7 @@ def render_line(text, fnt=None):
|
||||
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)
|
||||
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
|
||||
@@ -160,8 +201,8 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
continue
|
||||
buf = []
|
||||
for x, ch in enumerate(row):
|
||||
if ch == ' ':
|
||||
buf.append(' ')
|
||||
if ch == " ":
|
||||
buf.append(" ")
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
idx = min(round(shifted * (n - 1)), n - 1)
|
||||
@@ -178,7 +219,11 @@ def lr_gradient_opposite(rows, offset=0.0):
|
||||
# ─── 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
|
||||
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)
|
||||
@@ -187,28 +232,36 @@ def make_block(title, src, ts, w):
|
||||
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","-")]:
|
||||
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)
|
||||
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
|
||||
])
|
||||
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}"
|
||||
|
||||
@@ -3,16 +3,23 @@ Render engine — ticker content, scroll motion, message panel, and firehose ove
|
||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.terminal import RST, W_COOL, CLR, tw, th
|
||||
from engine.effects import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||
from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line
|
||||
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
@@ -28,8 +35,8 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||
|
||||
# Taxonomy:
|
||||
@@ -39,8 +46,10 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
# - firehose: fixed carriage-return style strip pinned at bottom
|
||||
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
|
||||
active = []
|
||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||
ticker_next_y = (
|
||||
ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||
)
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
|
||||
@@ -50,9 +59,9 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
return noise_cache[cy]
|
||||
|
||||
# Message color: bright cyan/white — distinct from headline greens
|
||||
MSG_META = "\033[38;5;245m" # cool grey
|
||||
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||
MSG_META = "\033[38;5;245m" # cool grey
|
||||
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||
|
||||
while queued < config.HEADLINE_LIMIT or active:
|
||||
t0 = time.monotonic()
|
||||
@@ -77,7 +86,9 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
_msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
msg_rows = _msg_cache[1]
|
||||
msg_rows = lr_gradient_opposite(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||
msg_rows = lr_gradient_opposite(
|
||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
)
|
||||
# Layout: rendered text + meta + border
|
||||
elapsed_s = int(time.monotonic() - m_ts)
|
||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||
@@ -87,19 +98,29 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Meta line: title (if distinct) + source + countdown
|
||||
meta_parts = []
|
||||
if m_title and m_title != m_body:
|
||||
meta_parts.append(m_title)
|
||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||
meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
|
||||
meta = (
|
||||
" " + " \u00b7 ".join(meta_parts)
|
||||
if len(meta_parts) > 1
|
||||
else " " + meta_parts[0]
|
||||
)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Border — constant boundary under message panel
|
||||
bar = "\u2500" * (w - 4)
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
|
||||
)
|
||||
|
||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||
ticker_h = ticker_view_h - msg_h
|
||||
@@ -111,7 +132,10 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
scroll_cam += 1
|
||||
|
||||
# Enqueue new headlines when room at the bottom
|
||||
while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
|
||||
while (
|
||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||
@@ -119,8 +143,9 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
queued += 1
|
||||
|
||||
# Prune off-screen blocks and stale noise
|
||||
active = [(c, hc, by, mi) for c, hc, by, mi in active
|
||||
if by + len(c) > scroll_cam]
|
||||
active = [
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
@@ -47,69 +47,69 @@ FEEDS = {
|
||||
# ─── 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": "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",
|
||||
"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",
|
||||
"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",
|
||||
"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',
|
||||
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',
|
||||
"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'}
|
||||
NO_UPPER = {"zh-cn", "ja", "ko", "ar", "fa", "hi", "th", "he"}
|
||||
|
||||
@@ -4,8 +4,8 @@ No internal dependencies.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
# ─── ANSI ─────────────────────────────────────────────────
|
||||
@@ -49,7 +49,7 @@ def type_out(text, color=G_HI):
|
||||
while i < len(text):
|
||||
if random.random() < 0.3:
|
||||
b = random.randint(2, 5)
|
||||
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
|
||||
sys.stdout.write(f"{color}{text[i : i + b]}{RST}")
|
||||
i += b
|
||||
else:
|
||||
sys.stdout.write(f"{color}{text[i]}{RST}")
|
||||
|
||||
@@ -3,10 +3,10 @@ Google Translate wrapper and location→language detection.
|
||||
Depends on: sources (for LOCATION_LANGS).
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import urllib.request
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from engine.sources import LOCATION_LANGS
|
||||
|
||||
@@ -29,8 +29,10 @@ def translate_headline(title, target_lang):
|
||||
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}")
|
||||
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())
|
||||
|
||||
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/AlphatronDemo-Display.otf
Normal file
BIN
fonts/AlphatronDemo-Display.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/Corptic DEMO.otf
Normal file
BIN
fonts/Corptic DEMO.otf
Normal file
Binary file not shown.
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo.otf
Normal file
BIN
fonts/CyberformDemo.otf
Normal file
Binary file not shown.
BIN
fonts/Eyekons.otf
Normal file
BIN
fonts/Eyekons.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.otf
Normal file
BIN
fonts/KATA Mac.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.ttf
Normal file
BIN
fonts/KATA Mac.ttf
Normal file
Binary file not shown.
BIN
fonts/KATA.otf
Normal file
BIN
fonts/KATA.otf
Normal file
Binary file not shown.
BIN
fonts/KATA.ttf
Normal file
BIN
fonts/KATA.ttf
Normal file
Binary file not shown.
BIN
fonts/Microbots Demo.otf
Normal file
BIN
fonts/Microbots Demo.otf
Normal file
Binary file not shown.
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Neoform-Demo.otf
Normal file
BIN
fonts/Neoform-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Pixel Sparta.otf
Normal file
BIN
fonts/Pixel Sparta.otf
Normal file
Binary file not shown.
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Resond-Regular.otf
Normal file
BIN
fonts/Resond-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Robocops-Demo.otf
Normal file
BIN
fonts/Robocops-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Synthetix.otf
Normal file
BIN
fonts/Synthetix.otf
Normal file
Binary file not shown.
BIN
fonts/Xeonic.ttf
Normal file
BIN
fonts/Xeonic.ttf
Normal file
Binary file not shown.
27
hk.pkl
Normal file
27
hk.pkl
Normal file
@@ -0,0 +1,27 @@
|
||||
amends "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Config.pkl"
|
||||
import "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Builtins.pkl"
|
||||
|
||||
hooks {
|
||||
["pre-commit"] {
|
||||
fix = true
|
||||
stash = "git"
|
||||
steps {
|
||||
["ruff-format"] = (Builtins.ruff_format) {
|
||||
prefix = "uv run"
|
||||
}
|
||||
["ruff"] = (Builtins.ruff) {
|
||||
prefix = "uv run"
|
||||
check = "ruff check engine/ tests/"
|
||||
fix = "ruff check --fix --unsafe-fixes engine/ tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
["pre-push"] {
|
||||
steps {
|
||||
["ruff"] = (Builtins.ruff) {
|
||||
prefix = "uv run"
|
||||
check = "ruff check engine/ tests/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
mainline.py
35
mainline.py
@@ -5,40 +5,7 @@ Digital news consciousness stream.
|
||||
Matrix aesthetic · THX-1138 hue.
|
||||
"""
|
||||
|
||||
import subprocess, sys, pathlib
|
||||
|
||||
# ─── BOOTSTRAP VENV ───────────────────────────────────────
|
||||
_VENV = pathlib.Path(__file__).resolve().parent / ".mainline_venv"
|
||||
_MARKER = _VENV / ".installed_v3"
|
||||
|
||||
def _ensure_venv():
|
||||
"""Create a local venv and install deps if needed."""
|
||||
if _MARKER.exists():
|
||||
return
|
||||
import venv
|
||||
print("\033[2;38;5;34m > first run — creating environment...\033[0m")
|
||||
venv.create(str(_VENV), with_pip=True, clear=True)
|
||||
pip = str(_VENV / "bin" / "pip")
|
||||
subprocess.check_call(
|
||||
[pip, "install", "feedparser", "Pillow", "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
_MARKER.touch()
|
||||
|
||||
_ensure_venv()
|
||||
|
||||
# Install sounddevice on first run after v3
|
||||
_MARKER_SD = _VENV / ".installed_sd"
|
||||
if not _MARKER_SD.exists():
|
||||
_pip = str(_VENV / "bin" / "pip")
|
||||
subprocess.check_call([_pip, "install", "sounddevice", "numpy", "-q"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
_MARKER_SD.touch()
|
||||
|
||||
sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
|
||||
|
||||
# ─── DELEGATE TO ENGINE ───────────────────────────────────
|
||||
from engine.app import main # noqa: E402
|
||||
from engine.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
52
mise.toml
Normal file
52
mise.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
[tools]
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
pkl = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Development
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-v = "uv run pytest -v"
|
||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||
|
||||
lint = "uv run ruff check engine/ mainline.py"
|
||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||
format = "uv run ruff format engine/ mainline.py"
|
||||
|
||||
# =====================
|
||||
# Runtime
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
# =====================
|
||||
# Environment
|
||||
# =====================
|
||||
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "uv sync"
|
||||
install-dev = "uv sync --group dev"
|
||||
|
||||
bootstrap = "uv sync && uv run mainline.py --help"
|
||||
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||
|
||||
# =====================
|
||||
# CI/CD
|
||||
# =====================
|
||||
|
||||
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"
|
||||
|
||||
# =====================
|
||||
# Git Hooks (via hk)
|
||||
# =====================
|
||||
|
||||
pre-commit = "hk run pre-commit"
|
||||
88
pyproject.toml
Normal file
88
pyproject.toml
Normal file
@@ -0,0 +1,88 @@
|
||||
[project]
|
||||
name = "mainline"
|
||||
version = "0.1.0"
|
||||
description = "Terminal news ticker with Matrix aesthetic"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "Mainline", email = "mainline@example.com" }
|
||||
]
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Terminals",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"feedparser>=6.0.0",
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mainline = "engine.app:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = [
|
||||
"--strict-markers",
|
||||
"--tb=short",
|
||||
"-v",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["engine"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"if TYPE_CHECKING:",
|
||||
"@abstractmethod",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]
|
||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
ruff>=0.1.0
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
feedparser>=6.0.0
|
||||
Pillow>=10.0.0
|
||||
sounddevice>=0.4.0
|
||||
numpy>=1.24.0
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
162
tests/test_config.py
Normal file
162
tests/test_config.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Tests for engine.config module.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine import config
|
||||
|
||||
|
||||
class TestArgValue:
|
||||
"""Tests for _arg_value helper."""
|
||||
|
||||
def test_returns_value_when_flag_present(self):
|
||||
"""Returns the value following the flag."""
|
||||
with patch.object(sys, "argv", ["prog", "--font-file", "test.otf"]):
|
||||
result = config._arg_value("--font-file")
|
||||
assert result == "test.otf"
|
||||
|
||||
def test_returns_none_when_flag_missing(self):
|
||||
"""Returns None when flag is not present."""
|
||||
with patch.object(sys, "argv", ["prog"]):
|
||||
result = config._arg_value("--font-file")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_no_value(self):
|
||||
"""Returns None when flag is last."""
|
||||
with patch.object(sys, "argv", ["prog", "--font-file"]):
|
||||
result = config._arg_value("--font-file")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestArgInt:
|
||||
"""Tests for _arg_int helper."""
|
||||
|
||||
def test_parses_valid_int(self):
|
||||
"""Parses valid integer."""
|
||||
with patch.object(sys, "argv", ["prog", "--font-index", "5"]):
|
||||
result = config._arg_int("--font-index", 0)
|
||||
assert result == 5
|
||||
|
||||
def test_returns_default_on_invalid(self):
|
||||
"""Returns default on invalid input."""
|
||||
with patch.object(sys, "argv", ["prog", "--font-index", "abc"]):
|
||||
result = config._arg_int("--font-index", 0)
|
||||
assert result == 0
|
||||
|
||||
def test_returns_default_when_missing(self):
|
||||
"""Returns default when flag missing."""
|
||||
with patch.object(sys, "argv", ["prog"]):
|
||||
result = config._arg_int("--font-index", 10)
|
||||
assert result == 10
|
||||
|
||||
|
||||
class TestResolveFontPath:
|
||||
"""Tests for _resolve_font_path helper."""
|
||||
|
||||
def test_returns_absolute_paths(self):
|
||||
"""Absolute paths are returned as-is."""
|
||||
result = config._resolve_font_path("/absolute/path.otf")
|
||||
assert result == "/absolute/path.otf"
|
||||
|
||||
def test_resolves_relative_paths(self):
|
||||
"""Relative paths are resolved to repo root."""
|
||||
result = config._resolve_font_path("fonts/test.otf")
|
||||
assert str(config._REPO_ROOT) in result
|
||||
|
||||
def test_expands_user_home(self):
|
||||
"""Tilde paths are expanded."""
|
||||
with patch("pathlib.Path.expanduser", return_value=Path("/home/user/fonts")):
|
||||
result = config._resolve_font_path("~/fonts/test.otf")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
class TestListFontFiles:
|
||||
"""Tests for _list_font_files helper."""
|
||||
|
||||
def test_returns_empty_for_missing_dir(self):
|
||||
"""Returns empty list for missing directory."""
|
||||
result = config._list_font_files("/nonexistent/directory")
|
||||
assert result == []
|
||||
|
||||
def test_filters_by_extension(self):
|
||||
"""Only returns valid font extensions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
Path(tmpdir, "valid.otf").touch()
|
||||
Path(tmpdir, "valid.ttf").touch()
|
||||
Path(tmpdir, "invalid.txt").touch()
|
||||
Path(tmpdir, "image.png").touch()
|
||||
|
||||
result = config._list_font_files(tmpdir)
|
||||
assert len(result) == 2
|
||||
assert all(f.endswith((".otf", ".ttf")) for f in result)
|
||||
|
||||
def test_sorts_alphabetically(self):
|
||||
"""Results are sorted alphabetically."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
Path(tmpdir, "zfont.otf").touch()
|
||||
Path(tmpdir, "afont.otf").touch()
|
||||
|
||||
result = config._list_font_files(tmpdir)
|
||||
filenames = [Path(f).name for f in result]
|
||||
assert filenames == ["afont.otf", "zfont.otf"]
|
||||
|
||||
|
||||
class TestDefaults:
|
||||
"""Tests for default configuration values."""
|
||||
|
||||
def test_headline_limit(self):
|
||||
"""HEADLINE_LIMIT has sensible default."""
|
||||
assert config.HEADLINE_LIMIT > 0
|
||||
|
||||
def test_feed_timeout(self):
|
||||
"""FEED_TIMEOUT has sensible default."""
|
||||
assert config.FEED_TIMEOUT > 0
|
||||
|
||||
def test_font_extensions(self):
|
||||
"""Font extensions are defined."""
|
||||
assert ".otf" in config._FONT_EXTENSIONS
|
||||
assert ".ttf" in config._FONT_EXTENSIONS
|
||||
assert ".ttc" in config._FONT_EXTENSIONS
|
||||
|
||||
|
||||
class TestGlyphs:
|
||||
"""Tests for glyph constants."""
|
||||
|
||||
def test_glitch_glyphs_defined(self):
|
||||
"""GLITCH glyphs are defined."""
|
||||
assert len(config.GLITCH) > 0
|
||||
|
||||
def test_kata_glyphs_defined(self):
|
||||
"""KATA glyphs are defined."""
|
||||
assert len(config.KATA) > 0
|
||||
|
||||
|
||||
class TestSetFontSelection:
|
||||
"""Tests for set_font_selection function."""
|
||||
|
||||
def test_updates_font_path(self):
|
||||
"""Updates FONT_PATH globally."""
|
||||
original = config.FONT_PATH
|
||||
config.set_font_selection(font_path="/new/path.otf")
|
||||
assert config.FONT_PATH == "/new/path.otf"
|
||||
config.FONT_PATH = original
|
||||
|
||||
def test_updates_font_index(self):
|
||||
"""Updates FONT_INDEX globally."""
|
||||
original = config.FONT_INDEX
|
||||
config.set_font_selection(font_index=5)
|
||||
assert config.FONT_INDEX == 5
|
||||
config.FONT_INDEX = original
|
||||
|
||||
def test_handles_none_values(self):
|
||||
"""Handles None values gracefully."""
|
||||
original_path = config.FONT_PATH
|
||||
original_index = config.FONT_INDEX
|
||||
|
||||
config.set_font_selection(font_path=None, font_index=None)
|
||||
assert original_path == config.FONT_PATH
|
||||
assert original_index == config.FONT_INDEX
|
||||
93
tests/test_filter.py
Normal file
93
tests/test_filter.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Tests for engine.filter module.
|
||||
"""
|
||||
|
||||
from engine.filter import skip, strip_tags
|
||||
|
||||
|
||||
class TestStripTags:
|
||||
"""Tests for strip_tags function."""
|
||||
|
||||
def test_strips_simple_html(self):
|
||||
"""Basic HTML tags are removed."""
|
||||
assert strip_tags("<p>Hello</p>") == "Hello"
|
||||
assert strip_tags("<b>Bold</b>") == "Bold"
|
||||
assert strip_tags("<em>Italic</em>") == "Italic"
|
||||
|
||||
def test_strips_nested_html(self):
|
||||
"""Nested HTML tags are handled."""
|
||||
assert strip_tags("<div><p>Nested</p></div>") == "Nested"
|
||||
assert strip_tags("<span><strong>Deep</strong></span>") == "Deep"
|
||||
|
||||
def test_strips_html_with_attributes(self):
|
||||
"""HTML with attributes is handled."""
|
||||
assert strip_tags('<a href="http://example.com">Link</a>') == "Link"
|
||||
assert strip_tags('<img src="test.jpg" alt="test">') == ""
|
||||
|
||||
def test_handles_empty_string(self):
|
||||
"""Empty string returns empty string."""
|
||||
assert strip_tags("") == ""
|
||||
assert strip_tags(None) == ""
|
||||
|
||||
def test_handles_plain_text(self):
|
||||
"""Plain text without tags passes through."""
|
||||
assert strip_tags("Plain text") == "Plain text"
|
||||
|
||||
def test_unescapes_html_entities(self):
|
||||
"""HTML entities are decoded and tags are stripped."""
|
||||
assert strip_tags(" test") == "test"
|
||||
assert strip_tags("Hello & World") == "Hello & World"
|
||||
|
||||
def test_handles_malformed_html(self):
|
||||
"""Malformed HTML is handled gracefully."""
|
||||
assert strip_tags("<p>Unclosed") == "Unclosed"
|
||||
assert strip_tags("</p>No start") == "No start"
|
||||
|
||||
|
||||
class TestSkip:
|
||||
"""Tests for skip function - content filtering."""
|
||||
|
||||
def test_skips_sports_content(self):
|
||||
"""Sports-related headlines are skipped."""
|
||||
assert skip("Football: Team wins championship") is True
|
||||
assert skip("NBA Finals Game 7 results") is True
|
||||
assert skip("Soccer match ends in draw") is True
|
||||
assert skip("Premier League transfer news") is True
|
||||
assert skip("Super Bowl halftime show") is True
|
||||
|
||||
def test_skips_vapid_content(self):
|
||||
"""Vapid/celebrity content is skipped."""
|
||||
assert skip("Kim Kardashian's new look") is True
|
||||
assert skip("Influencer goes viral") is True
|
||||
assert skip("Red carpet best dressed") is True
|
||||
assert skip("Celebrity couple splits") is True
|
||||
|
||||
def test_allows_real_news(self):
|
||||
"""Legitimate news headlines are allowed."""
|
||||
assert skip("Scientists discover new planet") is False
|
||||
assert skip("Economy grows by 3%") is False
|
||||
assert skip("World leaders meet for summit") is False
|
||||
assert skip("New technology breakthrough") is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Filter is case insensitive."""
|
||||
assert skip("FOOTBALL scores") is True
|
||||
assert skip("Football SCORES") is True
|
||||
assert skip("Kardashian") is True
|
||||
|
||||
def test_word_boundary_matching(self):
|
||||
"""Word boundary matching works correctly."""
|
||||
assert skip("The football stadium") is True
|
||||
assert skip("Footballer scores") is False
|
||||
assert skip("Footballs on sale") is False
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests combining filter functions."""
|
||||
|
||||
def test_full_pipeline(self):
|
||||
"""Test strip_tags followed by skip."""
|
||||
html = '<p><a href="#">Breaking: Football championship final</a></p>'
|
||||
text = strip_tags(html)
|
||||
assert text == "Breaking: Football championship final"
|
||||
assert skip(text) is True
|
||||
83
tests/test_mic.py
Normal file
83
tests/test_mic.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Tests for engine.mic module.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMicMonitorImport:
|
||||
"""Tests for module import behavior."""
|
||||
|
||||
def test_mic_monitor_imports_without_error(self):
|
||||
"""MicMonitor can be imported even without sounddevice."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
assert MicMonitor is not None
|
||||
|
||||
|
||||
class TestMicMonitorInit:
|
||||
"""Tests for MicMonitor initialization."""
|
||||
|
||||
def test_init_sets_threshold(self):
|
||||
"""Threshold is set correctly."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=60)
|
||||
assert monitor.threshold_db == 60
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Default values are set correctly."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert monitor.threshold_db == 50
|
||||
|
||||
def test_init_db_starts_at_negative(self):
|
||||
"""_db starts at negative value."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert monitor.db == -99.0
|
||||
|
||||
|
||||
class TestMicMonitorProperties:
|
||||
"""Tests for MicMonitor properties."""
|
||||
|
||||
def test_excess_returns_positive_when_above_threshold(self):
|
||||
"""excess returns positive value when above threshold."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=50)
|
||||
with patch.object(monitor, "_db", 60.0):
|
||||
assert monitor.excess == 10.0
|
||||
|
||||
def test_excess_returns_zero_when_below_threshold(self):
|
||||
"""excess returns zero when below threshold."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=50)
|
||||
with patch.object(monitor, "_db", 40.0):
|
||||
assert monitor.excess == 0.0
|
||||
|
||||
|
||||
class TestMicMonitorAvailable:
|
||||
"""Tests for MicMonitor.available property."""
|
||||
|
||||
def test_available_is_bool(self):
|
||||
"""available returns a boolean."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert isinstance(monitor.available, bool)
|
||||
|
||||
|
||||
class TestMicMonitorStop:
|
||||
"""Tests for MicMonitor.stop method."""
|
||||
|
||||
def test_stop_does_nothing_when_no_stream(self):
|
||||
"""stop() does nothing if no stream exists."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
monitor.stop()
|
||||
assert monitor._stream is None
|
||||
70
tests/test_ntfy.py
Normal file
70
tests/test_ntfy.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Tests for engine.ntfy module.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
|
||||
class TestNtfyPollerInit:
|
||||
"""Tests for NtfyPoller initialization."""
|
||||
|
||||
def test_init_sets_defaults(self):
|
||||
"""Default values are set correctly."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
assert poller.topic_url == "http://example.com/topic"
|
||||
assert poller.reconnect_delay == 5
|
||||
assert poller.display_secs == 30
|
||||
|
||||
def test_init_custom_values(self):
|
||||
"""Custom values are set correctly."""
|
||||
poller = NtfyPoller(
|
||||
"http://example.com/topic", reconnect_delay=10, display_secs=60
|
||||
)
|
||||
assert poller.reconnect_delay == 10
|
||||
assert poller.display_secs == 60
|
||||
|
||||
|
||||
class TestNtfyPollerStart:
|
||||
"""Tests for NtfyPoller.start method."""
|
||||
|
||||
@patch("engine.ntfy.threading.Thread")
|
||||
def test_start_creates_daemon_thread(self, mock_thread):
|
||||
"""start() creates and starts a daemon thread."""
|
||||
mock_thread_instance = MagicMock()
|
||||
mock_thread.return_value = mock_thread_instance
|
||||
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
result = poller.start()
|
||||
|
||||
assert result is True
|
||||
mock_thread.assert_called_once()
|
||||
args, kwargs = mock_thread.call_args
|
||||
assert kwargs.get("daemon") is True
|
||||
mock_thread_instance.start.assert_called_once()
|
||||
|
||||
|
||||
class TestNtfyPollerGetActiveMessage:
|
||||
"""Tests for NtfyPoller.get_active_message method."""
|
||||
|
||||
def test_returns_none_when_no_message(self):
|
||||
"""Returns None when no message has been received."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
result = poller.get_active_message()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestNtfyPollerDismiss:
|
||||
"""Tests for NtfyPoller.dismiss method."""
|
||||
|
||||
def test_dismiss_clears_message(self):
|
||||
"""dismiss() clears the current message."""
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
|
||||
with patch.object(poller, "_lock"):
|
||||
poller._message = ("Title", "Body", time.monotonic())
|
||||
poller.dismiss()
|
||||
|
||||
assert poller._message is None
|
||||
93
tests/test_sources.py
Normal file
93
tests/test_sources.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Tests for engine.sources module - data validation.
|
||||
"""
|
||||
|
||||
from engine import sources
|
||||
|
||||
|
||||
class TestFeeds:
|
||||
"""Tests for FEEDS data."""
|
||||
|
||||
def test_feeds_is_dict(self):
|
||||
"""FEEDS is a dictionary."""
|
||||
assert isinstance(sources.FEEDS, dict)
|
||||
|
||||
def test_feeds_has_entries(self):
|
||||
"""FEEDS has feed entries."""
|
||||
assert len(sources.FEEDS) > 0
|
||||
|
||||
def test_feeds_have_valid_urls(self):
|
||||
"""All feeds have valid URL format."""
|
||||
for name, url in sources.FEEDS.items():
|
||||
assert name
|
||||
assert url.startswith("http://") or url.startswith("https://")
|
||||
|
||||
|
||||
class TestPoetrySources:
|
||||
"""Tests for POETRY_SOURCES data."""
|
||||
|
||||
def test_poetry_is_dict(self):
|
||||
"""POETRY_SOURCES is a dictionary."""
|
||||
assert isinstance(sources.POETRY_SOURCES, dict)
|
||||
|
||||
def test_poetry_has_entries(self):
|
||||
"""POETRY_SOURCES has entries."""
|
||||
assert len(sources.POETRY_SOURCES) > 0
|
||||
|
||||
def test_poetry_have_gutenberg_urls(self):
|
||||
"""All poetry sources are from Gutenberg."""
|
||||
for _name, url in sources.POETRY_SOURCES.items():
|
||||
assert "gutenberg.org" in url
|
||||
|
||||
|
||||
class TestSourceLangs:
|
||||
"""Tests for SOURCE_LANGS mapping."""
|
||||
|
||||
def test_source_langs_is_dict(self):
|
||||
"""SOURCE_LANGS is a dictionary."""
|
||||
assert isinstance(sources.SOURCE_LANGS, dict)
|
||||
|
||||
def test_source_langs_valid_language_codes(self):
|
||||
"""Language codes are valid ISO codes."""
|
||||
valid_codes = {"de", "fr", "ja", "zh-cn", "ar", "hi"}
|
||||
for code in sources.SOURCE_LANGS.values():
|
||||
assert code in valid_codes
|
||||
|
||||
|
||||
class TestLocationLangs:
|
||||
"""Tests for LOCATION_LANGS mapping."""
|
||||
|
||||
def test_location_langs_is_dict(self):
|
||||
"""LOCATION_LANGS is a dictionary."""
|
||||
assert isinstance(sources.LOCATION_LANGS, dict)
|
||||
|
||||
def test_location_langs_has_patterns(self):
|
||||
"""LOCATION_LANGS has regex patterns."""
|
||||
assert len(sources.LOCATION_LANGS) > 0
|
||||
|
||||
|
||||
class TestScriptFonts:
|
||||
"""Tests for SCRIPT_FONTS mapping."""
|
||||
|
||||
def test_script_fonts_is_dict(self):
|
||||
"""SCRIPT_FONTS is a dictionary."""
|
||||
assert isinstance(sources.SCRIPT_FONTS, dict)
|
||||
|
||||
def test_script_fonts_has_paths(self):
|
||||
"""All script fonts have paths."""
|
||||
for _lang, path in sources.SCRIPT_FONTS.items():
|
||||
assert path
|
||||
|
||||
|
||||
class TestNoUpper:
|
||||
"""Tests for NO_UPPER set."""
|
||||
|
||||
def test_no_upper_is_set(self):
|
||||
"""NO_UPPER is a set."""
|
||||
assert isinstance(sources.NO_UPPER, set)
|
||||
|
||||
def test_no_upper_contains_scripts(self):
|
||||
"""NO_UPPER contains non-Latin scripts."""
|
||||
assert "zh-cn" in sources.NO_UPPER
|
||||
assert "ja" in sources.NO_UPPER
|
||||
assert "ar" in sources.NO_UPPER
|
||||
130
tests/test_terminal.py
Normal file
130
tests/test_terminal.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Tests for engine.terminal module.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine import terminal
|
||||
|
||||
|
||||
class TestTerminalDimensions:
|
||||
"""Tests for terminal width/height functions."""
|
||||
|
||||
def test_tw_returns_columns(self):
|
||||
"""tw() returns terminal width."""
|
||||
with (
|
||||
patch.object(sys.stdout, "isatty", return_value=True),
|
||||
patch("os.get_terminal_size") as mock_size,
|
||||
):
|
||||
mock_size.return_value = io.StringIO("columns=120")
|
||||
mock_size.columns = 120
|
||||
result = terminal.tw()
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_th_returns_lines(self):
|
||||
"""th() returns terminal height."""
|
||||
with (
|
||||
patch.object(sys.stdout, "isatty", return_value=True),
|
||||
patch("os.get_terminal_size") as mock_size,
|
||||
):
|
||||
mock_size.return_value = io.StringIO("lines=30")
|
||||
mock_size.lines = 30
|
||||
result = terminal.th()
|
||||
assert isinstance(result, int)
|
||||
|
||||
def test_tw_fallback_on_error(self):
|
||||
"""tw() falls back to 80 on error."""
|
||||
with patch("os.get_terminal_size", side_effect=OSError):
|
||||
result = terminal.tw()
|
||||
assert result == 80
|
||||
|
||||
def test_th_fallback_on_error(self):
|
||||
"""th() falls back to 24 on error."""
|
||||
with patch("os.get_terminal_size", side_effect=OSError):
|
||||
result = terminal.th()
|
||||
assert result == 24
|
||||
|
||||
|
||||
class TestANSICodes:
|
||||
"""Tests for ANSI escape code constants."""
|
||||
|
||||
def test_ansi_constants_exist(self):
|
||||
"""All ANSI constants are defined."""
|
||||
assert terminal.RST == "\033[0m"
|
||||
assert terminal.BOLD == "\033[1m"
|
||||
assert terminal.DIM == "\033[2m"
|
||||
|
||||
def test_green_shades_defined(self):
|
||||
"""Green gradient colors are defined."""
|
||||
assert terminal.G_HI == "\033[38;5;46m"
|
||||
assert terminal.G_MID == "\033[38;5;34m"
|
||||
assert terminal.G_LO == "\033[38;5;22m"
|
||||
|
||||
def test_white_shades_defined(self):
|
||||
"""White/gray tones are defined."""
|
||||
assert terminal.W_COOL == "\033[38;5;250m"
|
||||
assert terminal.W_DIM == "\033[2;38;5;245m"
|
||||
|
||||
def test_cursor_controls_defined(self):
|
||||
"""Cursor control codes are defined."""
|
||||
assert "?" in terminal.CURSOR_OFF
|
||||
assert "?" in terminal.CURSOR_ON
|
||||
|
||||
|
||||
class TestTypeOut:
|
||||
"""Tests for type_out function."""
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("time.sleep")
|
||||
def test_type_out_writes_text(self, mock_sleep, mock_stdout):
|
||||
"""type_out writes text to stdout."""
|
||||
with patch("random.random", return_value=0.5):
|
||||
terminal.type_out("Hi", color=terminal.G_HI)
|
||||
output = mock_stdout.getvalue()
|
||||
assert len(output) > 0
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_type_out_uses_color(self, mock_sleep):
|
||||
"""type_out applies color codes."""
|
||||
with (
|
||||
patch("sys.stdout", new_callable=io.StringIO),
|
||||
patch("random.random", return_value=0.5),
|
||||
):
|
||||
terminal.type_out("Test", color=terminal.G_HI)
|
||||
|
||||
|
||||
class TestSlowPrint:
|
||||
"""Tests for slow_print function."""
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("time.sleep")
|
||||
def test_slow_print_writes_text(self, mock_sleep, mock_stdout):
|
||||
"""slow_print writes text to stdout."""
|
||||
terminal.slow_print("Hi", color=terminal.G_DIM, delay=0)
|
||||
output = mock_stdout.getvalue()
|
||||
assert len(output) > 0
|
||||
|
||||
|
||||
class TestBootLn:
|
||||
"""Tests for boot_ln function."""
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("time.sleep")
|
||||
def test_boot_ln_writes_label_and_status(self, mock_sleep, mock_stdout):
|
||||
"""boot_ln shows label and status."""
|
||||
with patch("random.uniform", return_value=0):
|
||||
terminal.boot_ln("Loading", "OK", ok=True)
|
||||
output = mock_stdout.getvalue()
|
||||
assert "Loading" in output
|
||||
assert "OK" in output
|
||||
|
||||
@patch("sys.stdout", new_callable=io.StringIO)
|
||||
@patch("time.sleep")
|
||||
def test_boot_ln_error_status(self, mock_sleep, mock_stdout):
|
||||
"""boot_ln shows red for error status."""
|
||||
with patch("random.uniform", return_value=0):
|
||||
terminal.boot_ln("Loading", "FAIL", ok=False)
|
||||
output = mock_stdout.getvalue()
|
||||
assert "FAIL" in output
|
||||
Reference in New Issue
Block a user