MAJOR REFACTORING: Consolidate duplicated pipeline code and standardize on capability-based dependency resolution. This is a significant but backwards-compatible restructuring that improves maintainability and extensibility. ## ARCHITECTURE CHANGES ### Data Sources Consolidation - Move engine/sources_v2.py → engine/data_sources/sources.py - Move engine/pipeline_sources/ → engine/data_sources/ - Create unified DataSource ABC with common interface: * fetch() - idempotent data retrieval * get_items() - cached access with automatic refresh * refresh() - force cache invalidation * is_dynamic - indicate streaming vs static sources - Support for SourceItem dataclass (content, source, timestamp, metadata) ### Display Backend Improvements - Update all 7 display backends to use new import paths - Terminal: Improve dimension detection and handling - WebSocket: Better error handling and client lifecycle - Sixel: Refactor graphics rendering - Pygame: Modernize event handling - Kitty: Add protocol support for inline images - Multi: Ensure proper forwarding to all backends - Null: Maintain testing backend functionality ### Pipeline Adapter Consolidation - Refactor adapter stages for clarity and flexibility - RenderStage now handles both item-based and buffer-based rendering - Add SourceItemsToBufferStage for converting data source items - Improve DataSourceStage to work with all source types - Add DisplayStage wrapper for display backends ### Camera & Viewport Refinements - Update Camera class for new architecture - Improve viewport dimension detection - Better handling of resize events across backends ### New Effect Plugins - border.py: Frame rendering effect with configurable style - crop.py: Viewport clipping effect for selective display - tint.py: Color filtering effect for atmosphere ### Tests & Quality - Add test_border_effect.py with comprehensive border tests - Add test_crop_effect.py with viewport clipping tests - Add test_tint_effect.py with color filtering tests - Update test_pipeline.py for new architecture - Update test_pipeline_introspection.py for new data source location - All 463 tests pass with 56% coverage - Linting: All checks pass with ruff ### Removals (Code Cleanup) - Delete engine/benchmark.py (deprecated performance testing) - Delete engine/pipeline_sources/__init__.py (moved to data_sources) - Delete engine/sources_v2.py (replaced by data_sources/sources.py) - Update AGENTS.md to reflect new structure ### Import Path Updates - Update engine/pipeline/controller.py::create_default_pipeline() * Old: from engine.sources_v2 import HeadlinesDataSource * New: from engine.data_sources.sources import HeadlinesDataSource - All display backends import from new locations - All tests import from new locations ## BACKWARDS COMPATIBILITY This refactoring is intended to be backwards compatible: - Pipeline execution unchanged (DAG-based with capability matching) - Effect plugins unchanged (EffectPlugin interface same) - Display protocol unchanged (Display duck-typing works as before) - Config system unchanged (presets.toml format same) ## TESTING - 463 tests pass (0 failures, 19 skipped) - Full linting check passes - Manual testing on demo, poetry, websocket modes - All new effect plugins tested ## FILES CHANGED - 24 files modified/added/deleted - 723 insertions, 1,461 deletions (net -738 LOC - cleanup!) - No breaking changes to public APIs - All transitive imports updated correctly
181 lines
5.2 KiB
Python
181 lines
5.2 KiB
Python
"""
|
|
Kitty graphics display backend - renders using kitty's native graphics protocol.
|
|
"""
|
|
|
|
import time
|
|
|
|
from engine.display.renderer import get_default_font_path, parse_ansi
|
|
|
|
|
|
def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes:
|
|
"""Encode image data using kitty's graphics protocol."""
|
|
import base64
|
|
|
|
encoded = base64.b64encode(image_data).decode("ascii")
|
|
|
|
chunks = []
|
|
for i in range(0, len(encoded), 4096):
|
|
chunk = encoded[i : i + 4096]
|
|
if i == 0:
|
|
chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\")
|
|
else:
|
|
chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\")
|
|
|
|
return "".join(chunks).encode("utf-8")
|
|
|
|
|
|
class KittyDisplay:
|
|
"""Kitty graphics display backend using kitty's native protocol."""
|
|
|
|
width: int = 80
|
|
height: int = 24
|
|
|
|
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
|
self.width = 80
|
|
self.height = 24
|
|
self.cell_width = cell_width
|
|
self.cell_height = cell_height
|
|
self._initialized = False
|
|
self._font_path = None
|
|
|
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
"""Initialize display with dimensions.
|
|
|
|
Args:
|
|
width: Terminal width in characters
|
|
height: Terminal height in rows
|
|
reuse: Ignored for KittyDisplay (protocol doesn't support reuse)
|
|
"""
|
|
self.width = width
|
|
self.height = height
|
|
self._initialized = True
|
|
|
|
def _get_font_path(self) -> str | None:
|
|
"""Get font path from env or detect common locations."""
|
|
import os
|
|
|
|
if self._font_path:
|
|
return self._font_path
|
|
|
|
env_font = os.environ.get("MAINLINE_KITTY_FONT")
|
|
if env_font and os.path.exists(env_font):
|
|
self._font_path = env_font
|
|
return env_font
|
|
|
|
font_path = get_default_font_path()
|
|
if font_path:
|
|
self._font_path = font_path
|
|
|
|
return self._font_path
|
|
|
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
import sys
|
|
|
|
t0 = time.perf_counter()
|
|
|
|
# Get metrics for border display
|
|
fps = 0.0
|
|
frame_time = 0.0
|
|
from engine.display import get_monitor
|
|
|
|
monitor = get_monitor()
|
|
if monitor:
|
|
stats = monitor.get_stats()
|
|
avg_ms = stats.get("avg_ms", 0) if stats else 0
|
|
frame_count = stats.get("frame_count", 0) if stats else 0
|
|
if avg_ms and frame_count > 0:
|
|
fps = 1000.0 / avg_ms
|
|
frame_time = avg_ms
|
|
|
|
# Apply border if requested
|
|
if border:
|
|
from engine.display import render_border
|
|
|
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
|
|
img_width = self.width * self.cell_width
|
|
img_height = self.height * self.cell_height
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
except ImportError:
|
|
return
|
|
|
|
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
font_path = self._get_font_path()
|
|
font = None
|
|
if font_path:
|
|
try:
|
|
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
|
except Exception:
|
|
font = None
|
|
|
|
if font is None:
|
|
try:
|
|
font = ImageFont.load_default()
|
|
except Exception:
|
|
font = None
|
|
|
|
for row_idx, line in enumerate(buffer[: self.height]):
|
|
if row_idx >= self.height:
|
|
break
|
|
|
|
tokens = parse_ansi(line)
|
|
x_pos = 0
|
|
y_pos = row_idx * self.cell_height
|
|
|
|
for text, fg, bg, bold in tokens:
|
|
if not text:
|
|
continue
|
|
|
|
if bg != (0, 0, 0):
|
|
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
|
draw.rectangle(bbox, fill=(*bg, 255))
|
|
|
|
if bold and font:
|
|
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
|
|
|
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
|
|
|
if font:
|
|
x_pos += draw.textlength(text, font=font)
|
|
|
|
from io import BytesIO
|
|
|
|
output = BytesIO()
|
|
img.save(output, format="PNG")
|
|
png_data = output.getvalue()
|
|
|
|
graphic = _encode_kitty_graphic(png_data, img_width, img_height)
|
|
|
|
sys.stdout.buffer.write(graphic)
|
|
sys.stdout.flush()
|
|
|
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
|
|
from engine.display import get_monitor
|
|
|
|
monitor = get_monitor()
|
|
if monitor:
|
|
chars_in = sum(len(line) for line in buffer)
|
|
monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in)
|
|
|
|
def clear(self) -> None:
|
|
import sys
|
|
|
|
sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\")
|
|
sys.stdout.flush()
|
|
|
|
def cleanup(self) -> None:
|
|
self.clear()
|
|
|
|
def get_dimensions(self) -> tuple[int, int]:
|
|
"""Get current dimensions.
|
|
|
|
Returns:
|
|
(width, height) in character cells
|
|
"""
|
|
return (self.width, self.height)
|