feat: Implement a top-pinned ntfy message banner that reduces scrollable area instead of freezing the display.

This commit is contained in:
2026-03-14 22:02:28 -07:00
parent 7274f57bbb
commit 424332e065

View File

@@ -848,6 +848,7 @@ def stream(items):
sh = h - fh sh = h - fh
# ── Check for ntfy message ──────────────────────── # ── Check for ntfy message ────────────────────────
msg_h = 0 # rows consumed by message zone at top
msg_active = False msg_active = False
with _ntfy_lock: with _ntfy_lock:
if _ntfy_message is not None: if _ntfy_message is not None:
@@ -857,10 +858,9 @@ def stream(items):
else: else:
_ntfy_message = None # expired _ntfy_message = None # expired
buf = []
if msg_active: if msg_active:
# ── MESSAGE state: freeze scroll, render message ── # ── Message zone: pinned to top, scroll continues below ──
buf = []
# Render message text with OTF font (cached across frames)
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)
@@ -870,50 +870,33 @@ def stream(items):
else: else:
msg_rows = _msg_cache[1] msg_rows = _msg_cache[1]
msg_rows = _lr_gradient(msg_rows, (time.monotonic() * GRAD_SPEED) % 1.0) msg_rows = _lr_gradient(msg_rows, (time.monotonic() * GRAD_SPEED) % 1.0)
# Center vertically in scroll zone # Layout: rendered text + meta + border
total_h = len(msg_rows) + 4 # +4 for border + meta + padding elapsed_s = int(time.monotonic() - m_ts)
y_off = max(0, (sh - total_h) // 2) remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s)
for r in range(sh): ts_str = datetime.now().strftime("%H:%M:%S")
ri = r - y_off row_idx = 0
if ri == 0 or ri == total_h - 1: for mr in msg_rows:
# Border lines ln = _vis_trunc(mr, w)
bar = "" * (w - 4) buf.append(f"\033[{row_idx+1};1H {ln}{RST}\033[K")
buf.append(f"\033[{r+1};1H {MSG_BORDER}{bar}{RST}\033[K") row_idx += 1
elif 1 <= ri <= len(msg_rows): # Meta line: title (if distinct) + source + countdown
ln = _vis_trunc(msg_rows[ri - 1], w) meta_parts = []
buf.append(f"\033[{r+1};1H {ln}{RST}\033[K") if m_title and m_title != m_body:
elif ri == len(msg_rows) + 1: meta_parts.append(m_title)
# Title line (if present and different from body) meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
if m_title and m_title != m_body: meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
meta = f" {MSG_META}\u2591 {m_title}{RST}" buf.append(f"\033[{row_idx+1};1H{MSG_META}{meta}{RST}\033[K")
else: row_idx += 1
meta = "" # Border — constant boundary between message and scroll
buf.append(f"\033[{r+1};1H{meta}\033[K") bar = "\u2500" * (w - 4)
elif ri == len(msg_rows) + 2: buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K")
# Source + timestamp row_idx += 1
elapsed_s = int(time.monotonic() - m_ts) msg_h = row_idx
remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S")
meta = f" {MSG_META}\u2591 ntfy \u00b7 {ts_str} \u00b7 {remaining}s{RST}"
buf.append(f"\033[{r+1};1H{meta}\033[K")
else:
# Sparse noise outside the message
if random.random() < 0.06:
buf.append(f"\033[{r+1};1H{noise(w)}")
else:
buf.append(f"\033[{r+1};1H\033[K")
# Firehose keeps running during messages
if 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")
sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush()
elapsed = time.monotonic() - t0
time.sleep(max(0, _FRAME_DT - elapsed))
continue
# ── SCROLL state: normal headline rendering ─────── # Effective scroll zone: below message, above firehose
scroll_h = sh - msg_h
# ── Scroll: headline rendering (always runs) ──────
# Advance scroll on schedule # Advance scroll on schedule
scroll_accum += _FRAME_DT scroll_accum += _FRAME_DT
while scroll_accum >= scroll_interval: while scroll_accum >= scroll_interval:
@@ -935,15 +918,16 @@ def stream(items):
if k < cam: if k < cam:
del noise_cache[k] del noise_cache[k]
# Draw scroll zone # Draw scroll zone (below message zone, above firehose)
top_zone = max(1, int(sh * 0.25)) top_zone = max(1, int(scroll_h * 0.25))
bot_zone = max(1, int(sh * 0.10)) bot_zone = max(1, int(scroll_h * 0.10))
grad_offset = (time.monotonic() * GRAD_SPEED) % 1.0 grad_offset = (time.monotonic() * GRAD_SPEED) % 1.0
buf = [] scroll_buf_start = len(buf) # track where scroll rows start in buf
for r in range(sh): for r in range(scroll_h):
scr_row = msg_h + r + 1 # 1-indexed ANSI screen row
cy = cam + r cy = cam + r
top_f = min(1.0, r / top_zone) top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
bot_f = min(1.0, (sh - 1 - r) / bot_zone) bot_f = min(1.0, (scroll_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
row_fade = min(top_f, bot_f) row_fade = min(top_f, bot_f)
drawn = False drawn = False
for content, hc, by, midx in active: for content, hc, by, midx in active:
@@ -958,11 +942,11 @@ def stream(items):
if row_fade < 1.0: if row_fade < 1.0:
ln = _fade_line(ln, row_fade) ln = _fade_line(ln, row_fade)
if cr == midx: if cr == midx:
buf.append(f"\033[{r+1};1H{W_COOL}{ln}{RST}\033[K") buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
elif ln.strip(): elif ln.strip():
buf.append(f"\033[{r+1};1H{ln}{RST}\033[K") buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
else: else:
buf.append(f"\033[{r+1};1H\033[K") buf.append(f"\033[{scr_row};1H\033[K")
drawn = True drawn = True
break break
if not drawn: if not drawn:
@@ -970,9 +954,9 @@ def stream(items):
if row_fade < 1.0 and n: if row_fade < 1.0 and n:
n = _fade_line(n, row_fade) n = _fade_line(n, row_fade)
if n: if n:
buf.append(f"\033[{r+1};1H{n}") buf.append(f"\033[{scr_row};1H{n}")
else: else:
buf.append(f"\033[{r+1};1H\033[K") buf.append(f"\033[{scr_row};1H\033[K")
# Draw firehose zone # Draw firehose zone
if FIREHOSE and fh > 0: if FIREHOSE and fh > 0:
@@ -984,11 +968,12 @@ def stream(items):
mic_excess = max(0.0, _mic_db - MIC_THRESHOLD_DB) mic_excess = max(0.0, _mic_db - MIC_THRESHOLD_DB)
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
n_hits = 4 + int(mic_excess / 2) n_hits = 4 + int(mic_excess / 2)
g_limit = sh if FIREHOSE else len(buf) scroll_buf_len = len(buf) - scroll_buf_start
if random.random() < glitch_prob and g_limit > 0: if random.random() < glitch_prob and scroll_buf_len > 0:
for _ in range(min(n_hits, g_limit)): for _ in range(min(n_hits, scroll_buf_len)):
gi = random.randint(0, g_limit - 1) gi = random.randint(0, scroll_buf_len - 1)
buf[gi] = f"\033[{gi+1};1H{glitch_bar(w)}" scr_row = msg_h + gi + 1
buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush() sys.stdout.flush()