242 lines
9.6 KiB
Python
242 lines
9.6 KiB
Python
"""
|
|
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()
|