refactor: phase 2 - modularization of scroll engine
Split monolithic scroll.py into focused modules: - viewport.py: terminal size (tw/th), ANSI positioning helpers - frame.py: FrameTimer class, scroll step calculation - layers.py: message overlay, ticker zone, firehose rendering - scroll.py: simplified orchestrator, imports from new modules Add stream controller and event types for future event-driven architecture: - controller.py: StreamController for source initialization and stream lifecycle - events.py: EventType enum and event dataclasses (HeadlineEvent, FrameTickEvent, etc.) Added tests for new modules: - test_viewport.py: 8 tests for viewport utilities - test_frame.py: 10 tests for frame timing - test_layers.py: 13 tests for layer compositing - test_events.py: 11 tests for event types - test_controller.py: 6 tests for stream controller This enables: - Testable chunks with clear responsibilities - Reusable viewport utilities across modules - Better separation of concerns in render pipeline - Foundation for future event-driven architecture Also includes Phase 1 documentation updates in code comments.
This commit is contained in:
173
engine/scroll.py
173
engine/scroll.py
@@ -1,25 +1,22 @@
|
||||
"""
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||
Orchestrates viewport, frame timing, and layers.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||
from engine.terminal import CLR
|
||||
from engine.viewport import th, tw
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
@@ -35,33 +32,16 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||
ticker_view_h = h - fh
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
# 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 = []
|
||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||
ticker_next_y = (
|
||||
ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||
)
|
||||
scroll_cam = 0
|
||||
ticker_next_y = ticker_view_h
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
|
||||
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)
|
||||
msg_cache = (None, None)
|
||||
|
||||
while queued < config.HEADLINE_LIMIT or active:
|
||||
t0 = time.monotonic()
|
||||
@@ -69,80 +49,30 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
|
||||
# ── Check for ntfy message ────────────────────────
|
||||
msg_h = 0
|
||||
msg_overlay = []
|
||||
msg = ntfy_poller.get_active_message()
|
||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||
|
||||
buf = []
|
||||
if msg is not None:
|
||||
m_title, m_body, m_ts = msg
|
||||
# ── 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)
|
||||
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_opposite(
|
||||
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")
|
||||
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)
|
||||
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 = []
|
||||
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]
|
||||
)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Border — constant boundary under message panel
|
||||
bar = "\u2500" * (w - 4)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
|
||||
)
|
||||
ticker_h = ticker_view_h
|
||||
|
||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||
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
|
||||
):
|
||||
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
|
||||
|
||||
# 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
|
||||
]
|
||||
@@ -150,69 +80,26 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
# 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
|
||||
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, (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")
|
||||
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)
|
||||
|
||||
# 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 = gi + 1
|
||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
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()
|
||||
|
||||
# Precise frame timing
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user