""" Render engine — ticker content, scroll motion, message panel, and firehose overlay. 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 _ANSI_LINE_RE = re.compile(r"^(\033\[[0-9;]*m)(.*)(\033\[0m)$") FIREHOSE_BG = "" def _overlay_segments(ansi_line, bg=""): """Return (1-indexed column, colored chunk) for non-space runs in a line.""" m = _ANSI_LINE_RE.match(ansi_line) if m: color, text, _ = m.groups() else: color, text = "", ansi_line segs = [] i = 0 while i < len(text): if text[i] == " ": i += 1 continue j = i + 1 while j < len(text) and text[j] != " ": j += 1 chunk = text[i:j] if bg or color: segs.append((i + 1, f"{bg}{color}{chunk}{RST}")) else: segs.append((i + 1, chunk)) i = j return segs def stream(items, ntfy_poller, mic_monitor): """Main render loop with four layers: message, ticker, scroll motion, firehose.""" 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 ticker_view_h = h # ticker uses full viewport; firehose overlays it GAP = 3 # blank rows between headlines scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2 # Taxonomy: # - message: ntfy interrupt panel at top # - ticker: large headline text content # - scroll: upward camera motion applied to ticker content # - firehose: carriage-return style overlay drifting above ticker # 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) noise_cache = {} scroll_motion_accum = 0.0 firehose_top = h - fh firehose_rows = [firehose_line(items, w) for _ in range(fh)] if fh > 0 else [] firehose_accum = 0.0 firehose_interval = max(config.FRAME_DT, scroll_step_interval * 1.3) 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 ticker_view_h = h # ── 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 ticker draw height: below message ticker_h = ticker_view_h - msg_h # ── Ticker content + scroll motion (always runs) ── scroll_motion_accum += config.FRAME_DT while scroll_motion_accum >= scroll_step_interval: scroll_motion_accum -= scroll_step_interval 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: 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)) ticker_next_y += len(ticker_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) > scroll_cam] for k in list(noise_cache): if k < scroll_cam: del noise_cache[k] # Firehose overlay drift (slower than main ticker) if config.FIREHOSE and fh > 0: while len(firehose_rows) < fh: firehose_rows.append(firehose_line(items, w)) if len(firehose_rows) > fh: firehose_rows = firehose_rows[-fh:] firehose_accum += config.FRAME_DT while firehose_accum >= firehose_interval: firehose_accum -= firehose_interval firehose_top -= 1 firehose_rows.pop(0) firehose_rows.append(firehose_line(items, w)) if firehose_top + fh < 0: firehose_top = h - fh # Draw ticker zone (below message zone) top_zone = max(1, int(ticker_h * 0.25)) bot_zone = max(1, int(ticker_h * 0.10)) grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 ticker_buf_start = len(buf) # track where ticker rows start in buf for r in range(ticker_h): scr_row = msg_h + r + 1 # 1-indexed ANSI screen row cy = scroll_cam + r top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0 bot_f = min(1.0, (ticker_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") # Glitch — base rate + mic-reactive spikes (ticker 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) ticker_buf_len = len(buf) - ticker_buf_start if random.random() < glitch_prob and ticker_buf_len > 0: for _ in range(min(n_hits, ticker_buf_len)): gi = random.randint(0, ticker_buf_len - 1) scr_row = msg_h + gi + 1 buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" if config.FIREHOSE and fh > 0: for fr, fline in enumerate(firehose_rows): scr_row = firehose_top + fr + 1 if scr_row <= msg_h or scr_row > h: continue for col, chunk in _overlay_segments(fline, FIREHOSE_BG): buf.append(f"\033[{scr_row};{col}H{chunk}") 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()