feat(display): add configurable multi-backend display system
This commit is contained in:
32
AGENTS.md
32
AGENTS.md
@@ -22,13 +22,23 @@ uv sync
|
|||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run test # Run tests
|
mise run test # Run tests
|
||||||
mise run test-v # Run tests verbose
|
mise run test-v # Run tests verbose
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run lint # Run ruff linter
|
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
mise run lint # Run ruff linter
|
||||||
mise run format # Run ruff formatter
|
mise run lint-fix # Run ruff with auto-fix
|
||||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
mise run format # Run ruff formatter
|
||||||
|
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run run # Run mainline (terminal)
|
||||||
|
mise run run-websocket # Run with WebSocket display
|
||||||
|
mise run run-both # Run with both terminal and WebSocket
|
||||||
|
mise run run-client # Run both + open browser
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
## Git Hooks
|
||||||
@@ -108,3 +118,11 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
- **Display abstraction** (`engine/display.py`): swap display backends via the Display protocol
|
||||||
|
- `TerminalDisplay` - ANSI terminal output
|
||||||
|
- `WebSocketDisplay` - broadcasts to web clients via WebSocket
|
||||||
|
- `MultiDisplay` - forwards to multiple displays simultaneously
|
||||||
|
- **WebSocket display** (`engine/websocket_display.py`): real-time frame broadcasting to web browsers
|
||||||
|
- WebSocket server on port 8765
|
||||||
|
- HTTP server on port 8766 (serves HTML client)
|
||||||
|
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||||
|
|||||||
@@ -33,13 +33,25 @@ from engine.websocket_display import WebSocketDisplay
|
|||||||
|
|
||||||
|
|
||||||
def _get_display():
|
def _get_display():
|
||||||
"""Get the appropriate display based on config."""
|
"""Get the appropriate display(s) based on config."""
|
||||||
if config.WEBSOCKET:
|
from engine.display import MultiDisplay, TerminalDisplay
|
||||||
|
|
||||||
|
displays = []
|
||||||
|
|
||||||
|
if config.DISPLAY in ("terminal", "both"):
|
||||||
|
displays.append(TerminalDisplay())
|
||||||
|
|
||||||
|
if config.DISPLAY in ("websocket", "both") or config.WEBSOCKET:
|
||||||
ws = WebSocketDisplay(host="0.0.0.0", port=config.WEBSOCKET_PORT)
|
ws = WebSocketDisplay(host="0.0.0.0", port=config.WEBSOCKET_PORT)
|
||||||
ws.start_server()
|
ws.start_server()
|
||||||
ws.start_http_server()
|
ws.start_http_server()
|
||||||
return ws
|
displays.append(ws)
|
||||||
return None
|
|
||||||
|
if not displays:
|
||||||
|
return None
|
||||||
|
if len(displays) == 1:
|
||||||
|
return displays[0]
|
||||||
|
return MultiDisplay(displays)
|
||||||
|
|
||||||
|
|
||||||
TITLE = [
|
TITLE = [
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class Config:
|
|||||||
|
|
||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
display: str = "terminal"
|
||||||
websocket: bool = False
|
websocket: bool = False
|
||||||
websocket_port: int = 8765
|
websocket_port: int = 8765
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ class Config:
|
|||||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
display=_arg_value("--display", argv) or "terminal",
|
||||||
websocket="--websocket" in argv,
|
websocket="--websocket" in argv,
|
||||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||||
)
|
)
|
||||||
@@ -229,6 +231,7 @@ GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
|||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||||
|
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
|
||||||
WEBSOCKET = "--websocket" in sys.argv
|
WEBSOCKET = "--websocket" in sys.argv
|
||||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||||
|
|
||||||
|
|||||||
@@ -100,3 +100,30 @@ class NullDisplay:
|
|||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MultiDisplay:
|
||||||
|
"""Display that forwards to multiple displays."""
|
||||||
|
|
||||||
|
def __init__(self, displays: list[Display]):
|
||||||
|
self.displays = displays
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
for d in self.displays:
|
||||||
|
d.init(width, height)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str]) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.show(buffer)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.clear()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.cleanup()
|
||||||
|
|||||||
@@ -193,11 +193,9 @@ class WebSocketDisplay:
|
|||||||
def _run_async(self, coro):
|
def _run_async(self, coro):
|
||||||
"""Run coroutine in background."""
|
"""Run coroutine in background."""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.new_event_loop()
|
asyncio.run(coro)
|
||||||
asyncio.set_event_loop(loop)
|
except Exception as e:
|
||||||
loop.run_until_complete(coro)
|
print(f"WebSocket async error: {e}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def start_server(self):
|
def start_server(self):
|
||||||
"""Start the WebSocket server in a background thread."""
|
"""Start the WebSocket server in a background thread."""
|
||||||
@@ -224,6 +222,8 @@ class WebSocketDisplay:
|
|||||||
if self._http_thread is not None:
|
if self._http_thread is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
|
||||||
self._http_running = True
|
self._http_running = True
|
||||||
self._http_thread = threading.Thread(
|
self._http_thread = threading.Thread(
|
||||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ run = "uv run mainline.py"
|
|||||||
run-poetry = "uv run mainline.py --poetry"
|
run-poetry = "uv run mainline.py --poetry"
|
||||||
run-firehose = "uv run mainline.py --firehose"
|
run-firehose = "uv run mainline.py --firehose"
|
||||||
run-websocket = { run = "uv run mainline.py --websocket", depends = ["sync-all"] }
|
run-websocket = { run = "uv run mainline.py --websocket", depends = ["sync-all"] }
|
||||||
run-client = { run = "uv run mainline.py --websocket & WEBSOCKET_PID=$! && sleep 2 && case $(uname -s) in Darwin) open http://localhost:8766 ;; Linux) xdg-open http://localhost:8766 ;; CYGWIN*) cmd /c start http://localhost:8766 ;; *) echo 'Unknown platform' ;; esac && wait $WEBSOCKET_PID", depends = ["sync-all"] }
|
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||||
|
run-client = { run = "uv run mainline.py --display both & WEBSOCKET_PID=$! && sleep 2 && case $(uname -s) in Darwin) open http://localhost:8766 ;; Linux) xdg-open http://localhost:8766 ;; CYGWIN*) cmd /c start http://localhost:8766 ;; *) echo 'Unknown platform' ;; esac && wait $WEBSOCKET_PID", depends = ["sync-all"] }
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
Reference in New Issue
Block a user