feat: Implement scrolling camera with layout-aware filtering

- Rename VERTICAL camera mode to FEED (rapid single-item view)
- Add SCROLL camera mode with float accumulation for smooth movie-credits style scrolling
- Add estimate_block_height() for cheap layout calculation without full rendering
- Replace ViewportFilterStage with layout-aware filtering that tracks camera position
- Add render caching to FontStage to avoid re-rendering items
- Fix CameraStage to use global canvas height for scrolling bounds
- Add horizontal padding in Camera.apply() to prevent ghosting
- Add get_dimensions() to MultiDisplay for proper viewport sizing
- Fix PygameDisplay to auto-detect viewport from window size
- Update presets to use scroll camera with appropriate speeds
This commit is contained in:
2026-03-17 00:21:18 -07:00
parent 4c97cfe6aa
commit 57de835ae0
12 changed files with 303 additions and 66 deletions

View File

@@ -314,9 +314,7 @@ class CameraStage(Stage):
@property
def dependencies(self) -> set[str]:
return {
"source"
} # Prefix match any source (source.headlines, source.poetry, etc.)
return {"render.output"} # Depend on rendered output from font or render stage
@property
def inlet_types(self) -> set:
@@ -335,9 +333,54 @@ class CameraStage(Stage):
if data is None:
return None
if hasattr(self._camera, "apply"):
return self._camera.apply(
data, ctx.params.viewport_width if ctx.params else 80
viewport_width = ctx.params.viewport_width if ctx.params else 80
viewport_height = ctx.params.viewport_height if ctx.params else 24
buffer_height = len(data) if isinstance(data, list) else 0
# Get global layout height for canvas (enables full scrolling range)
total_layout_height = ctx.get("total_layout_height", buffer_height)
# Preserve camera's configured canvas width, but ensure it's at least viewport_width
# This allows horizontal/omni/floating/bounce cameras to scroll properly
canvas_width = max(
viewport_width, getattr(self._camera, "canvas_width", viewport_width)
)
# Update camera's viewport dimensions so it knows its actual bounds
if hasattr(self._camera, "viewport_width"):
self._camera.viewport_width = viewport_width
self._camera.viewport_height = viewport_height
# Set canvas to full layout height so camera can scroll through all content
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
# Update camera position (scroll) - uses global canvas for clamping
if hasattr(self._camera, "update"):
self._camera.update(1 / 60)
# Store camera_y in context for ViewportFilterStage (global y position)
ctx.set("camera_y", self._camera.y)
# Apply camera viewport slicing to the partial buffer
# The buffer starts at render_offset_y in global coordinates
render_offset_y = ctx.get("render_offset_y", 0)
# Temporarily shift camera to local buffer coordinates for apply()
real_y = self._camera.y
local_y = max(0, real_y - render_offset_y)
# Temporarily shrink canvas to local buffer size so apply() works correctly
self._camera.set_canvas_size(width=canvas_width, height=buffer_height)
self._camera.y = local_y
# Apply slicing
result = self._camera.apply(data, viewport_width, viewport_height)
# Restore global canvas and camera position for next frame
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
self._camera.y = real_y
return result
return data
def cleanup(self) -> None:
@@ -346,20 +389,20 @@ class CameraStage(Stage):
class ViewportFilterStage(Stage):
"""Stage that limits items to fit in viewport.
"""Stage that limits items based on layout calculation.
Filters the input list of items to only include as many as can fit
in the visible viewport. This prevents FontStage from rendering
thousands of items when only a few are visible, reducing processing time.
Estimate: each rendered item typically takes 5-8 terminal lines.
For a 24-line viewport, we limit to ~4 items (conservative estimate).
Computes cumulative y-offsets for all items using cheap height estimation,
then returns only items that overlap the camera's viewport window.
This prevents FontStage from rendering thousands of items when only a few
are visible, while still allowing camera scrolling through all content.
"""
def __init__(self, name: str = "viewport-filter"):
self.name = name
self.category = "filter"
self.optional = False
self._cached_count = 0
self._layout: list[tuple[int, int]] = []
@property
def stage_type(self) -> str:
@@ -386,21 +429,60 @@ class ViewportFilterStage(Stage):
return {DataType.SOURCE_ITEMS}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Filter items to viewport-fitting count."""
"""Filter items based on layout and camera position."""
if data is None or not isinstance(data, list):
return data
# Estimate: each rendered headline takes 5-8 lines
# Use a conservative factor to ensure we don't run out of space
lines_per_item = 6
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)
# Calculate how many items we need to fill the viewport
# Add 1 extra to account for padding/spacing
max_items = max(1, viewport_height // lines_per_item + 1)
# Recompute layout only when item count changes
if len(data) != self._cached_count:
self._layout = []
y = 0
from engine.render.blocks import estimate_block_height
# Return only the items that fit in the viewport
return data[:max_items]
for item in data:
if hasattr(item, "content"):
title = item.content
elif isinstance(item, tuple):
title = str(item[0]) if item else ""
else:
title = str(item)
h = estimate_block_height(title, viewport_width)
self._layout.append((y, h))
y += h
self._cached_count = len(data)
# Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer]
buffer_zone = viewport_height
vis_start = max(0, camera_y - buffer_zone)
vis_end = camera_y + viewport_height + buffer_zone
visible_items = []
render_offset_y = 0
first_visible_found = False
for i, (start_y, height) in enumerate(self._layout):
item_end = start_y + height
if item_end > vis_start and start_y < vis_end:
if not first_visible_found:
render_offset_y = start_y
first_visible_found = True
visible_items.append(data[i])
# Compute total layout height for the canvas
total_layout_height = 0
if self._layout:
last_start, last_height = self._layout[-1]
total_layout_height = last_start + last_height
# Store metadata for CameraStage
ctx.set("render_offset_y", render_offset_y)
ctx.set("total_layout_height", total_layout_height)
# Always return at least one item to avoid empty buffer errors
return visible_items if visible_items else data[:1]
class FontStage(Stage):
@@ -434,6 +516,7 @@ class FontStage(Stage):
self._font_size = font_size
self._font_ref = font_ref
self._font = None
self._render_cache: dict[tuple[str, str, int], list[str]] = {}
@property
def stage_type(self) -> str:
@@ -504,8 +587,15 @@ class FontStage(Stage):
src = "unknown"
ts = "0"
# Check cache first
cache_key = (title, src, ts, w)
if cache_key in self._render_cache:
result.extend(self._render_cache[cache_key])
continue
try:
block_lines, color_code, meta_idx = make_block(title, src, ts, w)
self._render_cache[cache_key] = block_lines
result.extend(block_lines)
except Exception:
result.append(title)

View File

@@ -45,7 +45,7 @@ class PipelinePreset:
description: str = ""
source: str = "headlines"
display: str = "terminal"
camera: str = "vertical"
camera: str = "scroll"
effects: list[str] = field(default_factory=list)
border: bool = False
@@ -79,7 +79,7 @@ DEMO_PRESET = PipelinePreset(
description="Demo mode with effect cycling and camera modes",
source="headlines",
display="pygame",
camera="vertical",
camera="scroll",
effects=["noise", "fade", "glitch", "firehose", "hud"],
)
@@ -88,7 +88,7 @@ POETRY_PRESET = PipelinePreset(
description="Poetry feed with subtle effects",
source="poetry",
display="pygame",
camera="vertical",
camera="scroll",
effects=["fade", "hud"],
)
@@ -106,7 +106,7 @@ WEBSOCKET_PRESET = PipelinePreset(
description="WebSocket display mode",
source="headlines",
display="websocket",
camera="vertical",
camera="scroll",
effects=["noise", "fade", "glitch", "hud"],
)
@@ -115,7 +115,7 @@ SIXEL_PRESET = PipelinePreset(
description="Sixel graphics display mode",
source="headlines",
display="sixel",
camera="vertical",
camera="scroll",
effects=["noise", "fade", "glitch", "hud"],
)
@@ -124,7 +124,7 @@ FIREHOSE_PRESET = PipelinePreset(
description="High-speed firehose mode",
source="headlines",
display="pygame",
camera="vertical",
camera="scroll",
effects=["noise", "fade", "glitch", "firehose", "hud"],
)