""" 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.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_figment_overlay, 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): """Main render loop with four layers: message, ticker, scroll motion, firehose.""" if display is None: display = TerminalDisplay() 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 = [] scroll_cam = 0 ticker_next_y = ticker_view_h noise_cache = {} scroll_motion_accum = 0.0 msg_cache = (None, None) frame_number = 0 # Figment overlay (optional — requires cairosvg) try: from effects_plugins.figment import FigmentEffect from engine.effects.registry import get_registry _fg_plugin = get_registry().get("figment") figment = _fg_plugin if isinstance(_fg_plugin, FigmentEffect) else None except ImportError: figment = None 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 scroll_cam += 1 while ( ticker_next_y < scroll_cam + 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) > scroll_cam ] for k in list(noise_cache): if k < scroll_cam: 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, scroll_cam, 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, scroll_cam, ticker_h, 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) # Figment overlay (between effects and ntfy message) if figment and figment.config.enabled: figment_state = figment.get_figment_state(frame_number, w, h) if figment_state is not None: figment_buf = render_figment_overlay(figment_state, w, h) buf.extend(figment_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()