Files
sideline/engine/pipeline/adapters/message_overlay.py
David Gwilliam fc7f58685a fix: Update imports to use engine.pipeline instead of engine.pipeline.core
The old engine/pipeline/core.py file was removed as part of the Sideline/Mainline split.
All imports that referenced engine.pipeline.core have been updated to use engine.pipeline
which re-exports from sideline.pipeline.core.

This ensures consistency and avoids duplicate DataType enum instances.
2026-03-23 20:45:40 -07:00

186 lines
5.6 KiB
Python

"""
Message overlay stage - Renders ntfy messages as an overlay on the buffer.
This stage provides message overlay capability for displaying ntfy.sh messages
as a centered panel with pink/magenta gradient, matching upstream/main aesthetics.
"""
import re
import time
from dataclasses import dataclass
from datetime import datetime
from engine import config
from engine.effects.legacy import vis_trunc
from engine.pipeline import DataType, PipelineContext, Stage
from engine.render.blocks import big_wrap
from engine.render.gradient import msg_gradient
@dataclass
class MessageOverlayConfig:
"""Configuration for MessageOverlayStage."""
enabled: bool = True
display_secs: int = 30 # How long to display messages
topic_url: str | None = None # Ntfy topic URL (None = use config default)
class MessageOverlayStage(Stage):
"""Stage that renders ntfy message overlay on the buffer.
Provides:
- message.overlay capability (optional)
- Renders centered panel with pink/magenta gradient
- Shows title, body, timestamp, and remaining time
"""
name = "message_overlay"
category = "overlay"
def __init__(
self, config: MessageOverlayConfig | None = None, name: str = "message_overlay"
):
self.config = config or MessageOverlayConfig()
self._ntfy_poller = None
self._msg_cache = (None, None) # (cache_key, rendered_rows)
@property
def capabilities(self) -> set[str]:
"""Provides message overlay capability."""
return {"message.overlay"} if self.config.enabled else set()
@property
def dependencies(self) -> set[str]:
"""Needs rendered buffer and camera transformation to overlay onto."""
return {"render.output", "camera"}
@property
def inlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def init(self, ctx: PipelineContext) -> bool:
"""Initialize ntfy poller if topic URL is configured."""
if not self.config.enabled:
return True
# Get or create ntfy poller
topic_url = self.config.topic_url or config.NTFY_TOPIC
if topic_url:
from engine.ntfy import NtfyPoller
self._ntfy_poller = NtfyPoller(
topic_url=topic_url,
reconnect_delay=getattr(config, "NTFY_RECONNECT_DELAY", 5),
display_secs=self.config.display_secs,
)
self._ntfy_poller.start()
ctx.set("ntfy_poller", self._ntfy_poller)
return True
def process(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Render message overlay on the buffer."""
if not self.config.enabled or not data:
return data
# Get active message from poller
msg = None
if self._ntfy_poller:
msg = self._ntfy_poller.get_active_message()
if msg is None:
return data
# Render overlay
w = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
h = ctx.terminal_height if hasattr(ctx, "terminal_height") else 24
overlay, self._msg_cache = self._render_message_overlay(
msg, w, h, self._msg_cache
)
# Composite overlay onto buffer
result = list(data)
for line in overlay:
# Overlay uses ANSI cursor positioning, just append
result.append(line)
return result
def _render_message_overlay(
self,
msg: tuple[str, str, float] | None,
w: int,
h: int,
msg_cache: tuple,
) -> tuple[list[str], tuple]:
"""Render ntfy message overlay.
Args:
msg: (title, body, timestamp) or None
w: terminal width
h: terminal height
msg_cache: (cache_key, rendered_rows) for caching
Returns:
(list of ANSI strings, updated cache)
"""
overlay = []
if msg is None:
return overlay, msg_cache
m_title, m_body, m_ts = msg
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 = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, self.config.display_secs - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S")
panel_h = len(msg_rows) + 2
panel_top = max(0, (h - panel_h) // 2)
row_idx = 0
for mr in msg_rows:
ln = vis_trunc(mr, w)
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
row_idx += 1
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]
)
overlay.append(
f"\033[{panel_top + row_idx + 1};1H\033[38;5;245m{meta}\033[0m\033[K"
)
row_idx += 1
bar = "\u2500" * (w - 4)
overlay.append(
f"\033[{panel_top + row_idx + 1};1H \033[2;38;5;37m{bar}\033[0m\033[K"
)
return overlay, msg_cache
def cleanup(self) -> None:
"""Cleanup resources."""
pass