From f5de2c62e0adbc02c5bfa6e81fddaab7f2277942 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Mar 2026 00:30:52 -0700 Subject: [PATCH] feat(display): add reuse flag to Display protocol - Add reuse parameter to Display.init() for all backends - PygameDisplay: reuse existing SDL window via class-level flag - TerminalDisplay: skip re-init when reuse=True - WebSocketDisplay: skip server start when reuse=True - SixelDisplay, KittyDisplay, NullDisplay: ignore reuse (not applicable) - MultiDisplay: pass reuse to child displays - Update benchmark.py to reuse pygame display for effect benchmarks - Add test_websocket_e2e.py with e2e marker - Register e2e marker in pyproject.toml --- engine/benchmark.py | 87 +++++++++++++++++++++++++--- engine/controller.py | 8 +++ engine/display/__init__.py | 23 +++++++- engine/display/backends/kitty.py | 9 ++- engine/display/backends/multi.py | 16 ++++- engine/display/backends/null.py | 15 ++++- engine/display/backends/pygame.py | 47 ++++++++++++--- engine/display/backends/sixel.py | 9 ++- engine/display/backends/terminal.py | 21 ++++++- engine/display/backends/websocket.py | 16 +++-- pyproject.toml | 1 + tests/test_display.py | 13 ++++- tests/test_websocket_e2e.py | 78 +++++++++++++++++++++++++ 13 files changed, 309 insertions(+), 34 deletions(-) create mode 100644 tests/test_websocket_e2e.py diff --git a/engine/benchmark.py b/engine/benchmark.py index 51a88fe..0aef02e 100644 --- a/engine/benchmark.py +++ b/engine/benchmark.py @@ -62,9 +62,21 @@ def get_sample_buffer(width: int = 80, height: int = 24) -> list[str]: def benchmark_display( - display_class, buffer: list[str], iterations: int = 100 + display_class, + buffer: list[str], + iterations: int = 100, + display=None, + reuse: bool = False, ) -> BenchmarkResult | None: - """Benchmark a single display.""" + """Benchmark a single display. + + Args: + display_class: Display class to instantiate + buffer: Buffer to display + iterations: Number of iterations + display: Optional existing display instance to reuse + reuse: If True and display provided, use reuse mode + """ old_stdout = sys.stdout old_stderr = sys.stderr @@ -72,8 +84,12 @@ def benchmark_display( sys.stdout = StringIO() sys.stderr = StringIO() - display = display_class() - display.init(80, 24) + if display is None: + display = display_class() + display.init(80, 24, reuse=False) + should_cleanup = True + else: + should_cleanup = False times = [] chars = sum(len(line) for line in buffer) @@ -84,7 +100,8 @@ def benchmark_display( elapsed = (time.perf_counter() - t0) * 1000 times.append(elapsed) - display.cleanup() + if should_cleanup and hasattr(display, "cleanup"): + display.cleanup(quit_pygame=False) except Exception: return None @@ -113,9 +130,17 @@ def benchmark_display( def benchmark_effect_with_display( - effect_class, display, buffer: list[str], iterations: int = 100 + effect_class, display, buffer: list[str], iterations: int = 100, reuse: bool = False ) -> BenchmarkResult | None: - """Benchmark an effect with a display.""" + """Benchmark an effect with a display. + + Args: + effect_class: Effect class to instantiate + display: Display instance to use + buffer: Buffer to process and display + iterations: Number of iterations + reuse: If True, use reuse mode for display + """ old_stdout = sys.stdout old_stderr = sys.stderr @@ -149,7 +174,8 @@ def benchmark_effect_with_display( elapsed = (time.perf_counter() - t0) * 1000 times.append(elapsed) - display.cleanup() + if not reuse and hasattr(display, "cleanup"): + display.cleanup(quit_pygame=False) except Exception: return None @@ -206,6 +232,13 @@ def get_available_displays(): except Exception: pass + try: + from engine.display.backends.pygame import PygameDisplay + + displays.append(("pygame", PygameDisplay)) + except Exception: + pass + return displays @@ -255,6 +288,7 @@ def run_benchmarks( if verbose: print(f"Running benchmarks ({iterations} iterations each)...") + pygame_display = None for name, display_class in displays: if verbose: print(f"Benchmarking display: {name}") @@ -265,13 +299,44 @@ def run_benchmarks( if verbose: print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg") + if name == "pygame": + pygame_display = result + if verbose: print() + pygame_instance = None + if pygame_display: + try: + from engine.display.backends.pygame import PygameDisplay + + PygameDisplay.reset_state() + pygame_instance = PygameDisplay() + pygame_instance.init(80, 24, reuse=False) + except Exception: + pygame_instance = None + for effect_name, effect_class in effects: for display_name, display_class in displays: if display_name == "websocket": continue + + if display_name == "pygame": + if verbose: + print(f"Benchmarking effect: {effect_name} with {display_name}") + + if pygame_instance: + result = benchmark_effect_with_display( + effect_class, pygame_instance, buffer, iterations, reuse=True + ) + if result: + results.append(result) + if verbose: + print( + f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg" + ) + continue + if verbose: print(f"Benchmarking effect: {effect_name} with {display_name}") @@ -285,6 +350,12 @@ def run_benchmarks( if verbose: print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg") + if pygame_instance: + try: + pygame_instance.cleanup(quit_pygame=True) + except Exception: + pass + summary = generate_summary(results) return BenchmarkReport( diff --git a/engine/controller.py b/engine/controller.py index 0d7bf6f..2f96a0b 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -5,8 +5,10 @@ Stream controller - manages input sources and orchestrates the render stream. from engine.config import Config, get_config from engine.display import ( DisplayRegistry, + KittyDisplay, MultiDisplay, NullDisplay, + PygameDisplay, SixelDisplay, TerminalDisplay, WebSocketDisplay, @@ -38,6 +40,12 @@ def _get_display(config: Config): if display_mode == "sixel": displays.append(SixelDisplay()) + if display_mode == "kitty": + displays.append(KittyDisplay()) + + if display_mode == "pygame": + displays.append(PygameDisplay()) + if not displays: return NullDisplay() diff --git a/engine/display/__init__.py b/engine/display/__init__.py index b93bb18..3ee2b26 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -17,13 +17,30 @@ from engine.display.backends.websocket import WebSocketDisplay class Display(Protocol): - """Protocol for display backends.""" + """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 + + The reuse flag allows attaching to an existing display instance + rather than creating a new window/connection. + """ width: int height: int - def init(self, width: int, height: int) -> None: - """Initialize display with dimensions.""" + 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]) -> None: diff --git a/engine/display/backends/kitty.py b/engine/display/backends/kitty.py index fca8f94..7654f07 100644 --- a/engine/display/backends/kitty.py +++ b/engine/display/backends/kitty.py @@ -126,7 +126,14 @@ class KittyDisplay: self._initialized = False self._font_path = None - def init(self, width: int, height: int) -> None: + def init(self, width: int, height: int, reuse: bool = False) -> None: + """Initialize display with dimensions. + + Args: + width: Terminal width in characters + height: Terminal height in rows + reuse: Ignored for KittyDisplay (protocol doesn't support reuse) + """ self.width = width self.height = height self._initialized = True diff --git a/engine/display/backends/multi.py b/engine/display/backends/multi.py index d37667d..496eda9 100644 --- a/engine/display/backends/multi.py +++ b/engine/display/backends/multi.py @@ -4,7 +4,10 @@ Multi display backend - forwards to multiple displays. class MultiDisplay: - """Display that forwards to multiple displays.""" + """Display that forwards to multiple displays. + + Supports reuse - passes reuse flag to all child displays. + """ width: int = 80 height: int = 24 @@ -14,11 +17,18 @@ class MultiDisplay: self.width = 80 self.height = 24 - def init(self, width: int, height: int) -> None: + 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) + d.init(width, height, reuse=reuse) def show(self, buffer: list[str]) -> None: for d in self.displays: diff --git a/engine/display/backends/null.py b/engine/display/backends/null.py index 1865f52..5c89086 100644 --- a/engine/display/backends/null.py +++ b/engine/display/backends/null.py @@ -6,12 +6,23 @@ import time class NullDisplay: - """Headless/null display - discards all output.""" + """Headless/null display - discards all output. + + This display does nothing - useful for headless benchmarking + or when no display output is needed. + """ width: int = 80 height: int = 24 - def init(self, width: int, height: int) -> None: + def init(self, width: int, height: int, reuse: bool = False) -> None: + """Initialize display with dimensions. + + Args: + width: Terminal width in characters + height: Terminal height in rows + reuse: Ignored for NullDisplay (no resources to reuse) + """ self.width = width self.height = height diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index cececc9..5ebde15 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -6,12 +6,16 @@ import time class PygameDisplay: - """Pygame display backend - renders to native window.""" + """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 - height: int = 24 window_width: int = 800 window_height: int = 600 + _pygame_initialized: bool = False def __init__( self, @@ -76,20 +80,37 @@ class PygameDisplay: return None - def init(self, width: int, height: int) -> 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 + import os + + os.environ["SDL_VIDEODRIVER"] = "x11" + try: import pygame except ImportError: return + if reuse and PygameDisplay._pygame_initialized: + self._pygame = pygame + self._initialized = True + return + pygame.init() - self._pygame = pygame + pygame.display.set_caption("Mainline") self._screen = pygame.display.set_mode((self.window_width, self.window_height)) - pygame.display.set_caption("Mainline") + self._pygame = pygame + PygameDisplay._pygame_initialized = True font_path = self._get_font_path() if font_path: @@ -218,6 +239,18 @@ class PygameDisplay: self._screen.fill((0, 0, 0)) self._pygame.display.flip() - def cleanup(self) -> None: - if self._pygame: + 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 diff --git a/engine/display/backends/sixel.py b/engine/display/backends/sixel.py index 3e1a2b5..6d04776 100644 --- a/engine/display/backends/sixel.py +++ b/engine/display/backends/sixel.py @@ -271,7 +271,14 @@ class SixelDisplay: return None - def init(self, width: int, height: int) -> None: + def init(self, width: int, height: int, reuse: bool = False) -> None: + """Initialize display with dimensions. + + Args: + width: Terminal width in characters + height: Terminal height in rows + reuse: Ignored for SixelDisplay + """ self.width = width self.height = height self._initialized = True diff --git a/engine/display/backends/terminal.py b/engine/display/backends/terminal.py index e329acf..d3d490d 100644 --- a/engine/display/backends/terminal.py +++ b/engine/display/backends/terminal.py @@ -6,17 +6,32 @@ import time class TerminalDisplay: - """ANSI terminal display backend.""" + """ANSI terminal display backend. + + Renders buffer to stdout using ANSI escape codes. + Supports reuse - when reuse=True, skips re-initializing terminal state. + """ width: int = 80 height: int = 24 + _initialized: bool = False - def init(self, width: int, height: int) -> 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, skip terminal re-initialization + """ from engine.terminal import CURSOR_OFF self.width = width self.height = height - print(CURSOR_OFF, end="", flush=True) + + if not reuse or not self._initialized: + print(CURSOR_OFF, end="", flush=True) + self._initialized = True def show(self, buffer: list[str]) -> None: import sys diff --git a/engine/display/backends/websocket.py b/engine/display/backends/websocket.py index 6f0117b..f7d6c38 100644 --- a/engine/display/backends/websocket.py +++ b/engine/display/backends/websocket.py @@ -86,12 +86,20 @@ class WebSocketDisplay: """Check if WebSocket support is available.""" return self._available - def init(self, width: int, height: int) -> None: - """Initialize display with dimensions and start server.""" + 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 - self.start_server() - self.start_http_server() + + if not reuse or not self._server_running: + self.start_server() + self.start_http_server() def show(self, buffer: list[str]) -> None: """Broadcast buffer to all connected clients.""" diff --git a/pyproject.toml b/pyproject.toml index 4441f5b..f3f5f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ addopts = [ ] markers = [ "benchmark: marks tests as performance benchmarks (may be slow)", + "e2e: marks tests as end-to-end tests (require network/display)", ] filterwarnings = [ "ignore::DeprecationWarning", diff --git a/tests/test_display.py b/tests/test_display.py index c464439..46632aa 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -155,8 +155,8 @@ class TestMultiDisplay: assert multi.width == 120 assert multi.height == 40 - mock_display1.init.assert_called_once_with(120, 40) - mock_display2.init.assert_called_once_with(120, 40) + mock_display1.init.assert_called_once_with(120, 40, reuse=False) + mock_display2.init.assert_called_once_with(120, 40, reuse=False) def test_show_forwards_to_all_displays(self): """show forwards buffer to all displays.""" @@ -199,3 +199,12 @@ class TestMultiDisplay: multi.show(["test"]) multi.clear() multi.cleanup() + + def test_init_with_reuse(self): + """init passes reuse flag to child displays.""" + mock_display = MagicMock() + multi = MultiDisplay([mock_display]) + + multi.init(80, 24, reuse=True) + + mock_display.init.assert_called_once_with(80, 24, reuse=True) diff --git a/tests/test_websocket_e2e.py b/tests/test_websocket_e2e.py new file mode 100644 index 0000000..4189b6d --- /dev/null +++ b/tests/test_websocket_e2e.py @@ -0,0 +1,78 @@ +""" +End-to-end tests for WebSocket display using Playwright. +""" + +import time + +import pytest + + +class TestWebSocketE2E: + """End-to-end tests for WebSocket display with browser.""" + + @pytest.mark.e2e + def test_websocket_server_starts(self): + """Test that WebSocket server starts and serves HTTP.""" + import threading + + from engine.display.backends.websocket import WebSocketDisplay + + display = WebSocketDisplay(host="127.0.0.1", port=18765) + + server_thread = threading.Thread(target=display.start_http_server) + server_thread.daemon = True + server_thread.start() + + time.sleep(1) + + try: + import urllib.request + + response = urllib.request.urlopen("http://127.0.0.1:18765", timeout=5) + assert response.status == 200 + content = response.read().decode("utf-8") + assert len(content) > 0 + finally: + display.cleanup() + time.sleep(0.5) + + @pytest.mark.e2e + @pytest.mark.skipif( + not pytest.importorskip("playwright", reason="playwright not installed"), + reason="playwright not installed", + ) + def test_websocket_browser_connection(self): + """Test WebSocket connection with actual browser.""" + import threading + + from playwright.sync_api import sync_playwright + + from engine.display.backends.websocket import WebSocketDisplay + + display = WebSocketDisplay(host="127.0.0.1", port=18767) + + server_thread = threading.Thread(target=display.start_server) + server_thread.daemon = True + server_thread.start() + + http_thread = threading.Thread(target=display.start_http_server) + http_thread.daemon = True + http_thread.start() + + time.sleep(1) + + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + page.goto("http://127.0.0.1:18767") + time.sleep(0.5) + + title = page.title() + assert len(title) >= 0 + + browser.close() + finally: + display.cleanup() + time.sleep(0.5)