- 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
275 lines
8.0 KiB
Python
275 lines
8.0 KiB
Python
"""
|
|
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
import time
|
|
from typing import Protocol
|
|
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
websockets = None
|
|
|
|
|
|
class Display(Protocol):
|
|
"""Protocol for display backends."""
|
|
|
|
width: int
|
|
height: int
|
|
|
|
def init(self, width: int, height: int) -> None:
|
|
"""Initialize display with dimensions."""
|
|
...
|
|
|
|
def show(self, buffer: list[str]) -> None:
|
|
"""Show buffer on display."""
|
|
...
|
|
|
|
def clear(self) -> None:
|
|
"""Clear display."""
|
|
...
|
|
|
|
def cleanup(self) -> None:
|
|
"""Shutdown display."""
|
|
...
|
|
|
|
|
|
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,
|
|
):
|
|
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._frame_delay = 0.0
|
|
|
|
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]) -> None:
|
|
"""Broadcast buffer to all connected clients."""
|
|
t0 = time.perf_counter()
|
|
|
|
if self._clients:
|
|
frame_data = {
|
|
"type": "frame",
|
|
"width": self.width,
|
|
"height": self.height,
|
|
"lines": buffer,
|
|
}
|
|
message = json.dumps(frame_data)
|
|
|
|
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)
|
|
|
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
monitor = get_monitor()
|
|
if monitor:
|
|
chars_in = sum(len(line) for line in buffer)
|
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
|
|
|
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)
|
|
if data.get("type") == "resize":
|
|
self.width = data.get("width", 80)
|
|
self.height = data.get("height", 24)
|
|
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."""
|
|
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
|
|
|
|
client_dir = os.path.join(
|
|
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)
|
|
while self._http_running:
|
|
httpd.handle_request()
|
|
|
|
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
|
|
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
|