forked from genewildish/Mainline
BUG FIXES:
1. Border parameter not being passed to display.show()
- Display backends support border parameter but app.py wasn't passing it
- Now app.py passes params.border to display.show(border=params.border)
- Enables border-test preset to actually render borders
2. WebSocket and Multi displays didn't support border parameter
- Updated WebSocket Protocol to include border parameter
- Updated MultiDisplay.show() to accept and forward border parameter
- Updated test to expect border parameter in mock calls
3. app.py didn't properly handle special sources (empty, pipeline-inspect)
- Border-test preset with source='empty' was still fetching headlines
- Pipeline-inspect source was never using the introspection data source
- Now app.py detects special sources and uses appropriate data source stages:
* 'empty' source → EmptyDataSource stage
* 'pipeline-inspect' → PipelineIntrospectionSource stage
* Other sources → traditional items-based approach
- Uses SourceItemsToBufferStage for special sources instead of RenderStage
- Sets pipeline on introspection source after build to avoid circular dependency
TESTING:
- All 463 tests pass
- Linting passes
- Manual test: `uv run mainline.py --preset border-test` now correctly shows empty source
- border-test preset now properly initializes without fetching unnecessary content
The issue was that the enhanced app.py code from the original diff didn't make it into
the refactor commit. This fix restores that functionality.
301 lines
8.9 KiB
Python
301 lines
8.9 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], border: bool = False) -> 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], border: bool = False) -> None:
|
|
"""Broadcast buffer to all connected clients."""
|
|
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("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 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
|
|
|
|
def get_dimensions(self) -> tuple[int, int]:
|
|
"""Get current dimensions.
|
|
|
|
Returns:
|
|
(width, height) in character cells
|
|
"""
|
|
return (self.width, self.height)
|