- Implements pipeline hot-rebuild with state preservation (issue #43) - Adds auto-injection of MVP stages for missing capabilities - Adds radial camera mode for polar coordinate scanning - Adds afterimage and motionblur effects using framebuffer history - Adds comprehensive acceptance tests for camera modes and pipeline rebuild - Updates presets.toml with new effect configurations Related to: #35 (Pipeline Mutation API epic) Closes: #43, #44, #45
291 lines
8.2 KiB
Python
291 lines
8.2 KiB
Python
"""
|
|
Display backend system with registry pattern.
|
|
|
|
Allows swapping output backends via the Display protocol.
|
|
Supports auto-discovery of display backends.
|
|
"""
|
|
|
|
from enum import Enum, auto
|
|
from typing import Protocol
|
|
|
|
# Optional backend - requires moderngl package
|
|
try:
|
|
from engine.display.backends.moderngl import ModernGLDisplay
|
|
|
|
_MODERNGL_AVAILABLE = True
|
|
except ImportError:
|
|
ModernGLDisplay = None
|
|
_MODERNGL_AVAILABLE = False
|
|
|
|
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.replay import ReplayDisplay
|
|
from engine.display.backends.terminal import TerminalDisplay
|
|
from engine.display.backends.websocket import WebSocketDisplay
|
|
|
|
|
|
class BorderMode(Enum):
|
|
"""Border rendering modes for displays."""
|
|
|
|
OFF = auto() # No border
|
|
SIMPLE = auto() # Traditional border with FPS/frame time
|
|
UI = auto() # Right-side UI panel with interactive controls
|
|
|
|
|
|
class Display(Protocol):
|
|
"""Protocol for display backends.
|
|
|
|
Required attributes:
|
|
- width: int
|
|
- height: int
|
|
|
|
Required methods (duck typing - actual signatures may vary):
|
|
- init(width, height, reuse=False)
|
|
- show(buffer, border=False)
|
|
- clear()
|
|
- cleanup()
|
|
- get_dimensions() -> (width, height)
|
|
|
|
Optional attributes (for UI mode):
|
|
- ui_panel: UIPanel instance (set by app when border=UI)
|
|
|
|
Optional methods:
|
|
- is_quit_requested() -> bool
|
|
- clear_quit_request() -> None
|
|
"""
|
|
|
|
width: int
|
|
height: int
|
|
|
|
|
|
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:
|
|
cls._backends[name.lower()] = backend_class
|
|
|
|
@classmethod
|
|
def get(cls, name: str) -> type[Display] | None:
|
|
return cls._backends.get(name.lower())
|
|
|
|
@classmethod
|
|
def list_backends(cls) -> list[str]:
|
|
return list(cls._backends.keys())
|
|
|
|
@classmethod
|
|
def create(cls, name: str, **kwargs) -> Display | None:
|
|
cls.initialize()
|
|
backend_class = cls.get(name)
|
|
if backend_class:
|
|
return backend_class(**kwargs)
|
|
return None
|
|
|
|
@classmethod
|
|
def initialize(cls) -> None:
|
|
if cls._initialized:
|
|
return
|
|
cls.register("terminal", TerminalDisplay)
|
|
cls.register("null", NullDisplay)
|
|
cls.register("replay", ReplayDisplay)
|
|
cls.register("websocket", WebSocketDisplay)
|
|
cls.register("pygame", PygameDisplay)
|
|
if _MODERNGL_AVAILABLE:
|
|
cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type]
|
|
cls._initialized = True
|
|
|
|
@classmethod
|
|
def create_multi(cls, names: list[str]) -> MultiDisplay | None:
|
|
displays = []
|
|
for name in names:
|
|
backend = cls.create(name)
|
|
if backend:
|
|
displays.append(backend)
|
|
else:
|
|
return None
|
|
if not displays:
|
|
return None
|
|
return MultiDisplay(displays)
|
|
|
|
|
|
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_simple_border(
|
|
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
|
) -> list[str]:
|
|
"""Render a traditional border around the buffer."""
|
|
if not buf or width < 3 or height < 3:
|
|
return buf
|
|
|
|
inner_w = width - 2
|
|
inner_h = height - 2
|
|
|
|
cropped = []
|
|
for i in range(min(inner_h, len(buf))):
|
|
line = buf[i]
|
|
visible_len = len(_strip_ansi(line))
|
|
if visible_len > inner_w:
|
|
cropped.append(line[:inner_w])
|
|
else:
|
|
cropped.append(line + " " * (inner_w - visible_len))
|
|
|
|
while len(cropped) < inner_h:
|
|
cropped.append(" " * inner_w)
|
|
|
|
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 + "┘"
|
|
|
|
result = [top_border]
|
|
for line in cropped:
|
|
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
|
|
|
|
|
|
def render_ui_panel(
|
|
buf: list[str],
|
|
width: int,
|
|
height: int,
|
|
ui_panel,
|
|
fps: float = 0.0,
|
|
frame_time: float = 0.0,
|
|
) -> list[str]:
|
|
"""Render buffer with a right-side UI panel."""
|
|
from engine.pipeline.ui import UIPanel
|
|
|
|
if not isinstance(ui_panel, UIPanel):
|
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
|
|
|
panel_width = min(ui_panel.config.panel_width, width - 4)
|
|
main_width = width - panel_width - 1
|
|
|
|
panel_lines = ui_panel.render(panel_width, height)
|
|
|
|
main_buf = buf[: height - 2]
|
|
main_result = _render_simple_border(
|
|
main_buf, main_width + 2, height, fps, frame_time
|
|
)
|
|
|
|
combined = []
|
|
for i in range(height):
|
|
if i < len(main_result):
|
|
main_line = main_result[i]
|
|
if len(main_line) >= 2:
|
|
main_content = (
|
|
main_line[1:-1] if main_line[-1] in "│┌┐└┘" else main_line[1:]
|
|
)
|
|
main_content = main_content.ljust(main_width)[:main_width]
|
|
else:
|
|
main_content = " " * main_width
|
|
else:
|
|
main_content = " " * main_width
|
|
|
|
panel_idx = i
|
|
panel_line = (
|
|
panel_lines[panel_idx][:panel_width].ljust(panel_width)
|
|
if panel_idx < len(panel_lines)
|
|
else " " * panel_width
|
|
)
|
|
|
|
separator = "│" if 0 < i < height - 1 else "┼" if i == 0 else "┴"
|
|
combined.append(main_content + separator + panel_line)
|
|
|
|
return combined
|
|
|
|
|
|
def render_border(
|
|
buf: list[str],
|
|
width: int,
|
|
height: int,
|
|
fps: float = 0.0,
|
|
frame_time: float = 0.0,
|
|
border_mode: BorderMode | bool = BorderMode.SIMPLE,
|
|
) -> list[str]:
|
|
"""Render a border or UI panel around the buffer.
|
|
|
|
Args:
|
|
buf: Input buffer
|
|
width: Display width
|
|
height: Display height
|
|
fps: FPS for top border
|
|
frame_time: Frame time for bottom border
|
|
border_mode: Border rendering mode
|
|
|
|
Returns:
|
|
Buffer with border/panel applied
|
|
"""
|
|
# Normalize border_mode to BorderMode enum
|
|
if isinstance(border_mode, bool):
|
|
border_mode = BorderMode.SIMPLE if border_mode else BorderMode.OFF
|
|
|
|
if border_mode == BorderMode.UI:
|
|
# UI panel requires a UIPanel instance (injected separately)
|
|
# For now, this will be called by displays that have a ui_panel attribute
|
|
# This function signature doesn't include ui_panel, so we'll handle it in render_ui_panel
|
|
# Fall back to simple border if no panel available
|
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
|
elif border_mode == BorderMode.SIMPLE:
|
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
|
else:
|
|
return buf
|
|
|
|
|
|
__all__ = [
|
|
"Display",
|
|
"DisplayRegistry",
|
|
"get_monitor",
|
|
"render_border",
|
|
"render_ui_panel",
|
|
"BorderMode",
|
|
"TerminalDisplay",
|
|
"NullDisplay",
|
|
"ReplayDisplay",
|
|
"WebSocketDisplay",
|
|
"MultiDisplay",
|
|
"PygameDisplay",
|
|
]
|
|
|
|
if _MODERNGL_AVAILABLE:
|
|
__all__.append("ModernGLDisplay")
|