Merge pull request 'refactor: Change firehose from a drifting overlay to a fixed bottom strip and message display from a top-pinned section to a centered overlay.' (#13) from drift into main

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-03-15 09:27:16 +00:00

View File

@@ -14,34 +14,6 @@ from engine.terminal import RST, W_COOL, CLR, tw, th
from engine.render import big_wrap, lr_gradient, make_block 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 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): def stream(items, ntfy_poller, mic_monitor):
"""Main render loop with four layers: message, ticker, scroll motion, firehose.""" """Main render loop with four layers: message, ticker, scroll motion, firehose."""
@@ -56,25 +28,21 @@ def stream(items, ntfy_poller, mic_monitor):
w, h = tw(), th() w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
ticker_view_h = h # ticker uses full viewport; firehose overlays it ticker_view_h = h - fh # reserve fixed firehose strip at bottom
GAP = 3 # blank rows between headlines GAP = 3 # blank rows between headlines
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2 scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
# Taxonomy: # Taxonomy:
# - message: ntfy interrupt panel at top # - message: centered ntfy overlay panel
# - ticker: large headline text content # - ticker: large headline text content
# - scroll: upward camera motion applied to ticker content # - scroll: upward camera motion applied to ticker content
# - firehose: carriage-return style overlay drifting above ticker # - firehose: fixed carriage-return style strip pinned at bottom
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx) # Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
active = [] active = []
scroll_cam = 0 # viewport top in virtual canvas coords scroll_cam = 0 # viewport top in virtual canvas coords
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom) ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
noise_cache = {} noise_cache = {}
scroll_motion_accum = 0.0 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): def _noise_at(cy):
if cy not in noise_cache: if cy not in noise_cache:
@@ -90,16 +58,17 @@ def stream(items, ntfy_poller, mic_monitor):
t0 = time.monotonic() t0 = time.monotonic()
w, h = tw(), th() w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
ticker_view_h = h ticker_view_h = h - fh
# ── Check for ntfy message ──────────────────────── # ── 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() msg = ntfy_poller.get_active_message()
buf = [] buf = []
if msg is not None: if msg is not None:
m_title, m_body, m_ts = msg 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 = m_body or m_title or "(empty)"
display_text = re.sub(r"\s+", " ", display_text.upper()) display_text = re.sub(r"\s+", " ", display_text.upper())
cache_key = (display_text, w) cache_key = (display_text, w)
@@ -113,10 +82,12 @@ def stream(items, ntfy_poller, mic_monitor):
elapsed_s = int(time.monotonic() - m_ts) elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s) remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%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 row_idx = 0
for mr in msg_rows: for mr in msg_rows:
ln = vis_trunc(mr, w) 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 row_idx += 1
# Meta line: title (if distinct) + source + countdown # Meta line: title (if distinct) + source + countdown
meta_parts = [] meta_parts = []
@@ -124,15 +95,13 @@ def stream(items, ntfy_poller, mic_monitor):
meta_parts.append(m_title) meta_parts.append(m_title)
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s") 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] 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 row_idx += 1
# Border — constant boundary between message and scroll # Border — constant boundary under message panel
bar = "\u2500" * (w - 4) bar = "\u2500" * (w - 4)
buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K") msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
row_idx += 1
msg_h = row_idx
# Effective ticker draw height: below message # Ticker draws above the fixed firehose strip; message is a centered overlay.
ticker_h = ticker_view_h - msg_h ticker_h = ticker_view_h - msg_h
# ── Ticker content + scroll motion (always runs) ── # ── Ticker content + scroll motion (always runs) ──
@@ -156,28 +125,13 @@ def stream(items, ntfy_poller, mic_monitor):
if k < scroll_cam: if k < scroll_cam:
del noise_cache[k] del noise_cache[k]
# Firehose overlay drift (slower than main ticker) # Draw ticker zone (above fixed firehose strip)
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)) top_zone = max(1, int(ticker_h * 0.25))
bot_zone = max(1, int(ticker_h * 0.10)) bot_zone = max(1, int(ticker_h * 0.10))
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
ticker_buf_start = len(buf) # track where ticker rows start in buf ticker_buf_start = len(buf) # track where ticker rows start in buf
for r in range(ticker_h): for r in range(ticker_h):
scr_row = msg_h + r + 1 # 1-indexed ANSI screen row scr_row = r + 1 # 1-indexed ANSI screen row
cy = scroll_cam + r cy = scroll_cam + r
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0 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 bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
@@ -219,16 +173,16 @@ def stream(items, ntfy_poller, mic_monitor):
if random.random() < glitch_prob and ticker_buf_len > 0: if random.random() < glitch_prob and ticker_buf_len > 0:
for _ in range(min(n_hits, ticker_buf_len)): for _ in range(min(n_hits, ticker_buf_len)):
gi = random.randint(0, ticker_buf_len - 1) gi = random.randint(0, ticker_buf_len - 1)
scr_row = msg_h + gi + 1 scr_row = gi + 1
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
if config.FIREHOSE and fh > 0: if config.FIREHOSE and fh > 0:
for fr, fline in enumerate(firehose_rows): for fr in range(fh):
scr_row = firehose_top + fr + 1 scr_row = h - fh + fr + 1
if scr_row <= msg_h or scr_row > h: fline = firehose_line(items, w)
continue buf.append(f"\033[{scr_row};1H{fline}\033[K")
for col, chunk in _overlay_segments(fline, FIREHOSE_BG): if msg_overlay:
buf.append(f"\033[{scr_row};{col}H{chunk}") buf.extend(msg_overlay)
sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush() sys.stdout.flush()