feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
This commit is contained in:
290
engine/display/__init__.py
Normal file
290
engine/display/__init__.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
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")
|
||||
50
engine/display/backends/multi.py
Normal file
50
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Multi display backend - forwards to multiple displays.
|
||||
"""
|
||||
|
||||
|
||||
class MultiDisplay:
|
||||
"""Display that forwards to multiple displays.
|
||||
|
||||
Supports reuse - passes reuse flag to all child displays.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, displays: list):
|
||||
self.displays = displays
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize all child displays with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, use reuse mode for child displays
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
for d in self.displays:
|
||||
d.init(width, height, reuse=reuse)
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
for d in self.displays:
|
||||
d.show(buffer, border=border)
|
||||
|
||||
def clear(self) -> None:
|
||||
for d in self.displays:
|
||||
d.clear()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get dimensions from the first child display that supports it."""
|
||||
for d in self.displays:
|
||||
if hasattr(d, "get_dimensions"):
|
||||
return d.get_dimensions()
|
||||
return (self.width, self.height)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for d in self.displays:
|
||||
d.cleanup()
|
||||
183
engine/display/backends/null.py
Normal file
183
engine/display/backends/null.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Null/headless display backend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed. Captures last buffer
|
||||
for testing purposes. Supports frame recording for replay
|
||||
and file export/import.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_last_buffer: list[str] | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._last_buffer = None
|
||||
self._is_recording = False
|
||||
self._recorded_frames: list[dict[str, Any]] = []
|
||||
self._frame_count = 0
|
||||
|
||||
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 NullDisplay (no resources to reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).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
|
||||
|
||||
if border:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
self._last_buffer = buffer
|
||||
|
||||
if self._is_recording:
|
||||
self._recorded_frames.append(
|
||||
{
|
||||
"frame_number": self._frame_count,
|
||||
"buffer": buffer,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
)
|
||||
|
||||
if self._frame_count <= 5 or self._frame_count % 10 == 0:
|
||||
sys.stdout.write("\n" + "=" * 80 + "\n")
|
||||
sys.stdout.write(
|
||||
f"Frame {self._frame_count} (buffer height: {len(buffer)})\n"
|
||||
)
|
||||
sys.stdout.write("=" * 80 + "\n")
|
||||
for i, line in enumerate(buffer[:30]):
|
||||
sys.stdout.write(f"{i:2}: {line}\n")
|
||||
if len(buffer) > 30:
|
||||
sys.stdout.write(f"... ({len(buffer) - 30} more lines)\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if monitor:
|
||||
t0 = time.perf_counter()
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
self._frame_count += 1
|
||||
|
||||
def start_recording(self) -> None:
|
||||
"""Begin recording frames."""
|
||||
self._is_recording = True
|
||||
self._recorded_frames = []
|
||||
|
||||
def stop_recording(self) -> None:
|
||||
"""Stop recording frames."""
|
||||
self._is_recording = False
|
||||
|
||||
def get_frames(self) -> list[list[str]]:
|
||||
"""Get recorded frames as list of buffers.
|
||||
|
||||
Returns:
|
||||
List of buffers, each buffer is a list of strings (lines)
|
||||
"""
|
||||
return [frame["buffer"] for frame in self._recorded_frames]
|
||||
|
||||
def get_recorded_data(self) -> list[dict[str, Any]]:
|
||||
"""Get full recorded data including metadata.
|
||||
|
||||
Returns:
|
||||
List of frame dicts with 'frame_number', 'buffer', 'width', 'height'
|
||||
"""
|
||||
return self._recorded_frames
|
||||
|
||||
def clear_recording(self) -> None:
|
||||
"""Clear recorded frames."""
|
||||
self._recorded_frames = []
|
||||
|
||||
def save_recording(self, filepath: str | Path) -> None:
|
||||
"""Save recorded frames to a JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to save the recording
|
||||
"""
|
||||
path = Path(filepath)
|
||||
data = {
|
||||
"version": 1,
|
||||
"display": "null",
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"frame_count": len(self._recorded_frames),
|
||||
"frames": self._recorded_frames,
|
||||
}
|
||||
path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def load_recording(self, filepath: str | Path) -> list[dict[str, Any]]:
|
||||
"""Load recorded frames from a JSON file.
|
||||
|
||||
Args:
|
||||
filepath: Path to load the recording from
|
||||
|
||||
Returns:
|
||||
List of frame dicts
|
||||
"""
|
||||
path = Path(filepath)
|
||||
data = json.loads(path.read_text())
|
||||
self._recorded_frames = data.get("frames", [])
|
||||
self.width = data.get("width", 80)
|
||||
self.height = data.get("height", 24)
|
||||
return self._recorded_frames
|
||||
|
||||
def replay_frames(self) -> list[list[str]]:
|
||||
"""Get frames for replay.
|
||||
|
||||
Returns:
|
||||
List of buffers for replay
|
||||
"""
|
||||
return self.get_frames()
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
369
engine/display/backends/pygame.py
Normal file
369
engine/display/backends/pygame.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Pygame display backend - renders to a native application window.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
|
||||
class PygameDisplay:
|
||||
"""Pygame display backend - renders to native window.
|
||||
|
||||
Supports reuse mode - when reuse=True, skips SDL initialization
|
||||
and reuses the existing pygame window from a previous instance.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
window_width: int = 800
|
||||
window_height: int = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
window_width: int = 800,
|
||||
window_height: int = 600,
|
||||
target_fps: float = 30.0,
|
||||
):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
self.target_fps = target_fps
|
||||
self._initialized = False
|
||||
self._pygame = None
|
||||
self._screen = None
|
||||
self._font = None
|
||||
self._resized = False
|
||||
self._quit_requested = False
|
||||
self._last_frame_time = 0.0
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._glyph_cache = {}
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path for rendering."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
return env_font
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.append(os.path.expanduser("~/Library/Fonts/"))
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.append(
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\")
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return 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: If True, attach to existing pygame window instead of creating new
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if reuse and PygameDisplay._pygame_initialized:
|
||||
self._pygame = pygame
|
||||
self._initialized = True
|
||||
return
|
||||
|
||||
pygame.init()
|
||||
pygame.display.set_caption("Mainline")
|
||||
|
||||
self._screen = pygame.display.set_mode(
|
||||
(self.window_width, self.window_height),
|
||||
pygame.RESIZABLE,
|
||||
)
|
||||
self._pygame = pygame
|
||||
PygameDisplay._pygame_initialized = True
|
||||
|
||||
# Calculate character dimensions from actual window size
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
if font_path:
|
||||
try:
|
||||
self._font = pygame.font.Font(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
else:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
|
||||
# Check if font supports box-drawing characters; if not, try to find one
|
||||
self._use_fallback_border = False
|
||||
if self._font:
|
||||
try:
|
||||
# Test rendering some key box-drawing characters
|
||||
test_chars = ["┌", "─", "┐", "│", "└", "┘"]
|
||||
for ch in test_chars:
|
||||
surf = self._font.render(ch, True, (255, 255, 255))
|
||||
# If surface is empty (width=0 or all black), font lacks glyph
|
||||
if surf.get_width() == 0:
|
||||
raise ValueError("Missing glyph")
|
||||
except Exception:
|
||||
# Font doesn't support box-drawing, will use line drawing fallback
|
||||
self._use_fallback_border = True
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
if not self._initialized or not self._pygame:
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
for event in self._pygame.event.get():
|
||||
if event.type == self._pygame.QUIT:
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.KEYDOWN:
|
||||
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
|
||||
if event.key == self._pygame.K_c and not (
|
||||
event.mod & self._pygame.KMOD_LCTRL
|
||||
or event.mod & self._pygame.KMOD_RCTRL
|
||||
):
|
||||
continue
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.VIDEORESIZE:
|
||||
self.window_width = event.w
|
||||
self.window_height = event.h
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
self._resized = True
|
||||
|
||||
# FPS limiting - skip frame if we're going too fast
|
||||
if self._frame_period > 0:
|
||||
now = time.perf_counter()
|
||||
elapsed = now - self._last_frame_time
|
||||
if elapsed < self._frame_period:
|
||||
return # Skip this frame
|
||||
self._last_frame_time = now
|
||||
|
||||
# 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("pipeline", {}).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
|
||||
|
||||
self._screen.fill((0, 0, 0))
|
||||
|
||||
# If border requested but font lacks box-drawing glyphs, use graphical fallback
|
||||
if border and self._use_fallback_border:
|
||||
self._draw_fallback_border(fps, frame_time)
|
||||
# Adjust content area to fit inside border
|
||||
content_offset_x = self.cell_width
|
||||
content_offset_y = self.cell_height
|
||||
self.window_width - 2 * self.cell_width
|
||||
self.window_height - 2 * self.cell_height
|
||||
else:
|
||||
# Normal rendering (with or without text border)
|
||||
content_offset_x = 0
|
||||
content_offset_y = 0
|
||||
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
blit_list = []
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = content_offset_x
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Use None as key for no background
|
||||
bg_key = bg if bg != (0, 0, 0) else None
|
||||
cache_key = (text, fg, bg_key)
|
||||
|
||||
if cache_key not in self._glyph_cache:
|
||||
# Render and cache
|
||||
if bg_key is not None:
|
||||
self._glyph_cache[cache_key] = self._font.render(
|
||||
text, True, fg, bg_key
|
||||
)
|
||||
else:
|
||||
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||
|
||||
surface = self._glyph_cache[cache_key]
|
||||
blit_list.append(
|
||||
(surface, (x_pos, content_offset_y + row_idx * self.cell_height))
|
||||
)
|
||||
x_pos += self._font.size(text)[0]
|
||||
|
||||
self._screen.blits(blit_list)
|
||||
|
||||
# Draw fallback border using graphics if needed
|
||||
if border and self._use_fallback_border:
|
||||
self._draw_fallback_border(fps, frame_time)
|
||||
|
||||
self._pygame.display.flip()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def _draw_fallback_border(self, fps: float, frame_time: float) -> None:
|
||||
"""Draw border using pygame graphics primitives instead of text."""
|
||||
if not self._screen or not self._pygame:
|
||||
return
|
||||
|
||||
# Colors
|
||||
border_color = (0, 255, 0) # Green (like terminal border)
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
# Calculate dimensions
|
||||
x1 = 0
|
||||
y1 = 0
|
||||
x2 = self.window_width - 1
|
||||
y2 = self.window_height - 1
|
||||
|
||||
# Draw outer rectangle
|
||||
self._pygame.draw.rect(
|
||||
self._screen, border_color, (x1, y1, x2 - x1 + 1, y2 - y1 + 1), 1
|
||||
)
|
||||
|
||||
# Draw top border with FPS
|
||||
if fps > 0:
|
||||
fps_text = f" FPS:{fps:.0f}"
|
||||
else:
|
||||
fps_text = ""
|
||||
# We need to render this text with a fallback font that has basic ASCII
|
||||
# Use system font which should have these characters
|
||||
try:
|
||||
font = self._font # May not have box chars but should have alphanumeric
|
||||
text_surf = font.render(fps_text, True, text_color, (0, 0, 0))
|
||||
text_rect = text_surf.get_rect()
|
||||
# Position on top border, right-aligned
|
||||
text_x = x2 - text_rect.width - 5
|
||||
text_y = y1 + 2
|
||||
self._screen.blit(text_surf, (text_x, text_y))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Draw bottom border with frame time
|
||||
if frame_time > 0:
|
||||
ft_text = f" {frame_time:.1f}ms"
|
||||
try:
|
||||
ft_surf = self._font.render(ft_text, True, text_color, (0, 0, 0))
|
||||
ft_rect = ft_surf.get_rect()
|
||||
ft_x = x2 - ft_rect.width - 5
|
||||
ft_y = y2 - ft_rect.height - 2
|
||||
self._screen.blit(ft_surf, (ft_x, ft_y))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._screen and self._pygame:
|
||||
self._screen.fill((0, 0, 0))
|
||||
self._pygame.display.flip()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions based on window size.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
# Query actual window size and recalculate character cells
|
||||
if self._screen and self._pygame:
|
||||
try:
|
||||
w, h = self._screen.get_size()
|
||||
if w != self.window_width or h != self.window_height:
|
||||
self.window_width = w
|
||||
self.window_height = h
|
||||
self.width = max(1, w // self.cell_width)
|
||||
self.height = max(1, h // self.cell_height)
|
||||
except Exception:
|
||||
pass
|
||||
return self.width, self.height
|
||||
|
||||
def cleanup(self, quit_pygame: bool = True) -> None:
|
||||
"""Cleanup display resources.
|
||||
|
||||
Args:
|
||||
quit_pygame: If True, quit pygame entirely. Set to False when
|
||||
reusing the display to avoid closing shared window.
|
||||
"""
|
||||
if quit_pygame and self._pygame:
|
||||
self._pygame.quit()
|
||||
PygameDisplay._pygame_initialized = False
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""Reset pygame state - useful for testing."""
|
||||
cls._pygame_initialized = False
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||
|
||||
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
|
||||
The main loop should check this and raise KeyboardInterrupt.
|
||||
"""
|
||||
return self._quit_requested
|
||||
|
||||
def clear_quit_request(self) -> bool:
|
||||
"""Clear the quit request flag after handling.
|
||||
|
||||
Returns the previous quit request state.
|
||||
"""
|
||||
was_requested = self._quit_requested
|
||||
self._quit_requested = False
|
||||
return was_requested
|
||||
122
engine/display/backends/replay.py
Normal file
122
engine/display/backends/replay.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Replay display backend - plays back recorded frames.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ReplayDisplay:
|
||||
"""Replay display - plays back recorded frames.
|
||||
|
||||
This display reads frames from a recording (list of frame data)
|
||||
and yields them sequentially, useful for testing and demo purposes.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self):
|
||||
self._frames: list[dict[str, Any]] = []
|
||||
self._current_frame = 0
|
||||
self._playback_index = 0
|
||||
self._loop = False
|
||||
|
||||
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 ReplayDisplay
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def set_frames(self, frames: list[dict[str, Any]]) -> None:
|
||||
"""Set frames to replay.
|
||||
|
||||
Args:
|
||||
frames: List of frame dicts with 'buffer', 'width', 'height'
|
||||
"""
|
||||
self._frames = frames
|
||||
self._current_frame = 0
|
||||
self._playback_index = 0
|
||||
|
||||
def set_loop(self, loop: bool) -> None:
|
||||
"""Set loop playback mode.
|
||||
|
||||
Args:
|
||||
loop: True to loop, False to stop at end
|
||||
"""
|
||||
self._loop = loop
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Display a frame (ignored in replay mode).
|
||||
|
||||
Args:
|
||||
buffer: Buffer to display (ignored)
|
||||
border: Border flag (ignored)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_next_frame(self) -> list[str] | None:
|
||||
"""Get the next frame in the recording.
|
||||
|
||||
Returns:
|
||||
Buffer list of strings, or None if playback is done
|
||||
"""
|
||||
if not self._frames:
|
||||
return None
|
||||
|
||||
if self._playback_index >= len(self._frames):
|
||||
if self._loop:
|
||||
self._playback_index = 0
|
||||
else:
|
||||
return None
|
||||
|
||||
frame = self._frames[self._playback_index]
|
||||
self._playback_index += 1
|
||||
return frame.get("buffer")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset playback to the beginning."""
|
||||
self._playback_index = 0
|
||||
|
||||
def seek(self, index: int) -> None:
|
||||
"""Seek to a specific frame.
|
||||
|
||||
Args:
|
||||
index: Frame index to seek to
|
||||
"""
|
||||
if 0 <= index < len(self._frames):
|
||||
self._playback_index = index
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
"""Check if playback is finished.
|
||||
|
||||
Returns:
|
||||
True if at end of frames and not looping
|
||||
"""
|
||||
return not self._loop and self._playback_index >= len(self._frames)
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
133
engine/display/backends/terminal.py
Normal file
133
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend.
|
||||
|
||||
Renders buffer to stdout using ANSI escape codes.
|
||||
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
||||
Auto-detects terminal dimensions on init.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_initialized: bool = False
|
||||
|
||||
def __init__(self, target_fps: float = 30.0):
|
||||
self.target_fps = target_fps
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._last_frame_time = 0.0
|
||||
self._cached_dimensions: tuple[int, int] | None = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
If width/height are not provided (0/None), auto-detects terminal size.
|
||||
Otherwise uses provided dimensions or falls back to terminal size
|
||||
if the provided dimensions exceed terminal capacity.
|
||||
|
||||
Args:
|
||||
width: Desired terminal width (0 = auto-detect)
|
||||
height: Desired terminal height (0 = auto-detect)
|
||||
reuse: If True, skip terminal re-initialization
|
||||
"""
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
# Auto-detect terminal size (handle case where no terminal)
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
term_width = term_size.columns
|
||||
term_height = term_size.lines
|
||||
except OSError:
|
||||
# No terminal available (e.g., in tests)
|
||||
term_width = width if width > 0 else 80
|
||||
term_height = height if height > 0 else 24
|
||||
|
||||
# Use provided dimensions if valid, otherwise use terminal size
|
||||
if width > 0 and height > 0:
|
||||
self.width = min(width, term_width)
|
||||
self.height = min(height, term_height)
|
||||
else:
|
||||
self.width = term_width
|
||||
self.height = term_height
|
||||
|
||||
if not reuse or not self._initialized:
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
self._initialized = True
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions.
|
||||
|
||||
Returns cached dimensions to avoid querying terminal every frame,
|
||||
which can cause inconsistent results. Dimensions are only refreshed
|
||||
when they actually change.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
new_dims = (term_size.columns, term_size.lines)
|
||||
except OSError:
|
||||
new_dims = (self.width, self.height)
|
||||
|
||||
# Only update cached dimensions if they actually changed
|
||||
if self._cached_dimensions is None or self._cached_dimensions != new_dims:
|
||||
self._cached_dimensions = new_dims
|
||||
self.width = new_dims[0]
|
||||
self.height = new_dims[1]
|
||||
|
||||
return self._cached_dimensions
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
||||
# This display renders every frame it receives.
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).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
|
||||
from engine.display import BorderMode
|
||||
|
||||
if border and border != BorderMode.OFF:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
# Write buffer with cursor home + erase down to avoid flicker
|
||||
output = "\033[H\033[J" + "".join(buffer)
|
||||
sys.stdout.buffer.write(output.encode())
|
||||
sys.stdout.flush()
|
||||
|
||||
def clear(self) -> None:
|
||||
from engine.terminal import CLR
|
||||
|
||||
print(CLR, end="", flush=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
from engine.terminal import CURSOR_ON
|
||||
|
||||
print(CURSOR_ON, end="", flush=True)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
464
engine/display/backends/websocket.py
Normal file
464
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||
|
||||
Supports streaming protocols:
|
||||
- Full frame (JSON) - default for compatibility
|
||||
- Binary streaming - efficient binary protocol
|
||||
- Diff streaming - only sends changed lines
|
||||
|
||||
TODO: Transform to a true streaming backend with:
|
||||
- Proper WebSocket message streaming (currently sends full buffer each frame)
|
||||
- Connection pooling and backpressure handling
|
||||
- Binary protocol for efficiency (instead of JSON)
|
||||
- Client management with proper async handling
|
||||
- Mark for deprecation if replaced by a new streaming implementation
|
||||
|
||||
Current implementation: Simple broadcast of text frames to all connected clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from enum import IntFlag
|
||||
|
||||
from engine.display.streaming import (
|
||||
MessageType,
|
||||
compress_frame,
|
||||
compute_diff,
|
||||
encode_binary_message,
|
||||
encode_diff_message,
|
||||
)
|
||||
|
||||
|
||||
class StreamingMode(IntFlag):
|
||||
"""Streaming modes for WebSocket display."""
|
||||
|
||||
JSON = 0x01 # Full JSON frames (default, compatible)
|
||||
BINARY = 0x02 # Binary compression
|
||||
DIFF = 0x04 # Differential updates
|
||||
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class WebSocketDisplay:
|
||||
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
http_port: int = 8766,
|
||||
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.http_port = http_port
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self._clients: set = set()
|
||||
self._server_running = False
|
||||
self._http_running = False
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._http_thread: threading.Thread | None = None
|
||||
self._available = True
|
||||
self._max_clients = 10
|
||||
self._client_connected_callback = None
|
||||
self._client_disconnected_callback = None
|
||||
self._command_callback = None
|
||||
self._controller = None # Reference to UI panel or pipeline controller
|
||||
self._frame_delay = 0.0
|
||||
self._httpd = None # HTTP server instance
|
||||
|
||||
# Streaming configuration
|
||||
self._streaming_mode = streaming_mode
|
||||
self._last_buffer: list[str] = []
|
||||
self._client_capabilities: dict = {} # Track client capabilities
|
||||
|
||||
try:
|
||||
import websockets as _ws
|
||||
|
||||
self._available = _ws is not None
|
||||
except ImportError:
|
||||
self._available = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if WebSocket support is available."""
|
||||
return self._available
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions and start server.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, skip starting servers (assume already running)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if not reuse or not self._server_running:
|
||||
self.start_server()
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).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)
|
||||
|
||||
if not self._clients:
|
||||
self._last_buffer = buffer
|
||||
return
|
||||
|
||||
# Send to each client based on their capabilities
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
client_id = id(client)
|
||||
client_mode = self._client_capabilities.get(
|
||||
client_id, StreamingMode.JSON
|
||||
)
|
||||
|
||||
if client_mode & StreamingMode.DIFF:
|
||||
self._send_diff_frame(client, buffer)
|
||||
elif client_mode & StreamingMode.BINARY:
|
||||
self._send_binary_frame(client, buffer)
|
||||
else:
|
||||
self._send_json_frame(client, buffer)
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
|
||||
self._last_buffer = buffer
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def _send_json_frame(self, client, buffer: list[str]) -> None:
|
||||
"""Send frame as JSON."""
|
||||
frame_data = {
|
||||
"type": "frame",
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"lines": buffer,
|
||||
}
|
||||
message = json.dumps(frame_data)
|
||||
asyncio.run(client.send(message))
|
||||
|
||||
def _send_binary_frame(self, client, buffer: list[str]) -> None:
|
||||
"""Send frame as compressed binary."""
|
||||
compressed = compress_frame(buffer)
|
||||
message = encode_binary_message(
|
||||
MessageType.FULL_FRAME, self.width, self.height, compressed
|
||||
)
|
||||
encoded = base64.b64encode(message).decode("utf-8")
|
||||
asyncio.run(client.send(encoded))
|
||||
|
||||
def _send_diff_frame(self, client, buffer: list[str]) -> None:
|
||||
"""Send frame as diff."""
|
||||
diff = compute_diff(self._last_buffer, buffer)
|
||||
|
||||
if not diff.changed_lines:
|
||||
return
|
||||
|
||||
diff_payload = encode_diff_message(diff)
|
||||
message = encode_binary_message(
|
||||
MessageType.DIFF_FRAME, self.width, self.height, diff_payload
|
||||
)
|
||||
encoded = base64.b64encode(message).decode("utf-8")
|
||||
asyncio.run(client.send(encoded))
|
||||
|
||||
def set_streaming_mode(self, mode: StreamingMode) -> None:
|
||||
"""Set the default streaming mode for new clients."""
|
||||
self._streaming_mode = mode
|
||||
|
||||
def get_streaming_mode(self) -> StreamingMode:
|
||||
"""Get the current streaming mode."""
|
||||
return self._streaming_mode
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Broadcast clear command to all clients."""
|
||||
if self._clients:
|
||||
clear_data = {"type": "clear"}
|
||||
message = json.dumps(clear_data)
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the servers."""
|
||||
self.stop_server()
|
||||
self.stop_http_server()
|
||||
|
||||
async def _websocket_handler(self, websocket):
|
||||
"""Handle WebSocket connections."""
|
||||
if len(self._clients) >= self._max_clients:
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
self._clients.add(websocket)
|
||||
if self._client_connected_callback:
|
||||
self._client_connected_callback(websocket)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
self.width = data.get("width", 80)
|
||||
self.height = data.get("height", 24)
|
||||
elif msg_type == "command" and self._command_callback:
|
||||
# Forward commands to the pipeline controller
|
||||
command = data.get("command", {})
|
||||
self._command_callback(command)
|
||||
elif msg_type == "state_request":
|
||||
# Send current state snapshot
|
||||
state = self._get_state_snapshot()
|
||||
if state:
|
||||
response = {"type": "state", "state": state}
|
||||
await websocket.send(json.dumps(response))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clients.discard(websocket)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(websocket)
|
||||
|
||||
async def _run_websocket_server(self):
|
||||
"""Run the WebSocket server."""
|
||||
if not websockets:
|
||||
return
|
||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||
while self._server_running:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _run_http_server(self):
|
||||
"""Run simple HTTP server for the client."""
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
# Find the project root by locating 'engine' directory in the path
|
||||
websocket_file = os.path.abspath(__file__)
|
||||
parts = websocket_file.split(os.sep)
|
||||
if "engine" in parts:
|
||||
engine_idx = parts.index("engine")
|
||||
project_root = os.sep.join(parts[:engine_idx])
|
||||
client_dir = os.path.join(project_root, "client")
|
||||
else:
|
||||
# Fallback: go up 4 levels from websocket.py
|
||||
# websocket.py: .../engine/display/backends/websocket.py
|
||||
# We need: .../client
|
||||
client_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
),
|
||||
"client",
|
||||
)
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=client_dir, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||
# Store reference for shutdown
|
||||
self._httpd = httpd
|
||||
# Serve requests continuously
|
||||
httpd.serve_forever()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run coroutine in background."""
|
||||
try:
|
||||
asyncio.run(coro)
|
||||
except Exception as e:
|
||||
print(f"WebSocket async error: {e}")
|
||||
|
||||
def start_server(self):
|
||||
"""Start the WebSocket server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._server_thread is not None:
|
||||
return
|
||||
|
||||
self._server_running = True
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
def stop_server(self):
|
||||
"""Stop the WebSocket server."""
|
||||
self._server_running = False
|
||||
self._server_thread = None
|
||||
|
||||
def start_http_server(self):
|
||||
"""Start the HTTP server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._http_thread is not None:
|
||||
return
|
||||
|
||||
self._http_running = True
|
||||
|
||||
self._http_running = True
|
||||
self._http_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||
)
|
||||
self._http_thread.start()
|
||||
|
||||
def stop_http_server(self):
|
||||
"""Stop the HTTP server."""
|
||||
self._http_running = False
|
||||
if hasattr(self, "_httpd") and self._httpd:
|
||||
self._httpd.shutdown()
|
||||
self._http_thread = None
|
||||
|
||||
def client_count(self) -> int:
|
||||
"""Return number of connected clients."""
|
||||
return len(self._clients)
|
||||
|
||||
def get_ws_port(self) -> int:
|
||||
"""Return WebSocket port."""
|
||||
return self.port
|
||||
|
||||
def get_http_port(self) -> int:
|
||||
"""Return HTTP port."""
|
||||
return self.http_port
|
||||
|
||||
def set_frame_delay(self, delay: float) -> None:
|
||||
"""Set delay between frames in seconds."""
|
||||
self._frame_delay = delay
|
||||
|
||||
def get_frame_delay(self) -> float:
|
||||
"""Get delay between frames."""
|
||||
return self._frame_delay
|
||||
|
||||
def set_client_connected_callback(self, callback) -> None:
|
||||
"""Set callback for client connections."""
|
||||
self._client_connected_callback = callback
|
||||
|
||||
def set_client_disconnected_callback(self, callback) -> None:
|
||||
"""Set callback for client disconnections."""
|
||||
self._client_disconnected_callback = callback
|
||||
|
||||
def set_command_callback(self, callback) -> None:
|
||||
"""Set callback for incoming command messages from clients."""
|
||||
self._command_callback = callback
|
||||
|
||||
def set_controller(self, controller) -> None:
|
||||
"""Set controller (UI panel or pipeline) for state queries and command execution."""
|
||||
self._controller = controller
|
||||
|
||||
def broadcast_state(self, state: dict) -> None:
|
||||
"""Broadcast state update to all connected clients.
|
||||
|
||||
Args:
|
||||
state: Dictionary containing state data to send to clients
|
||||
"""
|
||||
if not self._clients:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": "state", "state": state})
|
||||
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
|
||||
def _get_state_snapshot(self) -> dict | None:
|
||||
"""Get current state snapshot from controller."""
|
||||
if not self._controller:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Expect controller to have methods we need
|
||||
state = {}
|
||||
|
||||
# Get stages info if UIPanel
|
||||
if hasattr(self._controller, "stages"):
|
||||
state["stages"] = {
|
||||
name: {
|
||||
"enabled": ctrl.enabled,
|
||||
"params": ctrl.params,
|
||||
"selected": ctrl.selected,
|
||||
}
|
||||
for name, ctrl in self._controller.stages.items()
|
||||
}
|
||||
|
||||
# Get current preset
|
||||
if hasattr(self._controller, "_current_preset"):
|
||||
state["preset"] = self._controller._current_preset
|
||||
if hasattr(self._controller, "_presets"):
|
||||
state["presets"] = self._controller._presets
|
||||
|
||||
# Get selected stage
|
||||
if hasattr(self._controller, "selected_stage"):
|
||||
state["selected_stage"] = self._controller.selected_stage
|
||||
|
||||
return state
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Shared display rendering utilities.
|
||||
|
||||
Provides common functionality for displays that render text to images
|
||||
(Pygame, Sixel, Kitty displays).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
ANSI_COLORS = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
|
||||
def parse_ansi(
|
||||
text: str,
|
||||
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||
"""Parse ANSI escape sequences into text tokens with colors.
|
||||
|
||||
Args:
|
||||
text: Text containing ANSI escape sequences
|
||||
|
||||
Returns:
|
||||
List of (text, fg_rgb, bg_rgb, bold) tuples
|
||||
"""
|
||||
tokens = []
|
||||
current_text = ""
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
i = 0
|
||||
|
||||
ANSI_COLORS_4BIT = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
|
||||
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
current_text = ""
|
||||
|
||||
i += 2
|
||||
code = ""
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if c.isalpha():
|
||||
break
|
||||
code += c
|
||||
i += 1
|
||||
|
||||
if code:
|
||||
codes = code.split(";")
|
||||
for c in codes:
|
||||
if c == "0":
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
elif c == "1":
|
||||
bold = True
|
||||
elif c == "22":
|
||||
bold = False
|
||||
elif c == "39":
|
||||
fg = (204, 204, 204)
|
||||
elif c == "49":
|
||||
bg = (0, 0, 0)
|
||||
elif c.isdigit():
|
||||
color_idx = int(c)
|
||||
if color_idx in ANSI_COLORS_4BIT:
|
||||
fg = ANSI_COLORS_4BIT[color_idx]
|
||||
elif 30 <= color_idx <= 37:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg)
|
||||
elif 40 <= color_idx <= 47:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg)
|
||||
elif 90 <= color_idx <= 97:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg)
|
||||
elif 100 <= color_idx <= 107:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg)
|
||||
elif c.startswith("38;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
fg = ANSI_COLORS_4BIT.get(idx, fg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
fg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
fg = (gray, gray, gray)
|
||||
elif c.startswith("48;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
bg = ANSI_COLORS_4BIT.get(idx, bg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
bg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
bg = (gray, gray, gray)
|
||||
i += 1
|
||||
else:
|
||||
current_text += char
|
||||
i += 1
|
||||
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
|
||||
return tokens if tokens else [("", fg, bg, bold)]
|
||||
|
||||
|
||||
def get_default_font_path() -> str | None:
|
||||
"""Get the path to a default monospace font."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
if "mono" in name or "courier" in name or "terminal" in name:
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/Library/Fonts/"),
|
||||
"/System/Library/Fonts/",
|
||||
]
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\"),
|
||||
"C:\\Windows\\Fonts\\",
|
||||
]
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]:
|
||||
result = subprocess.run(
|
||||
["fc-match", "-f", "%{file}", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
font_file = result.stdout.strip()
|
||||
if os.path.exists(font_file):
|
||||
return font_file
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_to_pil(
|
||||
buffer: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
font_path: str | None = None,
|
||||
) -> Any:
|
||||
"""Render buffer to a PIL Image.
|
||||
|
||||
Args:
|
||||
buffer: List of text lines to render
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
cell_width: Width of each character cell in pixels
|
||||
cell_height: Height of each character cell in pixels
|
||||
font_path: Path to TTF/OTF font file (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
img_width = width * cell_width
|
||||
img_height = height * cell_height
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, cell_height - 2)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row_idx, line in enumerate(buffer[:height]):
|
||||
if row_idx >= height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * 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))
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
return img
|
||||
268
engine/display/streaming.py
Normal file
268
engine/display/streaming.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Streaming protocol utilities for efficient frame transmission.
|
||||
|
||||
Provides:
|
||||
- Frame differencing: Only send changed lines
|
||||
- Run-length encoding: Compress repeated lines
|
||||
- Binary encoding: Compact message format
|
||||
"""
|
||||
|
||||
import json
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class MessageType(IntEnum):
|
||||
"""Message types for streaming protocol."""
|
||||
|
||||
FULL_FRAME = 1
|
||||
DIFF_FRAME = 2
|
||||
STATE = 3
|
||||
CLEAR = 4
|
||||
PING = 5
|
||||
PONG = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameDiff:
|
||||
"""Represents a diff between two frames."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
changed_lines: list[tuple[int, str]] # (line_index, content)
|
||||
|
||||
|
||||
def compute_diff(old_buffer: list[str], new_buffer: list[str]) -> FrameDiff:
|
||||
"""Compute differences between old and new buffer.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame buffer
|
||||
new_buffer: Current frame buffer
|
||||
|
||||
Returns:
|
||||
FrameDiff with only changed lines
|
||||
"""
|
||||
height = len(new_buffer)
|
||||
changed_lines = []
|
||||
|
||||
for i, line in enumerate(new_buffer):
|
||||
if i >= len(old_buffer) or line != old_buffer[i]:
|
||||
changed_lines.append((i, line))
|
||||
|
||||
return FrameDiff(
|
||||
width=len(new_buffer[0]) if new_buffer else 0,
|
||||
height=height,
|
||||
changed_lines=changed_lines,
|
||||
)
|
||||
|
||||
|
||||
def encode_rle(lines: list[tuple[int, str]]) -> list[tuple[int, str, int]]:
|
||||
"""Run-length encode consecutive identical lines.
|
||||
|
||||
Args:
|
||||
lines: List of (index, content) tuples (must be sorted by index)
|
||||
|
||||
Returns:
|
||||
List of (start_index, content, run_length) tuples
|
||||
"""
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
encoded = []
|
||||
start_idx = lines[0][0]
|
||||
current_line = lines[0][1]
|
||||
current_rle = 1
|
||||
|
||||
for idx, line in lines[1:]:
|
||||
if line == current_line:
|
||||
current_rle += 1
|
||||
else:
|
||||
encoded.append((start_idx, current_line, current_rle))
|
||||
start_idx = idx
|
||||
current_line = line
|
||||
current_rle = 1
|
||||
|
||||
encoded.append((start_idx, current_line, current_rle))
|
||||
return encoded
|
||||
|
||||
|
||||
def decode_rle(encoded: list[tuple[int, str, int]]) -> list[tuple[int, str]]:
|
||||
"""Decode run-length encoded lines.
|
||||
|
||||
Args:
|
||||
encoded: List of (start_index, content, run_length) tuples
|
||||
|
||||
Returns:
|
||||
List of (index, content) tuples
|
||||
"""
|
||||
result = []
|
||||
for start_idx, line, rle in encoded:
|
||||
for i in range(rle):
|
||||
result.append((start_idx + i, line))
|
||||
return result
|
||||
|
||||
|
||||
def compress_frame(buffer: list[str], level: int = 6) -> bytes:
|
||||
"""Compress a frame buffer using zlib.
|
||||
|
||||
Args:
|
||||
buffer: Frame buffer (list of lines)
|
||||
level: Compression level (0-9)
|
||||
|
||||
Returns:
|
||||
Compressed bytes
|
||||
"""
|
||||
content = "\n".join(buffer)
|
||||
return zlib.compress(content.encode("utf-8"), level)
|
||||
|
||||
|
||||
def decompress_frame(data: bytes, height: int) -> list[str]:
|
||||
"""Decompress a frame buffer.
|
||||
|
||||
Args:
|
||||
data: Compressed bytes
|
||||
height: Number of lines in original buffer
|
||||
|
||||
Returns:
|
||||
Frame buffer (list of lines)
|
||||
"""
|
||||
content = zlib.decompress(data).decode("utf-8")
|
||||
lines = content.split("\n")
|
||||
if len(lines) > height:
|
||||
lines = lines[:height]
|
||||
while len(lines) < height:
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def encode_binary_message(
|
||||
msg_type: MessageType, width: int, height: int, payload: bytes
|
||||
) -> bytes:
|
||||
"""Encode a binary message.
|
||||
|
||||
Message format:
|
||||
- 1 byte: message type
|
||||
- 2 bytes: width (uint16)
|
||||
- 2 bytes: height (uint16)
|
||||
- 4 bytes: payload length (uint32)
|
||||
- N bytes: payload
|
||||
|
||||
Args:
|
||||
msg_type: Message type
|
||||
width: Frame width
|
||||
height: Frame height
|
||||
payload: Message payload
|
||||
|
||||
Returns:
|
||||
Encoded binary message
|
||||
"""
|
||||
import struct
|
||||
|
||||
header = struct.pack("!BHHI", msg_type.value, width, height, len(payload))
|
||||
return header + payload
|
||||
|
||||
|
||||
def decode_binary_message(data: bytes) -> tuple[MessageType, int, int, bytes]:
|
||||
"""Decode a binary message.
|
||||
|
||||
Args:
|
||||
data: Binary message data
|
||||
|
||||
Returns:
|
||||
Tuple of (msg_type, width, height, payload)
|
||||
"""
|
||||
import struct
|
||||
|
||||
msg_type_val, width, height, payload_len = struct.unpack("!BHHI", data[:9])
|
||||
payload = data[9 : 9 + payload_len]
|
||||
return MessageType(msg_type_val), width, height, payload
|
||||
|
||||
|
||||
def encode_diff_message(diff: FrameDiff, use_rle: bool = True) -> bytes:
|
||||
"""Encode a diff message for transmission.
|
||||
|
||||
Args:
|
||||
diff: Frame diff
|
||||
use_rle: Whether to use run-length encoding
|
||||
|
||||
Returns:
|
||||
Encoded diff payload
|
||||
"""
|
||||
|
||||
if use_rle:
|
||||
encoded_lines = encode_rle(diff.changed_lines)
|
||||
data = [[idx, line, rle] for idx, line, rle in encoded_lines]
|
||||
else:
|
||||
data = [[idx, line] for idx, line in diff.changed_lines]
|
||||
|
||||
payload = json.dumps(data).encode("utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def decode_diff_message(payload: bytes, use_rle: bool = True) -> list[tuple[int, str]]:
|
||||
"""Decode a diff message.
|
||||
|
||||
Args:
|
||||
payload: Encoded diff payload
|
||||
use_rle: Whether run-length encoding was used
|
||||
|
||||
Returns:
|
||||
List of (line_index, content) tuples
|
||||
"""
|
||||
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
|
||||
if use_rle:
|
||||
return decode_rle([(idx, line, rle) for idx, line, rle in data])
|
||||
else:
|
||||
return [(idx, line) for idx, line in data]
|
||||
|
||||
|
||||
def should_use_diff(
|
||||
old_buffer: list[str], new_buffer: list[str], threshold: float = 0.3
|
||||
) -> bool:
|
||||
"""Determine if diff or full frame is more efficient.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame
|
||||
new_buffer: Current frame
|
||||
threshold: Max changed ratio to use diff (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
True if diff is more efficient
|
||||
"""
|
||||
if not old_buffer or not new_buffer:
|
||||
return False
|
||||
|
||||
diff = compute_diff(old_buffer, new_buffer)
|
||||
total_lines = len(new_buffer)
|
||||
changed_ratio = len(diff.changed_lines) / total_lines if total_lines > 0 else 1.0
|
||||
|
||||
return changed_ratio <= threshold
|
||||
|
||||
|
||||
def apply_diff(old_buffer: list[str], diff: FrameDiff) -> list[str]:
|
||||
"""Apply a diff to an old buffer to get the new buffer.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame buffer
|
||||
diff: Frame diff to apply
|
||||
|
||||
Returns:
|
||||
New frame buffer
|
||||
"""
|
||||
new_buffer = list(old_buffer)
|
||||
|
||||
for line_idx, content in diff.changed_lines:
|
||||
if line_idx < len(new_buffer):
|
||||
new_buffer[line_idx] = content
|
||||
else:
|
||||
while len(new_buffer) < line_idx:
|
||||
new_buffer.append("")
|
||||
new_buffer.append(content)
|
||||
|
||||
while len(new_buffer) < diff.height:
|
||||
new_buffer.append("")
|
||||
|
||||
return new_buffer[: diff.height]
|
||||
Reference in New Issue
Block a user