fix: apply ruff auto-fixes and add hk git hooks

- Fix pre-existing lint errors in engine/ modules using ruff --unsafe-fixes
- Add hk.pkl with pre-commit and pre-push hooks using ruff builtin
- Configure hooks to use 'uv run' prefix for tool execution
- Update mise.toml to include hk and pkl tools
- All 73 tests pass
This commit is contained in:
2026-03-15 14:43:39 -07:00
parent bd4b146c02
commit 362bba9461
15 changed files with 287 additions and 185 deletions

View File

@@ -39,6 +39,7 @@ TITLE = [
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝", " ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
] ]
def _read_picker_key(): def _read_picker_key():
ch = sys.stdin.read(1) ch = sys.stdin.read(1)
if ch == "\x03": if ch == "\x03":
@@ -63,6 +64,7 @@ def _read_picker_key():
return "enter" return "enter"
return None return None
def _normalize_preview_rows(rows): def _normalize_preview_rows(rows):
"""Trim shared left padding and trailing spaces for stable on-screen previews.""" """Trim shared left padding and trailing spaces for stable on-screen previews."""
non_empty = [r for r in rows if r.strip()] non_empty = [r for r in rows if r.strip()]
@@ -109,7 +111,9 @@ def _draw_font_picker(faces, selected):
active = pos == selected active = pos == selected
pointer = "" if active else " " pointer = "" if active else " "
color = G_HI if active else W_DIM color = G_HI if active else W_DIM
print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}") print(
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
)
if top > 0: if top > 0:
print(f" {W_GHOST}{top} above{RST}") print(f" {W_GHOST}{top} above{RST}")
@@ -126,6 +130,7 @@ def _draw_font_picker(faces, selected):
shown = row[:max_preview_w] shown = row[:max_preview_w]
print(f" {shown}") print(f" {shown}")
def pick_font_face(): def pick_font_face():
"""Interactive startup picker for selecting a face from repo OTF files.""" """Interactive startup picker for selecting a face from repo OTF files."""
if not config.FONT_PICKER: if not config.FONT_PICKER:
@@ -235,7 +240,9 @@ def pick_font_face():
font_index=selected_font["font_index"], font_index=selected_font["font_index"],
) )
render.clear_font_cache() render.clear_font_cache()
print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}") print(
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
)
time.sleep(0.8) time.sleep(0.8)
print(CLR, end="") print(CLR, end="")
print(CURSOR_OFF, end="") print(CURSOR_OFF, end="")
@@ -265,23 +272,29 @@ def main():
time.sleep(0.07) time.sleep(0.07)
print() 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_DIM}v0.1 · {_subtitle}{RST}")
print(f" {W_GHOST}{'' * (w - 4)}{RST}") print(f" {W_GHOST}{'' * (w - 4)}{RST}")
print() print()
time.sleep(0.4) 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: if cached:
items = cached items = cached
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True) boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
elif config.MODE == 'poetry': elif config.MODE == "poetry":
slow_print(" > INITIALIZING LITERARY CORPUS...\n") slow_print(" > INITIALIZING LITERARY CORPUS...\n")
time.sleep(0.2) time.sleep(0.2)
print() print()
items, linked, failed = fetch_poetry() items, linked, failed = fetch_poetry()
print() 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}") print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
save_cache(items) save_cache(items)
else: else:
@@ -290,7 +303,9 @@ def main():
print() print()
items, linked, failed = fetch_all() items, linked, failed = fetch_all()
print() 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}") print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
save_cache(items) save_cache(items)
@@ -302,7 +317,13 @@ def main():
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB) mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
mic_ok = mic.start() mic_ok = mic.start()
if mic.available: 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( ntfy = NtfyPoller(
config.NTFY_TOPIC, config.NTFY_TOPIC,

View File

@@ -51,40 +51,42 @@ def _list_font_files(font_dir):
def list_repo_font_files(): def list_repo_font_files():
"""Public helper for discovering repository font files.""" """Public helper for discovering repository font files."""
return _list_font_files(FONT_DIR) return _list_font_files(FONT_DIR)
# ─── RUNTIME ────────────────────────────────────────────── # ─── RUNTIME ──────────────────────────────────────────────
HEADLINE_LIMIT = 1000 HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10 FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news' MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
FIREHOSE = '--firehose' in sys.argv FIREHOSE = "--firehose" in sys.argv
# ─── NTFY MESSAGE QUEUE ────────────────────────────────── # ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1" NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1"
NTFY_POLL_INTERVAL = 15 # seconds between polls NTFY_POLL_INTERVAL = 15 # seconds between polls
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
# ─── FONT RENDERING ────────────────────────────────────── # ─── FONT RENDERING ──────────────────────────────────────
FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts") FONT_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts")
_FONT_FILE_ARG = _arg_value('--font-file') _FONT_FILE_ARG = _arg_value("--font-file")
_FONT_FILES = _list_font_files(FONT_DIR) _FONT_FILES = _list_font_files(FONT_DIR)
FONT_PATH = ( FONT_PATH = (
_resolve_font_path(_FONT_FILE_ARG) _resolve_font_path(_FONT_FILE_ARG)
if _FONT_FILE_ARG if _FONT_FILE_ARG
else (_FONT_FILES[0] if _FONT_FILES else "") else (_FONT_FILES[0] if _FONT_FILES else "")
) )
FONT_INDEX = max(0, _arg_int('--font-index', 0)) FONT_INDEX = max(0, _arg_int("--font-index", 0))
FONT_PICKER = '--no-font-picker' not in sys.argv FONT_PICKER = "--no-font-picker" not in sys.argv
FONT_SZ = 60 FONT_SZ = 60
RENDER_H = 8 # terminal rows per rendered text line RENDER_H = 8 # terminal rows per rendered text line
# ─── FONT RENDERING (ADVANCED) ──────────────────────────── # ─── 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 / FRAME ──────────────────────────────────────
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed) SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS) FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
FIREHOSE_H = 12 # firehose zone height (terminal rows) FIREHOSE_H = 12 # firehose zone height (terminal rows)
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep) GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
# ─── GLYPHS ─────────────────────────────────────────────── # ─── GLYPHS ───────────────────────────────────────────────
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"

View File

@@ -34,23 +34,23 @@ def fade_line(s, fade):
if fade >= 1.0: if fade >= 1.0:
return s return s
if fade <= 0.0: if fade <= 0.0:
return '' return ""
result = [] result = []
i = 0 i = 0
while i < len(s): 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 j = i + 2
while j < len(s) and not s[j].isalpha(): while j < len(s) and not s[j].isalpha():
j += 1 j += 1
result.append(s[i:j + 1]) result.append(s[i : j + 1])
i = j + 1 i = j + 1
elif s[i] == ' ': elif s[i] == " ":
result.append(' ') result.append(" ")
i += 1 i += 1
else: else:
result.append(s[i] if random.random() < fade else ' ') result.append(s[i] if random.random() < fade else " ")
i += 1 i += 1
return ''.join(result) return "".join(result)
def vis_trunc(s, w): def vis_trunc(s, w):
@@ -61,17 +61,17 @@ def vis_trunc(s, w):
while i < len(s): while i < len(s):
if vw >= w: if vw >= w:
break 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 j = i + 2
while j < len(s) and not s[j].isalpha(): while j < len(s) and not s[j].isalpha():
j += 1 j += 1
result.append(s[i:j + 1]) result.append(s[i : j + 1])
i = j + 1 i = j + 1
else: else:
result.append(s[i]) result.append(s[i])
vw += 1 vw += 1
i += 1 i += 1
return ''.join(result) return "".join(result)
def next_headline(pool, items, seen): def next_headline(pool, items, seen):
@@ -94,7 +94,7 @@ def firehose_line(items, w):
if r < 0.35: if r < 0.35:
# Raw headline text # Raw headline text
title, src, ts = random.choice(items) 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]) color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
return f"{color}{text}{RST}" return f"{color}{text}{RST}"
elif r < 0.55: elif r < 0.55:
@@ -103,12 +103,13 @@ def firehose_line(items, w):
return "".join( return "".join(
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}" f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
f"{random.choice(config.GLITCH + config.KATA)}{RST}" f"{random.choice(config.GLITCH + config.KATA)}{RST}"
if random.random() < d else " " if random.random() < d
else " "
for _ in range(w) for _ in range(w)
) )
elif r < 0.78: elif r < 0.78:
# Status / program output # 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())) src = random.choice(list(sources.keys()))
msgs = [ msgs = [
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}", 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" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}", 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]) color = random.choice([G_LO, G_DIM, W_GHOST])
return f"{color}{text}{RST}" return f"{color}{text}{RST}"
else: else:
# Headline fragment with glitch prefix # Headline fragment with glitch prefix
title, _, _ = random.choice(items) title, _, _ = random.choice(items)
start = random.randint(0, max(0, len(title) - 20)) 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)) pad = random.randint(0, max(0, w - len(frag) - 8))
gp = ''.join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3))) gp = "".join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
text = (' ' * pad + gp + ' ' + frag)[:w - 1] text = (" " * pad + gp + " " + frag)[: w - 1]
color = random.choice([G_LO, C_DIM, W_GHOST]) color = random.choice([G_LO, C_DIM, W_GHOST])
return f"{color}{text}{RST}" return f"{color}{text}{RST}"

View File

@@ -64,26 +64,31 @@ def _fetch_gutenberg(url, label):
try: try:
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"}) req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=15) 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 # 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: if m:
text = text[m.end():] text = text[m.end() :]
m = re.search(r'\*\*\*\s*END OF', text) m = re.search(r"\*\*\*\s*END OF", text)
if m: if m:
text = text[:m.start()] text = text[: m.start()]
# Split on blank lines into stanzas/passages # Split on blank lines into stanzas/passages
blocks = re.split(r'\n{2,}', text.strip()) blocks = re.split(r"\n{2,}", text.strip())
items = [] items = []
for blk in blocks: 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: if len(blk) < 20 or len(blk) > 280:
continue continue
if blk.isupper(): # skip all-caps headers if blk.isupper(): # skip all-caps headers
continue continue
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
continue continue
items.append((blk, label, '')) items.append((blk, label, ""))
return items return items
except Exception: except Exception:
return [] return []

View File

@@ -29,29 +29,29 @@ def strip_tags(html):
# ─── CONTENT FILTER ─────────────────────────────────────── # ─── CONTENT FILTER ───────────────────────────────────────
_SKIP_RE = re.compile( _SKIP_RE = re.compile(
r'\b(?:' r"\b(?:"
# ── sports ── # ── sports ──
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|' r"football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|"
r'hockey|lacrosse|volleyball|badminton|' r"hockey|lacrosse|volleyball|badminton|"
r'nba|nfl|nhl|mlb|mls|fifa|uefa|' r"nba|nfl|nhl|mlb|mls|fifa|uefa|"
r'premier league|champions league|la liga|serie a|bundesliga|' r"premier league|champions league|la liga|serie a|bundesliga|"
r'world cup|super bowl|world series|stanley cup|' r"world cup|super bowl|world series|stanley cup|"
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|' r"playoff|playoffs|touchdown|goalkeeper|striker|quarterback|"
r'slam dunk|home run|grand slam|offside|halftime|' r"slam dunk|home run|grand slam|offside|halftime|"
r'batting|wicket|innings|' r"batting|wicket|innings|"
r'formula 1|nascar|motogp|' r"formula 1|nascar|motogp|"
r'boxing|ufc|mma|' r"boxing|ufc|mma|"
r'marathon|tour de france|' r"marathon|tour de france|"
r'transfer window|draft pick|relegation|' r"transfer window|draft pick|relegation|"
# ── vapid / insipid ── # ── vapid / insipid ──
r'kardashian|jenner|reality tv|reality show|' r"kardashian|jenner|reality tv|reality show|"
r'influencer|viral video|tiktok|instagram|' r"influencer|viral video|tiktok|instagram|"
r'best dressed|worst dressed|red carpet|' r"best dressed|worst dressed|red carpet|"
r'horoscope|zodiac|gossip|bikini|selfie|' r"horoscope|zodiac|gossip|bikini|selfie|"
r'you won.t believe|what happened next|' r"you won.t believe|what happened next|"
r'celebrity couple|celebrity feud|baby bump' r"celebrity couple|celebrity feud|baby bump"
r')\b', r")\b",
re.IGNORECASE re.IGNORECASE,
) )

View File

@@ -8,6 +8,7 @@ import atexit
try: try:
import numpy as _np import numpy as _np
import sounddevice as _sd import sounddevice as _sd
_HAS_MIC = True _HAS_MIC = True
except Exception: except Exception:
_HAS_MIC = False _HAS_MIC = False
@@ -40,12 +41,15 @@ class MicMonitor:
"""Start background mic stream. Returns True on success, False/None otherwise.""" """Start background mic stream. Returns True on success, False/None otherwise."""
if not _HAS_MIC: if not _HAS_MIC:
return None return None
def _cb(indata, frames, t, status): 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 self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
try: try:
self._stream = _sd.InputStream( self._stream = _sd.InputStream(
callback=_cb, channels=1, samplerate=44100, blocksize=2048) callback=_cb, channels=1, samplerate=44100, blocksize=2048
)
self._stream.start() self._stream.start()
atexit.register(self.stop) atexit.register(self.stop)
return True return True

View File

@@ -25,7 +25,7 @@ class NtfyPoller:
self.topic_url = topic_url self.topic_url = topic_url
self.poll_interval = poll_interval self.poll_interval = poll_interval
self.display_secs = display_secs 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() self._lock = threading.Lock()
def start(self): def start(self):
@@ -54,9 +54,12 @@ class NtfyPoller:
while True: while True:
try: try:
req = urllib.request.Request( req = urllib.request.Request(
self.topic_url, headers={"User-Agent": "mainline/0.1"}) self.topic_url, headers={"User-Agent": "mainline/0.1"}
)
resp = urllib.request.urlopen(req, timeout=10) resp = urllib.request.urlopen(req, timeout=10)
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'): for line in (
resp.read().decode("utf-8", errors="replace").strip().split("\n")
):
if not line.strip(): if not line.strip():
continue continue
try: try:

View File

@@ -20,15 +20,15 @@ from engine.translate import detect_location_language, translate_headline
GRAD_COLS = [ GRAD_COLS = [
"\033[1;38;5;231m", # white "\033[1;38;5;231m", # white
"\033[1;38;5;195m", # pale cyan-white "\033[1;38;5;195m", # pale cyan-white
"\033[38;5;123m", # bright cyan "\033[38;5;123m", # bright cyan
"\033[38;5;118m", # bright lime "\033[38;5;118m", # bright lime
"\033[38;5;82m", # lime "\033[38;5;82m", # lime
"\033[38;5;46m", # bright green "\033[38;5;46m", # bright green
"\033[38;5;40m", # green "\033[38;5;40m", # green
"\033[38;5;34m", # medium green "\033[38;5;34m", # medium green
"\033[38;5;28m", # dark green "\033[38;5;28m", # dark green
"\033[38;5;22m", # deep green "\033[38;5;22m", # deep green
"\033[2;38;5;22m", # dim deep green "\033[2;38;5;22m", # dim deep green
"\033[2;38;5;235m", # near black "\033[2;38;5;235m", # near black
] ]
@@ -36,15 +36,15 @@ GRAD_COLS = [
MSG_GRAD_COLS = [ MSG_GRAD_COLS = [
"\033[1;38;5;231m", # white "\033[1;38;5;231m", # white
"\033[1;38;5;225m", # pale pink-white "\033[1;38;5;225m", # pale pink-white
"\033[38;5;219m", # bright pink "\033[38;5;219m", # bright pink
"\033[38;5;213m", # hot pink "\033[38;5;213m", # hot pink
"\033[38;5;207m", # magenta "\033[38;5;207m", # magenta
"\033[38;5;201m", # bright magenta "\033[38;5;201m", # bright magenta
"\033[38;5;165m", # orchid-red "\033[38;5;165m", # orchid-red
"\033[38;5;161m", # ruby-magenta "\033[38;5;161m", # ruby-magenta
"\033[38;5;125m", # dark magenta "\033[38;5;125m", # dark magenta
"\033[38;5;89m", # deep maroon-magenta "\033[38;5;89m", # deep maroon-magenta
"\033[2;38;5;89m", # dim deep maroon-magenta "\033[2;38;5;89m", # dim deep maroon-magenta
"\033[2;38;5;235m", # near black "\033[2;38;5;235m", # near black
] ]
@@ -69,6 +69,7 @@ def font():
_FONT_OBJ_KEY = key _FONT_OBJ_KEY = key
return _FONT_OBJ return _FONT_OBJ
def clear_font_cache(): def clear_font_cache():
"""Reset cached font objects after changing primary font selection.""" """Reset cached font objects after changing primary font selection."""
global _FONT_OBJ, _FONT_OBJ_KEY global _FONT_OBJ, _FONT_OBJ_KEY
@@ -123,7 +124,7 @@ def render_line(text, fnt=None):
pad = 4 pad = 4
img_w = bbox[2] - bbox[0] + pad * 2 img_w = bbox[2] - bbox[0] + pad * 2
img_h = bbox[3] - bbox[1] + 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 = ImageDraw.Draw(img)
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt) draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
pix_h = config.RENDER_H * 2 pix_h = config.RENDER_H * 2
@@ -200,8 +201,8 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
continue continue
buf = [] buf = []
for x, ch in enumerate(row): for x, ch in enumerate(row):
if ch == ' ': if ch == " ":
buf.append(' ') buf.append(" ")
else: else:
shifted = (x / max(max_x - 1, 1) + offset) % 1.0 shifted = (x / max(max_x - 1, 1) + offset) % 1.0
idx = min(round(shifted * (n - 1)), n - 1) idx = min(round(shifted * (n - 1)), n - 1)
@@ -218,7 +219,11 @@ def lr_gradient_opposite(rows, offset=0.0):
# ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── # ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
def make_block(title, src, ts, w): def make_block(title, src, ts, w):
"""Render a headline into a content block with color.""" """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) lang_font = font_for_lang(target_lang)
if target_lang: if target_lang:
title = translate_headline(title, target_lang) title = translate_headline(title, target_lang)
@@ -227,28 +232,36 @@ def make_block(title, src, ts, w):
title_up = re.sub(r"\s+", " ", title) title_up = re.sub(r"\s+", " ", title)
else: else:
title_up = re.sub(r"\s+", " ", title.upper()) title_up = re.sub(r"\s+", " ", title.upper())
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'), for old, new in [
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]: ("\u2019", "'"),
("\u2018", "'"),
("\u201c", '"'),
("\u201d", '"'),
("\u2013", "-"),
("\u2014", "-"),
]:
title_up = title_up.replace(old, new) title_up = title_up.replace(old, new)
big_rows = big_wrap(title_up, w - 4, lang_font) big_rows = big_wrap(title_up, w - 4, lang_font)
hc = random.choice([ hc = random.choice(
"\033[38;5;46m", # matrix green [
"\033[38;5;34m", # dark green "\033[38;5;46m", # matrix green
"\033[38;5;82m", # lime "\033[38;5;34m", # dark green
"\033[38;5;48m", # sea green "\033[38;5;82m", # lime
"\033[38;5;37m", # teal "\033[38;5;48m", # sea green
"\033[38;5;44m", # cyan "\033[38;5;37m", # teal
"\033[38;5;87m", # sky "\033[38;5;44m", # cyan
"\033[38;5;117m", # ice blue "\033[38;5;87m", # sky
"\033[38;5;250m", # cool white "\033[38;5;117m", # ice blue
"\033[38;5;156m", # pale green "\033[38;5;250m", # cool white
"\033[38;5;120m", # mint "\033[38;5;156m", # pale green
"\033[38;5;80m", # dark cyan "\033[38;5;120m", # mint
"\033[38;5;108m", # grey-green "\033[38;5;80m", # dark cyan
"\033[38;5;115m", # sage "\033[38;5;108m", # grey-green
"\033[1;38;5;46m", # bold green "\033[38;5;115m", # sage
"\033[1;38;5;250m", # bold white "\033[1;38;5;46m", # bold green
]) "\033[1;38;5;250m", # bold white
]
)
content = [" " + r for r in big_rows] content = [" " + r for r in big_rows]
content.append("") content.append("")
meta = f"\u2591 {src} \u00b7 {ts}" meta = f"\u2591 {src} \u00b7 {ts}"

View File

@@ -35,8 +35,8 @@ def stream(items, ntfy_poller, mic_monitor):
w, h = tw(), th() w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
ticker_view_h = h - fh # reserve fixed firehose strip at bottom ticker_view_h = h - fh # reserve fixed firehose strip at bottom
GAP = 3 # blank rows between headlines GAP = 3 # blank rows between headlines
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2 scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
# Taxonomy: # Taxonomy:
@@ -46,8 +46,10 @@ def stream(items, ntfy_poller, mic_monitor):
# - firehose: fixed carriage-return style strip pinned at bottom # - firehose: fixed carriage-return style strip pinned at bottom
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx) # Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
active = [] active = []
scroll_cam = 0 # viewport top in virtual canvas coords scroll_cam = 0 # viewport top in virtual canvas coords
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom) ticker_next_y = (
ticker_view_h # canvas-y where next block starts (off-screen bottom)
)
noise_cache = {} noise_cache = {}
scroll_motion_accum = 0.0 scroll_motion_accum = 0.0
@@ -57,9 +59,9 @@ def stream(items, ntfy_poller, mic_monitor):
return noise_cache[cy] return noise_cache[cy]
# Message color: bright cyan/white — distinct from headline greens # Message color: bright cyan/white — distinct from headline greens
MSG_META = "\033[38;5;245m" # cool grey MSG_META = "\033[38;5;245m" # cool grey
MSG_BORDER = "\033[2;38;5;37m" # dim teal MSG_BORDER = "\033[2;38;5;37m" # dim teal
_msg_cache = (None, None) # (cache_key, rendered_rows) _msg_cache = (None, None) # (cache_key, rendered_rows)
while queued < config.HEADLINE_LIMIT or active: while queued < config.HEADLINE_LIMIT or active:
t0 = time.monotonic() t0 = time.monotonic()
@@ -84,7 +86,9 @@ def stream(items, ntfy_poller, mic_monitor):
_msg_cache = (cache_key, msg_rows) _msg_cache = (cache_key, msg_rows)
else: else:
msg_rows = _msg_cache[1] 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 # Layout: rendered text + meta + border
elapsed_s = int(time.monotonic() - m_ts) elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s) remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -94,19 +98,29 @@ def stream(items, ntfy_poller, mic_monitor):
row_idx = 0 row_idx = 0
for mr in msg_rows: for mr in msg_rows:
ln = vis_trunc(mr, w) 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 row_idx += 1
# Meta line: title (if distinct) + source + countdown # Meta line: title (if distinct) + source + countdown
meta_parts = [] meta_parts = []
if m_title and m_title != m_body: if m_title and m_title != m_body:
meta_parts.append(m_title) meta_parts.append(m_title)
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s") 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] meta = (
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K") " " + " \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 row_idx += 1
# Border — constant boundary under message panel # Border — constant boundary under message panel
bar = "\u2500" * (w - 4) 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 draws above the fixed firehose strip; message is a centered overlay.
ticker_h = ticker_view_h - msg_h ticker_h = ticker_view_h - msg_h
@@ -118,7 +132,10 @@ def stream(items, ntfy_poller, mic_monitor):
scroll_cam += 1 scroll_cam += 1
# Enqueue new headlines when room at the bottom # 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) t, src, ts = next_headline(pool, items, seen)
ticker_content, hc, midx = make_block(t, src, ts, w) ticker_content, hc, midx = make_block(t, src, ts, w)
active.append((ticker_content, hc, ticker_next_y, midx)) active.append((ticker_content, hc, ticker_next_y, midx))
@@ -126,8 +143,9 @@ def stream(items, ntfy_poller, mic_monitor):
queued += 1 queued += 1
# Prune off-screen blocks and stale noise # Prune off-screen blocks and stale noise
active = [(c, hc, by, mi) for c, hc, by, mi in active active = [
if by + len(c) > scroll_cam] (c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
]
for k in list(noise_cache): for k in list(noise_cache):
if k < scroll_cam: if k < scroll_cam:
del noise_cache[k] del noise_cache[k]

View File

@@ -47,69 +47,69 @@ FEEDS = {
# ─── POETRY / LITERATURE ───────────────────────────────── # ─── POETRY / LITERATURE ─────────────────────────────────
# Public domain via Project Gutenberg # Public domain via Project Gutenberg
POETRY_SOURCES = { POETRY_SOURCES = {
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt", "Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt", "Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt", "Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt", "Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt", "Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt", "Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt", "Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt", "Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt", "Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt", "Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt", "Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt", "Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
} }
# ─── SOURCE → LANGUAGE MAPPING ─────────────────────────── # ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
# Headlines from these outlets render in their cultural home language # Headlines from these outlets render in their cultural home language
SOURCE_LANGS = { SOURCE_LANGS = {
"Der Spiegel": "de", "Der Spiegel": "de",
"DW": "de", "DW": "de",
"France24": "fr", "France24": "fr",
"Japan Times": "ja", "Japan Times": "ja",
"The Hindu": "hi", "The Hindu": "hi",
"SCMP": "zh-cn", "SCMP": "zh-cn",
"Al Jazeera": "ar", "Al Jazeera": "ar",
} }
# ─── LOCATION → LANGUAGE ───────────────────────────────── # ─── LOCATION → LANGUAGE ─────────────────────────────────
LOCATION_LANGS = { LOCATION_LANGS = {
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn', r"\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b": "zh-cn",
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja', r"\b(?:japan|japanese|tokyo|osaka|kishida)\b": "ja",
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko', r"\b(?:korea|korean|seoul|pyongyang)\b": "ko",
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru', r"\b(?:russia|russian|moscow|kremlin|putin)\b": "ru",
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar', r"\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b": "ar",
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi', r"\b(?:india|indian|delhi|mumbai|modi)\b": "hi",
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de', r"\b(?:germany|german|berlin|munich|scholz)\b": "de",
r'\b(?:france|french|paris|lyon|macron)\b': 'fr', r"\b(?:france|french|paris|lyon|macron)\b": "fr",
r'\b(?:spain|spanish|madrid)\b': 'es', r"\b(?:spain|spanish|madrid)\b": "es",
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it', r"\b(?:italy|italian|rome|milan|meloni)\b": "it",
r'\b(?:portugal|portuguese|lisbon)\b': 'pt', r"\b(?:portugal|portuguese|lisbon)\b": "pt",
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt', r"\b(?:brazil|brazilian|são paulo|lula)\b": "pt",
r'\b(?:greece|greek|athens)\b': 'el', r"\b(?:greece|greek|athens)\b": "el",
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr', r"\b(?:turkey|turkish|istanbul|ankara|erdogan)\b": "tr",
r'\b(?:iran|iranian|tehran)\b': 'fa', r"\b(?:iran|iranian|tehran)\b": "fa",
r'\b(?:thailand|thai|bangkok)\b': 'th', r"\b(?:thailand|thai|bangkok)\b": "th",
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi', r"\b(?:vietnam|vietnamese|hanoi)\b": "vi",
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk', r"\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b": "uk",
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he', r"\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b": "he",
} }
# ─── NON-LATIN SCRIPT FONTS (macOS) ────────────────────── # ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
SCRIPT_FONTS = { SCRIPT_FONTS = {
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc', "zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc', "ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc', "ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf', "ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf', "uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
'el': '/System/Library/Fonts/Supplemental/Arial.ttf', "el": "/System/Library/Fonts/Supplemental/Arial.ttf",
'he': '/System/Library/Fonts/Supplemental/Arial.ttf', "he": "/System/Library/Fonts/Supplemental/Arial.ttf",
'ar': '/System/Library/Fonts/GeezaPro.ttc', "ar": "/System/Library/Fonts/GeezaPro.ttc",
'fa': '/System/Library/Fonts/GeezaPro.ttc', "fa": "/System/Library/Fonts/GeezaPro.ttc",
'hi': '/System/Library/Fonts/Kohinoor.ttc', "hi": "/System/Library/Fonts/Kohinoor.ttc",
'th': '/System/Library/Fonts/ThonburiUI.ttc', "th": "/System/Library/Fonts/ThonburiUI.ttc",
} }
# Scripts that have no uppercase # 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"}

View File

@@ -49,7 +49,7 @@ def type_out(text, color=G_HI):
while i < len(text): while i < len(text):
if random.random() < 0.3: if random.random() < 0.3:
b = random.randint(2, 5) 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 i += b
else: else:
sys.stdout.write(f"{color}{text[i]}{RST}") sys.stdout.write(f"{color}{text[i]}{RST}")

View File

@@ -29,8 +29,10 @@ def translate_headline(title, target_lang):
return _TRANSLATE_CACHE[key] return _TRANSLATE_CACHE[key]
try: try:
q = urllib.parse.quote(title) q = urllib.parse.quote(title)
url = ("https://translate.googleapis.com/translate_a/single" url = (
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}") "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"}) req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=5) resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read()) data = json.loads(resp.read())

25
hk.pkl Normal file
View File

@@ -0,0 +1,25 @@
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"
fix = "ruff check --fix --unsafe-fixes engine/ tests/"
}
}
}
["pre-push"] {
steps {
["ruff"] = (Builtins.ruff) {
prefix = "uv run"
}
}
}
}

View File

@@ -1,5 +1,7 @@
[tools] [tools]
python = "3.12" python = "3.12"
hk = "latest"
pkl = "latest"
[tasks] [tasks]
# ===================== # =====================
@@ -42,3 +44,9 @@ clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml" 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" ci-lint = "uv run ruff check engine/ mainline.py"
# =====================
# Git Hooks (via hk)
# =====================
pre-commit = "hk run pre-commit"

View File

@@ -85,4 +85,4 @@ target-version = "py310"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
ignore = ["E501"] ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]