From 9bd8115c557deb64f8097e95f845145126dfa475 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sat, 14 Mar 2026 23:36:56 -0700 Subject: [PATCH] feat: introduce the scroll engine with a main rendering loop for headlines, messages, and visual effects. --- engine/app.py | 114 ++++++++++++++++++++++++++++ engine/scroll.py | 188 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 engine/app.py create mode 100644 engine/scroll.py diff --git a/engine/app.py b/engine/app.py new file mode 100644 index 0000000..4a89098 --- /dev/null +++ b/engine/app.py @@ -0,0 +1,114 @@ +""" +Application orchestrator — boot sequence, signal handling, main loop wiring. +""" + +import sys +import time +import signal +import atexit + +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.fetch import fetch_all, fetch_poetry, load_cache, save_cache +from engine.ntfy import NtfyPoller +from engine.mic import MicMonitor +from engine.scroll import stream + +TITLE = [ + " ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗", + " ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝", + " ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ", + " ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ", + " ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗", + " ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝", +] + + +def main(): + atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) + + def handle_sigint(*_): + print(f"\n\n {G_DIM}> SIGNAL LOST{RST}") + print(f" {W_GHOST}> connection terminated{RST}\n") + sys.exit(0) + + signal.signal(signal.SIGINT, handle_sigint) + + w = tw() + print(CLR, end="") + print(CURSOR_OFF, end="") + print() + time.sleep(0.4) + + for ln in TITLE: + print(f"{G_HI}{ln}{RST}") + time.sleep(0.07) + + print() + _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 + if cached: + items = cached + boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True) + 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}{len(items)} STANZAS ACQUIRED{RST}") + save_cache(items) + else: + slow_print(" > INITIALIZING FEED ARRAY...\n") + time.sleep(0.2) + 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}{len(items)} SIGNALS ACQUIRED{RST}") + save_cache(items) + + if not items: + print(f"\n {W_DIM}> NO SIGNAL — check network{RST}") + sys.exit(1) + + print() + 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)) + + ntfy = NtfyPoller( + config.NTFY_TOPIC, + poll_interval=config.NTFY_POLL_INTERVAL, + display_secs=config.MESSAGE_DISPLAY_SECS, + ) + ntfy_ok = ntfy.start() + boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok) + + if config.FIREHOSE: + boot_ln("Firehose", "ENGAGED", True) + + time.sleep(0.4) + slow_print(" > STREAMING...\n") + time.sleep(0.2) + print(f" {W_GHOST}{'─' * (w - 4)}{RST}") + print() + time.sleep(0.4) + + stream(items, ntfy, mic) + + print() + print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}") + print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}") + print(f" {W_GHOST}> end of stream{RST}") + print() diff --git a/engine/scroll.py b/engine/scroll.py new file mode 100644 index 0000000..3466e37 --- /dev/null +++ b/engine/scroll.py @@ -0,0 +1,188 @@ +""" +Scroll engine — the main frame loop with headline rendering and message display. +Depends on: config, terminal, render, effects, ntfy, mic. +""" + +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.render import big_wrap, lr_gradient, make_block +from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line + + +def stream(items, ntfy_poller, mic_monitor): + """Main rendering loop. Scrolls headlines, shows ntfy messages, applies effects.""" + random.shuffle(items) + pool = list(items) + seen = set() + queued = 0 + + time.sleep(0.5) + sys.stdout.write(CLR) + sys.stdout.flush() + + w, h = tw(), th() + fh = config.FIREHOSE_H if config.FIREHOSE else 0 + sh = h - fh # scroll zone height + GAP = 3 # blank rows between headlines + scroll_interval = config.SCROLL_DUR / (sh + 15) * 2 + + # active blocks: (content_rows, color, canvas_y, meta_idx) + active = [] + cam = 0 # viewport top in virtual canvas coords + next_y = sh # canvas-y where next block starts (off-screen bottom) + noise_cache = {} + scroll_accum = 0.0 + + def _noise_at(cy): + if cy not in noise_cache: + noise_cache[cy] = noise(w) if random.random() < 0.15 else None + 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) + + while queued < config.HEADLINE_LIMIT or active: + t0 = time.monotonic() + w, h = tw(), th() + fh = config.FIREHOSE_H if config.FIREHOSE else 0 + sh = h - fh + + # ── Check for ntfy message ──────────────────────── + msg_h = 0 # rows consumed by message zone at top + msg = ntfy_poller.get_active_message() + + buf = [] + if msg is not None: + m_title, m_body, m_ts = msg + # ── Message zone: pinned to top, scroll continues below ── + display_text = m_body or m_title or "(empty)" + display_text = re.sub(r"\s+", " ", display_text.upper()) + cache_key = (display_text, w) + if _msg_cache[0] != cache_key: + msg_rows = big_wrap(display_text, w - 4) + _msg_cache = (cache_key, msg_rows) + else: + msg_rows = _msg_cache[1] + msg_rows = lr_gradient(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) + ts_str = datetime.now().strftime("%H:%M:%S") + row_idx = 0 + for mr in msg_rows: + ln = vis_trunc(mr, w) + buf.append(f"\033[{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] + buf.append(f"\033[{row_idx+1};1H{MSG_META}{meta}{RST}\033[K") + row_idx += 1 + # Border — constant boundary between message and scroll + bar = "\u2500" * (w - 4) + buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K") + row_idx += 1 + msg_h = row_idx + + # Effective scroll zone: below message, above firehose + scroll_h = sh - msg_h + + # ── Scroll: headline rendering (always runs) ────── + # Advance scroll on schedule + scroll_accum += config.FRAME_DT + while scroll_accum >= scroll_interval: + scroll_accum -= scroll_interval + cam += 1 + + # Enqueue new headlines when room at the bottom + while next_y < cam + sh + 10 and queued < config.HEADLINE_LIMIT: + t, src, ts = next_headline(pool, items, seen) + content, hc, midx = make_block(t, src, ts, w) + active.append((content, hc, next_y, midx)) + next_y += len(content) + GAP + 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) > cam] + for k in list(noise_cache): + if k < cam: + del noise_cache[k] + + # Draw scroll zone (below message zone, above firehose) + top_zone = max(1, int(scroll_h * 0.25)) + bot_zone = max(1, int(scroll_h * 0.10)) + grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 + scroll_buf_start = len(buf) # track where scroll rows start in buf + for r in range(scroll_h): + scr_row = msg_h + r + 1 # 1-indexed ANSI screen row + cy = cam + r + top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0 + bot_f = min(1.0, (scroll_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0 + row_fade = min(top_f, bot_f) + drawn = False + for content, hc, by, midx in active: + cr = cy - by + if 0 <= cr < len(content): + raw = content[cr] + if cr != midx: + colored = lr_gradient([raw], grad_offset)[0] + else: + colored = raw + ln = vis_trunc(colored, w) + if row_fade < 1.0: + ln = fade_line(ln, row_fade) + if cr == midx: + buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K") + elif ln.strip(): + buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K") + else: + buf.append(f"\033[{scr_row};1H\033[K") + drawn = True + break + if not drawn: + n = _noise_at(cy) + if row_fade < 1.0 and n: + n = fade_line(n, row_fade) + if n: + buf.append(f"\033[{scr_row};1H{n}") + else: + buf.append(f"\033[{scr_row};1H\033[K") + + # Draw firehose zone + if config.FIREHOSE and fh > 0: + for fr in range(fh): + fline = firehose_line(items, w) + buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K") + + # Glitch — base rate + mic-reactive spikes (scroll zone only) + mic_excess = mic_monitor.excess + glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) + n_hits = 4 + int(mic_excess / 2) + scroll_buf_len = len(buf) - scroll_buf_start + if random.random() < glitch_prob and scroll_buf_len > 0: + for _ in range(min(n_hits, scroll_buf_len)): + gi = random.randint(0, scroll_buf_len - 1) + scr_row = msg_h + gi + 1 + buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" + + sys.stdout.buffer.write("".join(buf).encode()) + sys.stdout.flush() + + # Precise frame timing + elapsed = time.monotonic() - t0 + time.sleep(max(0, config.FRAME_DT - elapsed)) + + sys.stdout.write(CLR) + sys.stdout.flush()