From 39dab4b22bca0caff89a15a656a5ece6d8ccb49c Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sun, 15 Mar 2026 00:49:58 -0700 Subject: [PATCH] feat: Implement a drifting firehose overlay that scrolls independently over the main ticker content. --- engine/scroll.py | 64 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/engine/scroll.py b/engine/scroll.py index 3466e37..eedc57f 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -14,6 +14,30 @@ 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)$") + + +def _overlay_segments(ansi_line): + """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] + segs.append((i + 1, f"{color}{chunk}{RST}" if color else chunk)) + i = j + return segs + def stream(items, ntfy_poller, mic_monitor): """Main rendering loop. Scrolls headlines, shows ntfy messages, applies effects.""" @@ -28,7 +52,7 @@ 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 + sh = h # headline scroll uses full viewport GAP = 3 # blank rows between headlines scroll_interval = config.SCROLL_DUR / (sh + 15) * 2 @@ -38,6 +62,10 @@ def stream(items, ntfy_poller, mic_monitor): next_y = sh # canvas-y where next block starts (off-screen bottom) noise_cache = {} scroll_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_interval * 1.3) def _noise_at(cy): if cy not in noise_cache: @@ -53,7 +81,7 @@ 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 + sh = h # ── Check for ntfy message ──────────────────────── msg_h = 0 # rows consumed by message zone at top @@ -95,7 +123,7 @@ def stream(items, ntfy_poller, mic_monitor): row_idx += 1 msg_h = row_idx - # Effective scroll zone: below message, above firehose + # Effective ticker zone: below message scroll_h = sh - msg_h # ── Scroll: headline rendering (always runs) ────── @@ -120,7 +148,22 @@ def stream(items, ntfy_poller, mic_monitor): if k < cam: del noise_cache[k] - # Draw scroll zone (below message zone, above firehose) + # 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(scroll_h * 0.25)) bot_zone = max(1, int(scroll_h * 0.10)) grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 @@ -160,11 +203,6 @@ 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) mic_excess = mic_monitor.excess @@ -177,6 +215,14 @@ def stream(items, ntfy_poller, mic_monitor): scr_row = msg_h + gi + 1 buf[scroll_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): + buf.append(f"\033[{scr_row};{col}H{chunk}") + sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.flush()