From 39dab4b22bca0caff89a15a656a5ece6d8ccb49c Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sun, 15 Mar 2026 00:49:58 -0700 Subject: [PATCH 1/4] 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() From b00b612da0965237266cead5c73dc681bb2d0c2b Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sun, 15 Mar 2026 00:58:36 -0700 Subject: [PATCH 2/4] refactor: rename rendering components and variables for clarity, distinguishing between message, ticker, and scroll motion layers. --- engine/scroll.py | 77 +++++++++++++++++++++++++----------------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/engine/scroll.py b/engine/scroll.py index eedc57f..4deb47d 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -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. """ @@ -40,7 +40,7 @@ def _overlay_segments(ansi_line): 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() @@ -52,20 +52,25 @@ def stream(items, ntfy_poller, mic_monitor): w, h = tw(), th() fh = config.FIREHOSE_H if config.FIREHOSE else 0 - sh = h # headline scroll uses full viewport + ticker_view_h = h # ticker uses full viewport; firehose overlays it 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: ntfy interrupt panel at top + # - ticker: large headline text content + # - scroll: upward camera motion applied to ticker content + # - firehose: carriage-return style overlay drifting above ticker + # 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 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) + firehose_interval = max(config.FRAME_DT, scroll_step_interval * 1.3) def _noise_at(cy): if cy not in noise_cache: @@ -81,7 +86,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 + ticker_view_h = h # ── Check for ntfy message ──────────────────────── msg_h = 0 # rows consumed by message zone at top @@ -123,29 +128,28 @@ def stream(items, ntfy_poller, mic_monitor): row_idx += 1 msg_h = row_idx - # Effective ticker zone: below message - scroll_h = sh - msg_h + # Effective ticker draw height: below message + 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] # Firehose overlay drift (slower than main ticker) @@ -164,15 +168,15 @@ def stream(items, ntfy_poller, mic_monitor): 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)) + 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): + ticker_buf_start = len(buf) # track where ticker rows start in buf + for r in range(ticker_h): scr_row = msg_h + r + 1 # 1-indexed ANSI screen row - cy = cam + r + 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: @@ -203,17 +207,16 @@ def stream(items, ntfy_poller, mic_monitor): else: buf.append(f"\033[{scr_row};1H\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) + 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 = msg_h + gi + 1 - buf[scroll_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: for fr, fline in enumerate(firehose_rows): From 0f762475b58feb0366876156c6b0fa61063e8bd4 Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sun, 15 Mar 2026 01:08:17 -0700 Subject: [PATCH 3/4] feat: Apply a distinct background color to firehose lines. --- engine/scroll.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/engine/scroll.py b/engine/scroll.py index 4deb47d..e6738e3 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -15,9 +15,10 @@ 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)$") +FIREHOSE_BG = "\033[48;5;233m" -def _overlay_segments(ansi_line): +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: @@ -34,7 +35,10 @@ def _overlay_segments(ansi_line): 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)) + if bg or color: + segs.append((i + 1, f"{bg}{color}{chunk}{RST}")) + else: + segs.append((i + 1, chunk)) i = j return segs @@ -223,7 +227,9 @@ def stream(items, ntfy_poller, mic_monitor): scr_row = firehose_top + fr + 1 if scr_row <= msg_h or scr_row > h: continue - for col, chunk in _overlay_segments(fline): + # Opaque row backdrop to distinguish firehose from ticker beneath it. + buf.append(f"\033[{scr_row};1H{FIREHOSE_BG}{' ' * w}{RST}") + for col, chunk in _overlay_segments(fline, FIREHOSE_BG): buf.append(f"\033[{scr_row};{col}H{chunk}") sys.stdout.buffer.write("".join(buf).encode()) From 086214f05efe055fd318f085cea1dafc0c75856b Mon Sep 17 00:00:00 2001 From: Gene Johnson Date: Sun, 15 Mar 2026 01:31:44 -0700 Subject: [PATCH 4/4] style: remove firehose opaque row backdrop and background color --- engine/scroll.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/engine/scroll.py b/engine/scroll.py index e6738e3..3926989 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -15,7 +15,7 @@ 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)$") -FIREHOSE_BG = "\033[48;5;233m" +FIREHOSE_BG = "" def _overlay_segments(ansi_line, bg=""): @@ -227,8 +227,6 @@ def stream(items, ntfy_poller, mic_monitor): scr_row = firehose_top + fr + 1 if scr_row <= msg_h or scr_row > h: continue - # Opaque row backdrop to distinguish firehose from ticker beneath it. - buf.append(f"\033[{scr_row};1H{FIREHOSE_BG}{' ' * w}{RST}") for col, chunk in _overlay_segments(fline, FIREHOSE_BG): buf.append(f"\033[{scr_row};{col}H{chunk}")