5 Commits

View File

@@ -1,5 +1,5 @@
"""
Scroll engine — the main frame loop with headline rendering and message display.
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
Depends on: config, terminal, render, effects, ntfy, mic.
"""
@@ -16,7 +16,7 @@ from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headlin
def stream(items, ntfy_poller, mic_monitor):
"""Main rendering loop. Scrolls headlines, shows ntfy messages, applies effects."""
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
random.shuffle(items)
pool = list(items)
seen = set()
@@ -28,16 +28,21 @@ def stream(items, ntfy_poller, mic_monitor):
w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0
sh = h - fh # scroll zone height
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
GAP = 3 # blank rows between headlines
scroll_interval = config.SCROLL_DUR / (sh + 15) * 2
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
# active blocks: (content_rows, color, canvas_y, meta_idx)
# Taxonomy:
# - message: centered ntfy overlay panel
# - ticker: large headline text content
# - scroll: upward camera motion applied to ticker content
# - firehose: fixed carriage-return style strip pinned at bottom
# Active ticker 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)
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_accum = 0.0
scroll_motion_accum = 0.0
def _noise_at(cy):
if cy not in noise_cache:
@@ -53,16 +58,17 @@ def stream(items, ntfy_poller, mic_monitor):
t0 = time.monotonic()
w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0
sh = h - fh
ticker_view_h = h - fh
# ── Check for ntfy message ────────────────────────
msg_h = 0 # rows consumed by message zone at top
msg_h = 0
msg_overlay = []
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 ──
# ── Message overlay: centered in the viewport ──
display_text = m_body or m_title or "(empty)"
display_text = re.sub(r"\s+", " ", display_text.upper())
cache_key = (display_text, w)
@@ -76,10 +82,12 @@ def stream(items, ntfy_poller, mic_monitor):
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S")
panel_h = len(msg_rows) + 2 # meta + border
panel_top = max(0, (h - panel_h) // 2)
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")
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
row_idx += 1
# Meta line: title (if distinct) + source + countdown
meta_parts = []
@@ -87,49 +95,46 @@ def stream(items, ntfy_poller, mic_monitor):
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")
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
row_idx += 1
# Border — constant boundary between message and scroll
# Border — constant boundary under message panel
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
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
# Effective scroll zone: below message, above firehose
scroll_h = sh - msg_h
# Ticker draws above the fixed firehose strip; message is a centered overlay.
ticker_h = ticker_view_h - 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
# ── 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 next_y < cam + sh + 10 and queued < config.HEADLINE_LIMIT:
while ticker_next_y < scroll_cam + ticker_view_h + 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
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) > cam]
if by + len(c) > scroll_cam]
for k in list(noise_cache):
if k < cam:
if k < scroll_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))
# Draw ticker zone (above fixed firehose strip)
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
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
ticker_buf_start = len(buf) # track where ticker rows start in buf
for r in range(ticker_h):
scr_row = 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, (scroll_h - 1 - r) / bot_zone) if bot_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:
@@ -160,22 +165,24 @@ def stream(items, ntfy_poller, mic_monitor):
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)
# 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)
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)}"
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 = gi + 1
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
if config.FIREHOSE and fh > 0:
for fr in range(fh):
scr_row = h - fh + fr + 1
fline = firehose_line(items, w)
buf.append(f"\033[{scr_row};1H{fline}\033[K")
if msg_overlay:
buf.extend(msg_overlay)
sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush()