forked from genewildish/Mainline
## Summary Fixed critical performance issue where demo/poetry presets would hang for 10+ seconds due to FontStage rendering all 1438+ headline items instead of just the visible ~5 items. ## Changes ### Core Fix: ViewportFilterStage - New pipeline stage that filters items to only those fitting in the viewport - Reduces 1438 items → ~5 items (288x reduction) before FontStage - Prevents expensive PIL font rendering operations on items that won't be displayed - Located: engine/pipeline/adapters.py:348-403 ### Pipeline Integration - Updated app.py to add ViewportFilterStage before FontStage for headlines/poetry sources - Ensures correct data flow: source → viewport_filter → font → camera → effects → display - ViewportFilterStage depends on 'source' capability, providing pass-through filtering ### Display Protocol Enhancement - Added is_quit_requested() and clear_quit_request() method signatures to Display protocol - Documented as optional methods for backends supporting keyboard input - Already implemented by pygame backend, now formally part of protocol ### Debug Infrastructure - Added MAINLINE_DEBUG_DATAFLOW environment variable logging throughout pipeline - Logs stage input/output types and data sizes to stderr (when flag enabled) - Verified working: 1438 → 5 item reduction shown in debug output ### Performance Testing - Added pytest-benchmark (v5.2.3) as dev dependency for statistical benchmarking - Created comprehensive performance regression tests (tests/test_performance_regression.py) - Tests verify: - ViewportFilterStage filters 2000 items efficiently (<1ms) - FontStage processes filtered items quickly (<50ms) - 288x performance improvement ratio maintained - Pipeline doesn't hang with large datasets - All 523 tests passing, including 7 new performance tests ## Performance Impact **Before:** FontStage renders all 1438 items per frame → 10+ second hang **After:** FontStage renders ~5 items per frame → sub-second execution Real-world impact: Demo preset now responsive and usable with news sources. ## Testing - Unit tests: 523 passed, 16 skipped - Regression tests: Catch performance degradation with large datasets - E2E verification: Debug logging confirms correct pipeline flow - Benchmark suite: Statistical performance tracking enabled
251 lines
7.4 KiB
Python
251 lines
7.4 KiB
Python
"""
|
|
Display backend system with registry pattern.
|
|
|
|
Allows swapping output backends via the Display protocol.
|
|
Supports auto-discovery of display backends.
|
|
"""
|
|
|
|
from typing import Protocol
|
|
|
|
from engine.display.backends.kitty import KittyDisplay
|
|
from engine.display.backends.multi import MultiDisplay
|
|
from engine.display.backends.null import NullDisplay
|
|
from engine.display.backends.pygame import PygameDisplay
|
|
from engine.display.backends.sixel import SixelDisplay
|
|
from engine.display.backends.terminal import TerminalDisplay
|
|
from engine.display.backends.websocket import WebSocketDisplay
|
|
|
|
|
|
class Display(Protocol):
|
|
"""Protocol for display backends.
|
|
|
|
All display backends must implement:
|
|
- width, height: Terminal dimensions
|
|
- init(width, height, reuse=False): Initialize the display
|
|
- show(buffer): Render buffer to display
|
|
- clear(): Clear the display
|
|
- cleanup(): Shutdown the display
|
|
|
|
Optional methods for keyboard input:
|
|
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
|
|
- clear_quit_request(): Clears the quit request flag
|
|
|
|
The reuse flag allows attaching to an existing display instance
|
|
rather than creating a new window/connection.
|
|
|
|
Keyboard input support by backend:
|
|
- terminal: No native input (relies on signal handler for Ctrl+C)
|
|
- pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown
|
|
- websocket: No native input (relies on signal handler for Ctrl+C)
|
|
- sixel: No native input (relies on signal handler for Ctrl+C)
|
|
- null: No native input
|
|
- kitty: Supports Ctrl+C, Ctrl+Q, Escape (via pygame-like handling)
|
|
"""
|
|
|
|
width: int
|
|
height: int
|
|
|
|
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: If True, attach to existing display instead of creating new
|
|
"""
|
|
...
|
|
|
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
"""Show buffer on display.
|
|
|
|
Args:
|
|
buffer: Buffer to display
|
|
border: If True, render border around buffer (default False)
|
|
"""
|
|
...
|
|
|
|
def clear(self) -> None:
|
|
"""Clear display."""
|
|
...
|
|
|
|
def cleanup(self) -> None:
|
|
"""Shutdown display."""
|
|
...
|
|
|
|
def get_dimensions(self) -> tuple[int, int]:
|
|
"""Get current terminal dimensions.
|
|
|
|
Returns:
|
|
(width, height) in character cells
|
|
|
|
This method is called after show() to check if the display
|
|
was resized. The main loop should compare this to the current
|
|
viewport dimensions and update accordingly.
|
|
"""
|
|
...
|
|
|
|
def is_quit_requested(self) -> bool:
|
|
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
|
|
|
Returns:
|
|
True if quit was requested, False otherwise
|
|
|
|
Optional method - only implemented by backends that support keyboard input.
|
|
"""
|
|
...
|
|
|
|
def clear_quit_request(self) -> None:
|
|
"""Clear the quit request flag.
|
|
|
|
Optional method - only implemented by backends that support keyboard input.
|
|
"""
|
|
...
|
|
|
|
|
|
class DisplayRegistry:
|
|
"""Registry for display backends with auto-discovery."""
|
|
|
|
_backends: dict[str, type[Display]] = {}
|
|
_initialized = False
|
|
|
|
@classmethod
|
|
def register(cls, name: str, backend_class: type[Display]) -> None:
|
|
"""Register a display backend."""
|
|
cls._backends[name.lower()] = backend_class
|
|
|
|
@classmethod
|
|
def get(cls, name: str) -> type[Display] | None:
|
|
"""Get a display backend class by name."""
|
|
return cls._backends.get(name.lower())
|
|
|
|
@classmethod
|
|
def list_backends(cls) -> list[str]:
|
|
"""List all available display backend names."""
|
|
return list(cls._backends.keys())
|
|
|
|
@classmethod
|
|
def create(cls, name: str, **kwargs) -> Display | None:
|
|
"""Create a display instance by name."""
|
|
cls.initialize()
|
|
backend_class = cls.get(name)
|
|
if backend_class:
|
|
return backend_class(**kwargs)
|
|
return None
|
|
|
|
@classmethod
|
|
def initialize(cls) -> None:
|
|
"""Initialize and register all built-in backends."""
|
|
if cls._initialized:
|
|
return
|
|
|
|
cls.register("terminal", TerminalDisplay)
|
|
cls.register("null", NullDisplay)
|
|
cls.register("websocket", WebSocketDisplay)
|
|
cls.register("sixel", SixelDisplay)
|
|
cls.register("kitty", KittyDisplay)
|
|
cls.register("pygame", PygameDisplay)
|
|
|
|
cls._initialized = True
|
|
|
|
|
|
def get_monitor():
|
|
"""Get the performance monitor."""
|
|
try:
|
|
from engine.effects.performance import get_monitor as _get_monitor
|
|
|
|
return _get_monitor()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _strip_ansi(s: str) -> str:
|
|
"""Strip ANSI escape sequences from string for length calculation."""
|
|
import re
|
|
|
|
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
|
|
|
|
|
def render_border(
|
|
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
|
) -> list[str]:
|
|
"""Render a border around the buffer.
|
|
|
|
Args:
|
|
buf: Input buffer (list of strings)
|
|
width: Display width in characters
|
|
height: Display height in rows
|
|
fps: Current FPS to display in top border (optional)
|
|
frame_time: Frame time in ms to display in bottom border (optional)
|
|
|
|
Returns:
|
|
Buffer with border applied
|
|
"""
|
|
if not buf or width < 3 or height < 3:
|
|
return buf
|
|
|
|
inner_w = width - 2
|
|
inner_h = height - 2
|
|
|
|
# Crop buffer to fit inside border
|
|
cropped = []
|
|
for i in range(min(inner_h, len(buf))):
|
|
line = buf[i]
|
|
# Calculate visible width (excluding ANSI codes)
|
|
visible_len = len(_strip_ansi(line))
|
|
if visible_len > inner_w:
|
|
# Truncate carefully - this is approximate for ANSI text
|
|
cropped.append(line[:inner_w])
|
|
else:
|
|
cropped.append(line + " " * (inner_w - visible_len))
|
|
|
|
# Pad with empty lines if needed
|
|
while len(cropped) < inner_h:
|
|
cropped.append(" " * inner_w)
|
|
|
|
# Build borders
|
|
if fps > 0:
|
|
fps_str = f" FPS:{fps:.0f}"
|
|
if len(fps_str) < inner_w:
|
|
right_len = inner_w - len(fps_str)
|
|
top_border = "┌" + "─" * right_len + fps_str + "┐"
|
|
else:
|
|
top_border = "┌" + "─" * inner_w + "┐"
|
|
else:
|
|
top_border = "┌" + "─" * inner_w + "┐"
|
|
|
|
if frame_time > 0:
|
|
ft_str = f" {frame_time:.1f}ms"
|
|
if len(ft_str) < inner_w:
|
|
right_len = inner_w - len(ft_str)
|
|
bottom_border = "└" + "─" * right_len + ft_str + "┘"
|
|
else:
|
|
bottom_border = "└" + "─" * inner_w + "┘"
|
|
else:
|
|
bottom_border = "└" + "─" * inner_w + "┘"
|
|
|
|
# Build result with left/right borders
|
|
result = [top_border]
|
|
for line in cropped:
|
|
# Ensure exactly inner_w characters before adding right border
|
|
if len(line) < inner_w:
|
|
line = line + " " * (inner_w - len(line))
|
|
elif len(line) > inner_w:
|
|
line = line[:inner_w]
|
|
result.append("│" + line + "│")
|
|
result.append(bottom_border)
|
|
|
|
return result
|
|
|
|
|
|
__all__ = [
|
|
"Display",
|
|
"DisplayRegistry",
|
|
"get_monitor",
|
|
"render_border",
|
|
"TerminalDisplay",
|
|
"NullDisplay",
|
|
"WebSocketDisplay",
|
|
"SixelDisplay",
|
|
"MultiDisplay",
|
|
]
|