refactor(display)!: remove deprecated backends, simplify protocol, and add BorderMode/UI rendering

- Remove SixelDisplay and KittyDisplay backends (unmaintained)
- Simplify Display protocol: reduce docstring noise, emphasize duck typing
- Add BorderMode enum (OFF, SIMPLE, UI) for flexible border rendering
- Rename render_border to _render_simple_border
- Add render_ui_panel() to compose main viewport with right-side UI panel
- Add new render_border() dispatcher supporting BorderMode
- Update __all__ to expose BorderMode, render_ui_panel, PygameDisplay
- Clean up DisplayRegistry: remove deprecated method docstrings
- Update tests: remove SixelDisplay import, assert sixel not in registry
- Add TODO comment to WebSocket backend about streaming improvements

This is a breaking change (removal of backends) but enables cleaner architecture and interactive UI panel.

Closes #13, #21
This commit is contained in:
2026-03-18 12:18:02 -07:00
parent 60ae4f7dfb
commit 36afbacb6b
6 changed files with 144 additions and 657 deletions

View File

@@ -5,102 +5,58 @@ Allows swapping output backends via the Display protocol.
Supports auto-discovery of display backends.
"""
from enum import Enum, auto
from typing import Protocol
from engine.display.backends.kitty import KittyDisplay
# 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.sixel import SixelDisplay
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.
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
Required attributes:
- width: int
- height: int
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
Required methods (duck typing - actual signatures may vary):
- init(width, height, reuse=False)
- show(buffer, border=False)
- clear()
- cleanup()
- get_dimensions() -> (width, height)
The reuse flag allows attaching to an existing display instance
rather than creating a new window/connection.
Optional attributes (for UI mode):
- ui_panel: UIPanel instance (set by app when border=UI)
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)
Optional methods:
- is_quit_requested() -> bool
- clear_quit_request() -> None
"""
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."""
@@ -110,22 +66,18 @@ class DisplayRegistry:
@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:
@@ -134,31 +86,18 @@ class DisplayRegistry:
@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)
if _MODERNGL_AVAILABLE:
cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type]
cls._initialized = True
@classmethod
def create_multi(cls, names: list[str]) -> "Display | None":
"""Create a MultiDisplay from a list of backend names.
Args:
names: List of display backend names (e.g., ["terminal", "pygame"])
Returns:
MultiDisplay instance or None if any backend fails
"""
from engine.display.backends.multi import MultiDisplay
def create_multi(cls, names: list[str]) -> MultiDisplay | None:
displays = []
for name in names:
backend = cls.create(name)
@@ -166,10 +105,8 @@ class DisplayRegistry:
displays.append(backend)
else:
return None
if not displays:
return None
return MultiDisplay(displays)
@@ -190,44 +127,28 @@ def _strip_ansi(s: str) -> str:
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
def render_border(
def _render_simple_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
"""
"""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
# 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:
@@ -248,10 +169,8 @@ def render_border(
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:
@@ -262,14 +181,107 @@ def render_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",
"WebSocketDisplay",
"SixelDisplay",
"MultiDisplay",
"PygameDisplay",
]
if _MODERNGL_AVAILABLE:
__all__.append("ModernGLDisplay")