diff --git a/README.md b/README.md index c6a7ac8..5d0d429 100644 --- a/README.md +++ b/README.md @@ -155,3 +155,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None --- *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 diff --git a/engine/app.py b/engine/app.py index 84b68e7..ec36e11 100644 --- a/engine/app.py +++ b/engine/app.py @@ -39,6 +39,7 @@ TITLE = [ " ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝", ] + def _read_picker_key(): ch = sys.stdin.read(1) if ch == "\x03": @@ -63,6 +64,7 @@ def _read_picker_key(): 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()] @@ -109,7 +111,9 @@ def _draw_font_picker(faces, selected): 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}") + print( + f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}" + ) if top > 0: print(f" {W_GHOST}… {top} above{RST}") @@ -126,6 +130,7 @@ def _draw_font_picker(faces, selected): 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: @@ -235,7 +240,9 @@ def pick_font_face(): font_index=selected_font["font_index"], ) 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) print(CLR, end="") print(CURSOR_OFF, end="") @@ -265,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: @@ -290,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) @@ -302,7 +317,13 @@ 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, diff --git a/engine/config.py b/engine/config.py index 29b9e23..2174568 100644 --- a/engine/config.py +++ b/engine/config.py @@ -51,40 +51,42 @@ def _list_font_files(font_dir): 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" NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream -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_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts") -_FONT_FILE_ARG = _arg_value('--font-file') +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_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 = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋" diff --git a/engine/effects.py b/engine/effects.py index 3edb265..92ca9ec 100644 --- a/engine/effects.py +++ b/engine/effects.py @@ -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}" diff --git a/engine/fetch.py b/engine/fetch.py index 1c55861..a236c6e 100644 --- a/engine/fetch.py +++ b/engine/fetch.py @@ -64,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 [] diff --git a/engine/filter.py b/engine/filter.py index 4725493..8e41376 100644 --- a/engine/filter.py +++ b/engine/filter.py @@ -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, ) diff --git a/engine/mic.py b/engine/mic.py index 7d09740..b8c5175 100644 --- a/engine/mic.py +++ b/engine/mic.py @@ -8,6 +8,7 @@ import atexit try: 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 diff --git a/engine/ntfy.py b/engine/ntfy.py index e38e41b..b7f971d 100644 --- a/engine/ntfy.py +++ b/engine/ntfy.py @@ -26,7 +26,7 @@ class NtfyPoller: self.topic_url = topic_url 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): @@ -55,7 +55,7 @@ class NtfyPoller: """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'] + 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)) @@ -65,7 +65,8 @@ class NtfyPoller: try: url = self._build_url(last_id) req = urllib.request.Request( - url, headers={"User-Agent": "mainline/0.1"}) + 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: @@ -73,7 +74,7 @@ class NtfyPoller: if not line: break # server closed connection — reconnect try: - data = json.loads(line.decode('utf-8', errors='replace')) + data = json.loads(line.decode("utf-8", errors="replace")) except json.JSONDecodeError: continue # Advance cursor on every event (message + keepalive) to diff --git a/engine/render.py b/engine/render.py index 3025601..4b24eef 100644 --- a/engine/render.py +++ b/engine/render.py @@ -20,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 ] @@ -36,15 +36,15 @@ 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 ] @@ -69,6 +69,7 @@ def font(): _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 @@ -123,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 @@ -200,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) @@ -218,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) @@ -227,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}" diff --git a/engine/scroll.py b/engine/scroll.py index d5e815c..810fe9f 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -35,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: @@ -46,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 @@ -57,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() @@ -84,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) @@ -94,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 @@ -118,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)) @@ -126,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] diff --git a/engine/sources.py b/engine/sources.py index d7b6733..650117d 100644 --- a/engine/sources.py +++ b/engine/sources.py @@ -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"} diff --git a/engine/terminal.py b/engine/terminal.py index 4194e5e..c657d47 100644 --- a/engine/terminal.py +++ b/engine/terminal.py @@ -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}") diff --git a/engine/translate.py b/engine/translate.py index ab4c273..eb1f2ca 100644 --- a/engine/translate.py +++ b/engine/translate.py @@ -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()) diff --git a/hk.pkl b/hk.pkl new file mode 100644 index 0000000..964f9ad --- /dev/null +++ b/hk.pkl @@ -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" + } + } + } +} diff --git a/mise.toml b/mise.toml index 0dcba9e..32f7c59 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,7 @@ [tools] python = "3.12" +hk = "latest" +pkl = "latest" [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-lint = "uv run ruff check engine/ mainline.py" + +# ===================== +# Git Hooks (via hk) +# ===================== + +pre-commit = "hk run pre-commit" diff --git a/pyproject.toml b/pyproject.toml index 910cea3..6d93f42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,4 +85,4 @@ target-version = "py310" [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] -ignore = ["E501"] +ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]