feature/capability-based-deps (#53)

Reviewed-on: klubhaus/sideline#53
Co-authored-by: David Gwilliam <dhgwilliam@gmail.com>
Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
This commit is contained in:
2026-03-31 01:55:21 +00:00
committed by david
parent 2650f7245e
commit 2d28e92594
190 changed files with 37860 additions and 2026 deletions

View File

@@ -0,0 +1,55 @@
"""Stage adapters - Bridge existing components to the Stage interface.
This module provides adapters that wrap existing components
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
"""
from .camera import CameraClockStage, CameraStage
from .data_source import DataSourceStage, PassthroughStage, SourceItemsToBufferStage
from .display import DisplayStage
from .effect_plugin import EffectPluginStage
from .factory import (
create_stage_from_camera,
create_stage_from_display,
create_stage_from_effect,
create_stage_from_font,
create_stage_from_source,
)
from .message_overlay import MessageOverlayConfig, MessageOverlayStage
from .positioning import (
PositioningMode,
PositionStage,
create_position_stage,
)
from .transform import (
CanvasStage,
FontStage,
ImageToTextStage,
ViewportFilterStage,
)
__all__ = [
# Adapter classes
"EffectPluginStage",
"DisplayStage",
"DataSourceStage",
"PassthroughStage",
"SourceItemsToBufferStage",
"CameraStage",
"CameraClockStage",
"ViewportFilterStage",
"FontStage",
"ImageToTextStage",
"CanvasStage",
"MessageOverlayStage",
"MessageOverlayConfig",
"PositionStage",
"PositioningMode",
# Factory functions
"create_stage_from_display",
"create_stage_from_effect",
"create_stage_from_source",
"create_stage_from_camera",
"create_stage_from_font",
"create_position_stage",
]

View File

@@ -0,0 +1,219 @@
"""Adapter for camera stage."""
import time
from typing import Any
from engine.pipeline.core import DataType, PipelineContext, Stage
class CameraClockStage(Stage):
"""Per-frame clock stage that updates camera state.
This stage runs once per frame and updates the camera's internal state
(position, time). It makes camera_y/camera_x available to subsequent
stages via the pipeline context.
Unlike other stages, this is a pure clock stage and doesn't process
data - it just updates camera state and passes data through unchanged.
"""
def __init__(self, camera, name: str = "camera-clock"):
self._camera = camera
self.name = name
self.category = "camera"
self.optional = False
self._last_frame_time: float | None = None
@property
def stage_type(self) -> str:
return "camera"
@property
def capabilities(self) -> set[str]:
# Provides camera state info only
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
return {"camera.state"}
@property
def dependencies(self) -> set[str]:
# Clock stage - no dependencies (updates every frame regardless of data flow)
return set()
@property
def inlet_types(self) -> set:
# Accept any data type - this is a pass-through stage
return {DataType.ANY}
@property
def outlet_types(self) -> set:
# Pass through whatever was received
return {DataType.ANY}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Update camera state and pass data through.
This stage updates the camera's internal state (position, time) and
makes the updated camera_y/camera_x available to subsequent stages
via the pipeline context.
The data is passed through unchanged - this stage only updates
camera state, it doesn't transform the data.
"""
if data is None:
return data
# Update camera speed from params if explicitly set (for dynamic modulation)
# Only update if camera_speed in params differs from the default (1.0)
# This preserves camera speed set during construction
if (
ctx.params
and hasattr(ctx.params, "camera_speed")
and ctx.params.camera_speed != 1.0
):
self._camera.set_speed(ctx.params.camera_speed)
current_time = time.perf_counter()
dt = 0.0
if self._last_frame_time is not None:
dt = current_time - self._last_frame_time
self._camera.update(dt)
self._last_frame_time = current_time
# Update context with current camera position
ctx.set_state("camera_y", self._camera.y)
ctx.set_state("camera_x", self._camera.x)
# Pass data through unchanged
return data
class CameraStage(Stage):
"""Adapter wrapping Camera as a Stage.
This stage applies camera viewport transformation to the rendered buffer.
Camera state updates are handled by CameraClockStage.
"""
def __init__(self, camera, name: str = "vertical"):
self._camera = camera
self.name = name
self.category = "camera"
self.optional = True
self._last_frame_time: float | None = None
def save_state(self) -> dict[str, Any]:
"""Save camera state for restoration after pipeline rebuild.
Returns:
Dictionary containing camera state that can be restored
"""
state = {
"x": self._camera.x,
"y": self._camera.y,
"mode": self._camera.mode.value
if hasattr(self._camera.mode, "value")
else self._camera.mode,
"speed": self._camera.speed,
"zoom": self._camera.zoom,
"canvas_width": self._camera.canvas_width,
"canvas_height": self._camera.canvas_height,
"_x_float": getattr(self._camera, "_x_float", 0.0),
"_y_float": getattr(self._camera, "_y_float", 0.0),
"_time": getattr(self._camera, "_time", 0.0),
}
# Save radial camera state if present
if hasattr(self._camera, "_r_float"):
state["_r_float"] = self._camera._r_float
if hasattr(self._camera, "_theta_float"):
state["_theta_float"] = self._camera._theta_float
if hasattr(self._camera, "_radial_input"):
state["_radial_input"] = self._camera._radial_input
return state
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore camera state from saved state.
Args:
state: Dictionary containing camera state from save_state()
"""
from engine.camera import CameraMode
self._camera.x = state.get("x", 0)
self._camera.y = state.get("y", 0)
# Restore mode - handle both enum value and direct enum
mode_value = state.get("mode", 0)
if isinstance(mode_value, int):
self._camera.mode = CameraMode(mode_value)
else:
self._camera.mode = mode_value
self._camera.speed = state.get("speed", 1.0)
self._camera.zoom = state.get("zoom", 1.0)
self._camera.canvas_width = state.get("canvas_width", 200)
self._camera.canvas_height = state.get("canvas_height", 200)
# Restore internal state
if hasattr(self._camera, "_x_float"):
self._camera._x_float = state.get("_x_float", 0.0)
if hasattr(self._camera, "_y_float"):
self._camera._y_float = state.get("_y_float", 0.0)
if hasattr(self._camera, "_time"):
self._camera._time = state.get("_time", 0.0)
# Restore radial camera state if present
if hasattr(self._camera, "_r_float"):
self._camera._r_float = state.get("_r_float", 0.0)
if hasattr(self._camera, "_theta_float"):
self._camera._theta_float = state.get("_theta_float", 0.0)
if hasattr(self._camera, "_radial_input"):
self._camera._radial_input = state.get("_radial_input", 0.0)
@property
def stage_type(self) -> str:
return "camera"
@property
def capabilities(self) -> set[str]:
return {"camera"}
@property
def dependencies(self) -> set[str]:
return {"render.output", "camera.state"}
@property
def inlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Apply camera transformation to items."""
if data is None:
return data
# Camera state is updated by CameraClockStage
# We only apply the viewport transformation here
if hasattr(self._camera, "apply"):
viewport_width = ctx.params.viewport_width if ctx.params else 80
viewport_height = ctx.params.viewport_height if ctx.params else 24
# Use filtered camera position if available (from ViewportFilterStage)
# This handles the case where the buffer has been filtered and starts at row 0
filtered_camera_y = ctx.get("camera_y", self._camera.y)
# Temporarily adjust camera position for filtering
original_y = self._camera.y
self._camera.y = filtered_camera_y
try:
result = self._camera.apply(data, viewport_width, viewport_height)
finally:
# Restore original camera position
self._camera.y = original_y
return result
return data

View File

@@ -0,0 +1,143 @@
"""
Stage adapters - Bridge existing components to the Stage interface.
This module provides adapters that wrap existing components
(DataSource) as Stage implementations.
"""
from typing import Any
from engine.data_sources import SourceItem
from engine.pipeline.core import DataType, PipelineContext, Stage
class DataSourceStage(Stage):
"""Adapter wrapping DataSource as a Stage."""
def __init__(self, data_source, name: str = "headlines"):
self._source = data_source
self.name = name
self.category = "source"
self.optional = False
@property
def capabilities(self) -> set[str]:
return {f"source.{self.name}"}
@property
def dependencies(self) -> set[str]:
return set()
@property
def inlet_types(self) -> set:
return {DataType.NONE} # Sources don't take input
@property
def outlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Fetch data from source."""
if hasattr(self._source, "get_items"):
return self._source.get_items()
return data
class PassthroughStage(Stage):
"""Simple stage that passes data through unchanged.
Used for sources that already provide the data in the correct format
(e.g., pipeline introspection that outputs text directly).
"""
def __init__(self, name: str = "passthrough"):
self.name = name
self.category = "render"
self.optional = True
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Pass data through unchanged."""
return data
class SourceItemsToBufferStage(Stage):
"""Convert SourceItem objects to text buffer.
Takes a list of SourceItem objects and extracts their content,
splitting on newlines to create a proper text buffer for display.
"""
def __init__(self, name: str = "items-to-buffer"):
self.name = name
self.category = "render"
self.optional = True
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Convert SourceItem list to text buffer."""
if data is None:
return []
# If already a list of strings, return as-is
if isinstance(data, list) and data and isinstance(data[0], str):
return data
# If it's a list of SourceItem, extract content
if isinstance(data, list):
result = []
for item in data:
if isinstance(item, SourceItem):
# Split content by newline to get individual lines
lines = item.content.split("\n")
result.extend(lines)
elif hasattr(item, "content"): # Has content attribute
lines = str(item.content).split("\n")
result.extend(lines)
else:
result.append(str(item))
return result
# Single item
if isinstance(data, SourceItem):
return data.content.split("\n")
return [str(data)]

View File

@@ -0,0 +1,108 @@
"""Adapter wrapping Display as a Stage."""
from typing import Any
from engine.pipeline.core import PipelineContext, Stage
class DisplayStage(Stage):
"""Adapter wrapping Display as a Stage."""
def __init__(self, display, name: str = "terminal", positioning: str = "mixed"):
self._display = display
self.name = name
self.category = "display"
self.optional = False
self._initialized = False
self._init_width = 80
self._init_height = 24
self._positioning = positioning
def save_state(self) -> dict[str, Any]:
"""Save display state for restoration after pipeline rebuild.
Returns:
Dictionary containing display state that can be restored
"""
return {
"initialized": self._initialized,
"init_width": self._init_width,
"init_height": self._init_height,
"width": getattr(self._display, "width", 80),
"height": getattr(self._display, "height", 24),
}
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore display state from saved state.
Args:
state: Dictionary containing display state from save_state()
"""
self._initialized = state.get("initialized", False)
self._init_width = state.get("init_width", 80)
self._init_height = state.get("init_height", 24)
# Restore display dimensions if the display supports it
if hasattr(self._display, "width"):
self._display.width = state.get("width", 80)
if hasattr(self._display, "height"):
self._display.height = state.get("height", 24)
@property
def capabilities(self) -> set[str]:
return {"display.output"}
@property
def dependencies(self) -> set[str]:
# Display needs rendered content and camera transformation
return {"render.output", "camera"}
@property
def inlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.TEXT_BUFFER} # Display consumes rendered text
@property
def outlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.NONE} # Display is a terminal stage (no output)
def init(self, ctx: PipelineContext) -> bool:
w = ctx.params.viewport_width if ctx.params else 80
h = ctx.params.viewport_height if ctx.params else 24
# Try to reuse display if already initialized
reuse = self._initialized
result = self._display.init(w, h, reuse=reuse)
# Update initialization state
if result is not False:
self._initialized = True
self._init_width = w
self._init_height = h
return result is not False
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Output data to display."""
if data is not None:
# Check if positioning mode is specified in context params
positioning = self._positioning
if ctx and ctx.params and hasattr(ctx.params, "positioning"):
positioning = ctx.params.positioning
# Pass positioning to display if supported
if (
hasattr(self._display, "show")
and "positioning" in self._display.show.__code__.co_varnames
):
self._display.show(data, positioning=positioning)
else:
# Fallback for displays that don't support positioning parameter
self._display.show(data)
return data
def cleanup(self) -> None:
self._display.cleanup()

View File

@@ -0,0 +1,124 @@
"""Adapter wrapping EffectPlugin as a Stage."""
from typing import Any
from engine.pipeline.core import PipelineContext, Stage
class EffectPluginStage(Stage):
"""Adapter wrapping EffectPlugin as a Stage.
Supports capability-based dependencies through the dependencies parameter.
"""
def __init__(
self,
effect_plugin,
name: str = "effect",
dependencies: set[str] | None = None,
):
self._effect = effect_plugin
self.name = name
self.category = "effect"
self.optional = False
self._dependencies = dependencies or set()
@property
def stage_type(self) -> str:
"""Return stage_type based on effect name.
Overlay effects have stage_type "overlay".
"""
if self.is_overlay:
return "overlay"
return self.category
@property
def render_order(self) -> int:
"""Return render_order based on effect type.
Overlay effects have high render_order to appear on top.
"""
if self.is_overlay:
return 100 # High order for overlays
return 0
@property
def is_overlay(self) -> bool:
"""Return True for overlay effects.
Overlay effects compose on top of the buffer
rather than transforming it for the next stage.
"""
# Check if the effect has an is_overlay attribute that is explicitly True
# (not just any truthy value from a mock object)
if hasattr(self._effect, "is_overlay"):
effect_overlay = self._effect.is_overlay
# Only return True if it's explicitly set to True
if effect_overlay is True:
return True
return self.name == "hud"
@property
def capabilities(self) -> set[str]:
return {f"effect.{self.name}"}
@property
def dependencies(self) -> set[str]:
return self._dependencies
@property
def inlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Process data through the effect."""
if data is None:
return None
from engine.effects.types import EffectContext, apply_param_bindings
w = ctx.params.viewport_width if ctx.params else 80
h = ctx.params.viewport_height if ctx.params else 24
frame = ctx.params.frame_number if ctx.params else 0
effect_ctx = EffectContext(
terminal_width=w,
terminal_height=h,
scroll_cam=0,
ticker_height=h,
camera_x=0,
mic_excess=0.0,
grad_offset=(frame * 0.01) % 1.0,
frame_number=frame,
has_message=False,
items=ctx.get("items", []),
)
# Copy sensor state from PipelineContext to EffectContext
for key, value in ctx.state.items():
if key.startswith("sensor."):
effect_ctx.set_state(key, value)
# Copy metrics from PipelineContext to EffectContext
if "metrics" in ctx.state:
effect_ctx.set_state("metrics", ctx.state["metrics"])
# Copy pipeline_order from PipelineContext services to EffectContext state
pipeline_order = ctx.get("pipeline_order")
if pipeline_order:
effect_ctx.set_state("pipeline_order", pipeline_order)
# Apply sensor param bindings if effect has them
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
bound_config = apply_param_bindings(self._effect, effect_ctx)
self._effect.configure(bound_config)
return self._effect.process(data, effect_ctx)

View File

@@ -0,0 +1,38 @@
"""Factory functions for creating stage instances."""
from engine.pipeline.adapters.camera import CameraStage
from engine.pipeline.adapters.data_source import DataSourceStage
from engine.pipeline.adapters.display import DisplayStage
from engine.pipeline.adapters.effect_plugin import EffectPluginStage
from engine.pipeline.adapters.transform import FontStage
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
"""Create a DisplayStage from a display instance."""
return DisplayStage(display, name=name)
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
"""Create an EffectPluginStage from an effect plugin."""
return EffectPluginStage(effect_plugin, name=name)
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
"""Create a DataSourceStage from a data source."""
return DataSourceStage(data_source, name=name)
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
"""Create a CameraStage from a camera instance."""
return CameraStage(camera, name=name)
def create_stage_from_font(
font_path: str | None = None,
font_size: int | None = None,
font_ref: str | None = "default",
name: str = "font",
) -> FontStage:
"""Create a FontStage with specified font configuration."""
# FontStage currently doesn't use these parameters but keeps them for compatibility
return FontStage(name=name)

View File

@@ -0,0 +1,165 @@
"""
Frame Capture Stage Adapter
Wraps pipeline stages to capture frames for animation report generation.
"""
from typing import Any
from engine.display.backends.animation_report import AnimationReportDisplay
from engine.pipeline.core import PipelineContext, Stage
class FrameCaptureStage(Stage):
"""
Wrapper stage that captures frames before and after a wrapped stage.
This allows generating animation reports showing how each stage
transforms the data.
"""
def __init__(
self,
wrapped_stage: Stage,
display: AnimationReportDisplay,
name: str | None = None,
):
"""
Initialize frame capture stage.
Args:
wrapped_stage: The stage to wrap and capture frames from
display: The animation report display to send frames to
name: Optional name for this capture stage
"""
self._wrapped_stage = wrapped_stage
self._display = display
self.name = name or f"capture_{wrapped_stage.name}"
self.category = wrapped_stage.category
self.optional = wrapped_stage.optional
# Capture state
self._captured_input = False
self._captured_output = False
@property
def stage_type(self) -> str:
return self._wrapped_stage.stage_type
@property
def capabilities(self) -> set[str]:
return self._wrapped_stage.capabilities
@property
def dependencies(self) -> set[str]:
return self._wrapped_stage.dependencies
@property
def inlet_types(self) -> set:
return self._wrapped_stage.inlet_types
@property
def outlet_types(self) -> set:
return self._wrapped_stage.outlet_types
def init(self, ctx: PipelineContext) -> bool:
"""Initialize the wrapped stage."""
return self._wrapped_stage.init(ctx)
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""
Process data through wrapped stage and capture frames.
Args:
data: Input data (typically a text buffer)
ctx: Pipeline context
Returns:
Output data from wrapped stage
"""
# Capture input frame (before stage processing)
if isinstance(data, list) and all(isinstance(line, str) for line in data):
self._display.start_stage(f"{self._wrapped_stage.name}_input")
self._display.show(data)
self._captured_input = True
# Process through wrapped stage
result = self._wrapped_stage.process(data, ctx)
# Capture output frame (after stage processing)
if isinstance(result, list) and all(isinstance(line, str) for line in result):
self._display.start_stage(f"{self._wrapped_stage.name}_output")
self._display.show(result)
self._captured_output = True
return result
def cleanup(self) -> None:
"""Cleanup the wrapped stage."""
self._wrapped_stage.cleanup()
class FrameCaptureController:
"""
Controller for managing frame capture across the pipeline.
This class provides an easy way to enable frame capture for
specific stages or the entire pipeline.
"""
def __init__(self, display: AnimationReportDisplay):
"""
Initialize frame capture controller.
Args:
display: The animation report display to use for capture
"""
self._display = display
self._captured_stages: list[FrameCaptureStage] = []
def wrap_stage(self, stage: Stage, name: str | None = None) -> FrameCaptureStage:
"""
Wrap a stage with frame capture.
Args:
stage: The stage to wrap
name: Optional name for the capture stage
Returns:
Wrapped stage that captures frames
"""
capture_stage = FrameCaptureStage(stage, self._display, name)
self._captured_stages.append(capture_stage)
return capture_stage
def wrap_stages(self, stages: dict[str, Stage]) -> dict[str, Stage]:
"""
Wrap multiple stages with frame capture.
Args:
stages: Dictionary of stage names to stages
Returns:
Dictionary of stage names to wrapped stages
"""
wrapped = {}
for name, stage in stages.items():
wrapped[name] = self.wrap_stage(stage, name)
return wrapped
def get_captured_stages(self) -> list[FrameCaptureStage]:
"""Get list of all captured stages."""
return self._captured_stages
def generate_report(self, title: str = "Pipeline Animation Report") -> str:
"""
Generate the animation report.
Args:
title: Title for the report
Returns:
Path to the generated HTML file
"""
report_path = self._display.generate_report(title)
return str(report_path)

View File

@@ -0,0 +1,185 @@
"""
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.core 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

View File

@@ -0,0 +1,185 @@
"""PositionStage - Configurable positioning mode for terminal rendering.
This module provides positioning stages that allow choosing between
different ANSI positioning approaches:
- ABSOLUTE: Use cursor positioning codes (\\033[row;colH) for all lines
- RELATIVE: Use newlines for all lines
- MIXED: Base content uses newlines, effects use cursor positioning (default)
"""
from enum import Enum
from typing import Any
from engine.pipeline.core import DataType, PipelineContext, Stage
class PositioningMode(Enum):
"""Positioning mode for terminal rendering."""
ABSOLUTE = "absolute" # All lines have cursor positioning codes
RELATIVE = "relative" # Lines use newlines (no cursor codes)
MIXED = "mixed" # Mixed: newlines for base, cursor codes for overlays (default)
class PositionStage(Stage):
"""Applies positioning mode to buffer before display.
This stage allows configuring how lines are positioned in the terminal:
- ABSOLUTE: Each line has \\033[row;colH prefix (precise control)
- RELATIVE: Lines are joined with \\n (natural flow)
- MIXED: Leaves buffer as-is (effects add their own positioning)
"""
def __init__(
self, mode: PositioningMode = PositioningMode.RELATIVE, name: str = "position"
):
self.mode = mode
self.name = name
self.category = "position"
self._mode_str = mode.value
def save_state(self) -> dict[str, Any]:
"""Save positioning mode for restoration."""
return {"mode": self.mode.value}
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore positioning mode from saved state."""
mode_value = state.get("mode", "relative")
self.mode = PositioningMode(mode_value)
@property
def capabilities(self) -> set[str]:
return {"position.output"}
@property
def dependencies(self) -> set[str]:
# Position stage typically runs after render but before effects
# Effects may add their own positioning codes
return {"render.output"}
@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 the positioning stage."""
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Apply positioning mode to the buffer.
Args:
data: List of strings (buffer lines)
ctx: Pipeline context
Returns:
Buffer with applied positioning mode
"""
if data is None:
return data
if not isinstance(data, list):
return data
if self.mode == PositioningMode.ABSOLUTE:
return self._to_absolute(data, ctx)
elif self.mode == PositioningMode.RELATIVE:
return self._to_relative(data, ctx)
else: # MIXED
return data # No transformation
def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to absolute positioning (all lines have cursor codes).
This mode prefixes each line with \\033[row;colH to move cursor
to the exact position before writing the line.
Args:
data: List of buffer lines
ctx: Pipeline context (provides terminal dimensions)
Returns:
Buffer with cursor positioning codes for each line
"""
result = []
viewport_height = ctx.params.viewport_height if ctx.params else 24
for i, line in enumerate(data):
if i >= viewport_height:
break # Don't exceed viewport
# Check if line already has cursor positioning
if "\033[" in line and "H" in line:
# Already has cursor positioning - leave as-is
result.append(line)
else:
# Add cursor positioning for this line
# Row is 1-indexed
result.append(f"\033[{i + 1};1H{line}")
return result
def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to relative positioning (use newlines).
This mode removes explicit cursor positioning codes from lines
(except for effects that specifically add them).
Note: Effects like HUD add their own cursor positioning codes,
so we can't simply remove all of them. We rely on the terminal
display to join lines with newlines.
Args:
data: List of buffer lines
ctx: Pipeline context (unused)
Returns:
Buffer with minimal cursor positioning (only for overlays)
"""
# For relative mode, we leave the buffer as-is
# The terminal display handles joining with newlines
# Effects that need absolute positioning will add their own codes
# Filter out lines that would cause double-positioning
result = []
for i, line in enumerate(data):
# Check if this line looks like base content (no cursor code at start)
# vs an effect line (has cursor code at start)
if line.startswith("\033[") and "H" in line[:20]:
# This is an effect with positioning - keep it
result.append(line)
else:
# Base content - strip any inline cursor codes (rare)
# but keep color codes
result.append(line)
return result
def cleanup(self) -> None:
"""Clean up positioning stage."""
pass
# Convenience function to create positioning stage
def create_position_stage(
mode: str = "relative", name: str = "position"
) -> PositionStage:
"""Create a positioning stage with the specified mode.
Args:
mode: Positioning mode ("absolute", "relative", or "mixed")
name: Name for the stage
Returns:
PositionStage instance
"""
try:
positioning_mode = PositioningMode(mode)
except ValueError:
positioning_mode = PositioningMode.RELATIVE
return PositionStage(mode=positioning_mode, name=name)

View File

@@ -0,0 +1,293 @@
"""Adapters for transform stages (viewport, font, image, canvas)."""
from typing import Any
import engine.render
from engine.data_sources import SourceItem
from engine.pipeline.core import DataType, PipelineContext, Stage
def estimate_simple_height(text: str, width: int) -> int:
"""Estimate height in terminal rows using simple word wrap.
Uses conservative estimation suitable for headlines.
Each wrapped line is approximately 6 terminal rows (big block rendering).
"""
words = text.split()
if not words:
return 6
lines = 1
current_len = 0
for word in words:
word_len = len(word)
if current_len + word_len + 1 > width - 4: # -4 for margins
lines += 1
current_len = word_len
else:
current_len += word_len + 1
return lines * 6 # 6 rows per line for big block rendering
class ViewportFilterStage(Stage):
"""Filter items to viewport height based on rendered height."""
def __init__(self, name: str = "viewport-filter"):
self.name = name
self.category = "render"
self.optional = True
self._layout: list[int] = []
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"source.filtered"}
@property
def dependencies(self) -> set[str]:
# Always requires camera.state for viewport filtering
# CameraUpdateStage provides this (auto-injected if missing)
return {"source", "camera.state"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Filter items to viewport height based on rendered height."""
if data is None:
return data
if not isinstance(data, list):
return data
if not data:
return []
# Get viewport parameters from context
viewport_height = ctx.params.viewport_height if ctx.params else 24
viewport_width = ctx.params.viewport_width if ctx.params else 80
camera_y = ctx.get("camera_y", 0)
# Estimate height for each item and cache layout
self._layout = []
cumulative_heights = []
current_height = 0
for item in data:
title = item.content if isinstance(item, SourceItem) else str(item)
# Use simple height estimation (not PIL-based)
estimated_height = estimate_simple_height(title, viewport_width)
self._layout.append(estimated_height)
current_height += estimated_height
cumulative_heights.append(current_height)
# Find visible range based on camera_y and viewport_height
# camera_y is the scroll offset (how many rows are scrolled up)
start_y = camera_y
end_y = camera_y + viewport_height
# Find start index (first item that intersects with visible range)
start_idx = 0
start_item_y = 0 # Y position where the first visible item starts
for i, total_h in enumerate(cumulative_heights):
if total_h > start_y:
start_idx = i
# Calculate the Y position of the start of this item
if i > 0:
start_item_y = cumulative_heights[i - 1]
break
# Find end index (first item that extends beyond visible range)
end_idx = len(data)
for i, total_h in enumerate(cumulative_heights):
if total_h >= end_y:
end_idx = i + 1
break
# Adjust camera_y for the filtered buffer
# The filtered buffer starts at row 0, but the camera position
# needs to be relative to where the first visible item starts
filtered_camera_y = camera_y - start_item_y
# Update context with the filtered camera position
# This ensures CameraStage can correctly slice the filtered buffer
ctx.set_state("camera_y", filtered_camera_y)
ctx.set_state("camera_x", ctx.get("camera_x", 0)) # Keep camera_x unchanged
# Return visible items
return data[start_idx:end_idx]
class FontStage(Stage):
"""Render items using font."""
def __init__(self, name: str = "font"):
self.name = name
self.category = "render"
self.optional = False
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def stage_dependencies(self) -> set[str]:
# Must connect to viewport_filter stage to get filtered source
return {"viewport_filter"}
@property
def dependencies(self) -> set[str]:
# Depend on source.filtered (provided by viewport_filter)
# This ensures we get the filtered/processed source, not raw source
return {"source.filtered"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Render items to text buffer using font."""
if data is None:
return []
if not isinstance(data, list):
return [str(data)]
import os
if os.environ.get("DEBUG_CAMERA"):
print(f"FontStage: input items={len(data)}")
viewport_width = ctx.params.viewport_width if ctx.params else 80
result = []
for item in data:
if isinstance(item, SourceItem):
title = item.content
src = item.source
ts = item.timestamp
content_lines, _, _ = engine.render.make_block(
title, src, ts, viewport_width
)
result.extend(content_lines)
elif hasattr(item, "content"):
title = str(item.content)
content_lines, _, _ = engine.render.make_block(
title, "", "", viewport_width
)
result.extend(content_lines)
else:
result.append(str(item))
return result
class ImageToTextStage(Stage):
"""Convert image items to text."""
def __init__(self, name: str = "image-to-text"):
self.name = name
self.category = "render"
self.optional = True
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Convert image items to text representation."""
if data is None:
return []
if not isinstance(data, list):
return [str(data)]
result = []
for item in data:
# Check if item is an image
if hasattr(item, "image_path") or hasattr(item, "image_data"):
# Placeholder: would normally render image to ASCII art
result.append(f"[Image: {getattr(item, 'image_path', 'data')}]")
elif isinstance(item, SourceItem):
result.extend(item.content.split("\n"))
else:
result.append(str(item))
return result
class CanvasStage(Stage):
"""Render items to canvas."""
def __init__(self, name: str = "canvas"):
self.name = name
self.category = "render"
self.optional = False
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
@property
def inlet_types(self) -> set:
return {DataType.SOURCE_ITEMS}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Render items to canvas."""
if data is None:
return []
if not isinstance(data, list):
return [str(data)]
# Simple canvas rendering
result = []
for item in data:
if isinstance(item, SourceItem):
result.extend(item.content.split("\n"))
else:
result.append(str(item))
return result