""" Render engine — ticker content, scroll motion, message panel, and firehose overlay. Orchestrates viewport, frame timing, and layers. """ import random import time from engine import config from engine.camera import Camera from engine.display import ( Display, TerminalDisplay, ) from engine.display import ( get_monitor as _get_display_monitor, ) from engine.frame import calculate_scroll_step from engine.layers import ( apply_glitch, process_effects, render_firehose, render_message_overlay, render_ticker_zone, ) from engine.viewport import th, tw USE_EFFECT_CHAIN = True def stream( items, ntfy_poller, mic_monitor, display: Display | None = None, camera: Camera | None = None, ): """Main render loop with four layers: message, ticker, scroll motion, firehose.""" if display is None: display = TerminalDisplay() if camera is None: camera = Camera.vertical() random.shuffle(items) pool = list(items) seen = set() queued = 0 time.sleep(0.5) w, h = tw(), th() display.init(w, h) display.clear() fh = config.FIREHOSE_H if config.FIREHOSE else 0 ticker_view_h = h - fh GAP = 3 scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h) active = [] ticker_next_y = ticker_view_h noise_cache = {} scroll_motion_accum = 0.0 msg_cache = (None, None) frame_number = 0 while True: if queued >= config.HEADLINE_LIMIT and not active: break t0 = time.monotonic() w, h = tw(), th() fh = config.FIREHOSE_H if config.FIREHOSE else 0 ticker_view_h = h - fh scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h) msg = ntfy_poller.get_active_message() msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache) buf = [] ticker_h = ticker_view_h scroll_motion_accum += config.FRAME_DT while scroll_motion_accum >= scroll_step_interval: scroll_motion_accum -= scroll_step_interval camera.update(config.FRAME_DT) while ( ticker_next_y < camera.y + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT ): from engine.effects import next_headline from engine.render import make_block 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 active = [ (c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > camera.y ] for k in list(noise_cache): if k < camera.y: del noise_cache[k] grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 ticker_buf_start = len(buf) ticker_buf, noise_cache = render_ticker_zone( active, camera.y, camera.x, ticker_h, w, noise_cache, grad_offset ) buf.extend(ticker_buf) mic_excess = mic_monitor.excess render_start = time.perf_counter() if USE_EFFECT_CHAIN: buf = process_effects( buf, w, h, camera.y, ticker_h, camera.x, mic_excess, grad_offset, frame_number, msg is not None, items, ) else: buf = apply_glitch(buf, ticker_buf_start, mic_excess, w) firehose_buf = render_firehose(items, w, fh, h) buf.extend(firehose_buf) if msg_overlay: buf.extend(msg_overlay) render_elapsed = (time.perf_counter() - render_start) * 1000 monitor = _get_display_monitor() if monitor: chars = sum(len(line) for line in buf) monitor.record_effect("render", render_elapsed, chars, chars) display.show(buf) elapsed = time.monotonic() - t0 time.sleep(max(0, config.FRAME_DT - elapsed)) frame_number += 1 display.cleanup()