forked from genewildish/Mainline
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.
294 lines
8.7 KiB
Python
294 lines
8.7 KiB
Python
"""Adapters for transform stages (viewport, font, image, canvas)."""
|
|
|
|
from typing import Any
|
|
|
|
import engine.render
|
|
from engine.data_sources import SourceItem
|
|
from engine.pipeline 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
|