From 31ecaa53f2ba1339e9e8f265fa5aba9b29b67af1 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Mon, 2 Feb 2026 20:28:56 +0100 Subject: [PATCH 01/27] wip hud - init of hud skill and server --- api/interface.py | 17 + hud_server/__init__.py | 47 + hud_server/http_client.py | 744 +++++ hud_server/hud_manager.py | 733 +++++ hud_server/hud_types.py | 228 ++ hud_server/layout/README.md | 230 ++ hud_server/layout/__init__.py | 9 + hud_server/layout/manager.py | 506 ++++ hud_server/models.py | 314 +++ hud_server/overlay/__init__.py | 1 + hud_server/overlay/overlay.py | 3438 +++++++++++++++++++++++ hud_server/platform/__init__.py | 1 + hud_server/platform/win32.py | 132 + hud_server/rendering/__init__.py | 1 + hud_server/rendering/markdown.py | 2330 +++++++++++++++ hud_server/server.py | 692 +++++ hud_server/tests/README.md | 55 + hud_server/tests/__init__.py | 2 + hud_server/tests/debug_layout.py | 86 + hud_server/tests/run_tests.py | 151 + hud_server/tests/test_chat.py | 402 +++ hud_server/tests/test_layout.py | 335 +++ hud_server/tests/test_layout_visual.py | 714 +++++ hud_server/tests/test_messages.py | 204 ++ hud_server/tests/test_multiuser.py | 910 ++++++ hud_server/tests/test_persistent.py | 73 + hud_server/tests/test_progress.py | 56 + hud_server/tests/test_runner.py | 95 + hud_server/tests/test_session.py | 343 +++ hud_server/tests/test_unicode_stress.py | 557 ++++ skills/hud/default_config.yaml | 161 ++ skills/hud/logo.png | Bin 0 -> 5812 bytes skills/hud/main.py | 1334 +++++++++ templates/configs/settings.yaml | 7 +- wingman_core.py | 45 + 35 files changed, 14952 insertions(+), 1 deletion(-) create mode 100644 hud_server/__init__.py create mode 100644 hud_server/http_client.py create mode 100644 hud_server/hud_manager.py create mode 100644 hud_server/hud_types.py create mode 100644 hud_server/layout/README.md create mode 100644 hud_server/layout/__init__.py create mode 100644 hud_server/layout/manager.py create mode 100644 hud_server/models.py create mode 100644 hud_server/overlay/__init__.py create mode 100644 hud_server/overlay/overlay.py create mode 100644 hud_server/platform/__init__.py create mode 100644 hud_server/platform/win32.py create mode 100644 hud_server/rendering/__init__.py create mode 100644 hud_server/rendering/markdown.py create mode 100644 hud_server/server.py create mode 100644 hud_server/tests/README.md create mode 100644 hud_server/tests/__init__.py create mode 100644 hud_server/tests/debug_layout.py create mode 100644 hud_server/tests/run_tests.py create mode 100644 hud_server/tests/test_chat.py create mode 100644 hud_server/tests/test_layout.py create mode 100644 hud_server/tests/test_layout_visual.py create mode 100644 hud_server/tests/test_messages.py create mode 100644 hud_server/tests/test_multiuser.py create mode 100644 hud_server/tests/test_persistent.py create mode 100644 hud_server/tests/test_progress.py create mode 100644 hud_server/tests/test_runner.py create mode 100644 hud_server/tests/test_session.py create mode 100644 hud_server/tests/test_unicode_stress.py create mode 100644 skills/hud/default_config.yaml create mode 100644 skills/hud/logo.png create mode 100644 skills/hud/main.py diff --git a/api/interface.py b/api/interface.py index 92b7a09cd..a4759b5ce 100644 --- a/api/interface.py +++ b/api/interface.py @@ -1056,12 +1056,29 @@ class DuplicateWingmanResult(BaseModel): wingman_file: WingmanConfigFileInfo +class HudServerSettings(BaseModel): + """HUD Server settings for global configuration.""" + + enabled: bool = True + """Whether the HUD server should auto-start with Wingman AI Core.""" + + host: str = "127.0.0.1" + """The interface to listen on. Use '127.0.0.1' for local only, '0.0.0.0' for LAN access.""" + + port: int = 7862 + """The port to listen on.""" + + framerate: int = Field(default=60, ge=1) + """HUD overlay rendering framerate. Higher = smoother but more CPU. Minimum 1.""" + + class SettingsConfig(BaseModel): audio: Optional[AudioSettings] = None voice_activation: VoiceActivationSettings wingman_pro: WingmanProSettings xvasynth: XVASynthSettings pocket_tts: PocketTTSSettings + hud_server: Optional[HudServerSettings] = None debug_mode: bool streamer_mode: bool cancel_tts_key: Optional[str] = None diff --git a/hud_server/__init__.py b/hud_server/__init__.py new file mode 100644 index 000000000..c33d5b805 --- /dev/null +++ b/hud_server/__init__.py @@ -0,0 +1,47 @@ +""" +HUD Server - Integrated HTTP server for HUD overlay control. + +This server provides a REST API to control HUD overlays from any client. +It runs independently and can be used by multiple applications simultaneously. + +Included modules: +- server.py: FastAPI HTTP server +- hud_manager.py: State management for HUD groups +- http_client.py: HTTP client for skills to use +- overlay/overlay.py: PIL-based overlay renderer (Windows) +- rendering/markdown.py: Markdown rendering +- platform/win32.py: Win32 API definitions +- hud_types.py: Type definitions for HUD elements +""" + +from hud_server.server import HudServer +from hud_server.http_client import HudHttpClient, HudHttpClientSync +from hud_server.models import ( + HudServerSettings, + GroupState, + MessageRequest, + ChatMessageRequest, + ProgressRequest, + TimerRequest, + ItemRequest, + StateRestoreRequest, + HealthResponse, + GroupStateResponse, +) + +__all__ = [ + "HudServer", + "HudHttpClient", + "HudHttpClientSync", + "HudServerSettings", + "GroupState", + "MessageRequest", + "ChatMessageRequest", + "ProgressRequest", + "TimerRequest", + "ItemRequest", + "StateRestoreRequest", + "HealthResponse", + "GroupStateResponse", +] + diff --git a/hud_server/http_client.py b/hud_server/http_client.py new file mode 100644 index 000000000..dfb9bd8d7 --- /dev/null +++ b/hud_server/http_client.py @@ -0,0 +1,744 @@ +# -*- coding: utf-8 -*- +""" +HUD HTTP Client - Client for interacting with the integrated HUD Server. + +Provides both async and sync APIs for controlling HUD groups via HTTP. +This replaces the WebSocket-based client for the integrated HUD server. + +Usage: + # Async usage + async with HudHttpClient() as client: + await client.show_message("group1", "Title", "Content") + + # Sync usage + client = HudHttpClientSync() + client.show_message("group1", "Title", "Content") +""" + +import asyncio +import threading +import time +from typing import Optional, Any +from urllib.parse import quote + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None + + +class HudHttpClient: + """Async HTTP client for the HUD Server.""" + + def __init__(self, base_url: str = "http://127.0.0.1:7862"): + if not HTTPX_AVAILABLE: + raise ImportError("httpx library not installed. Run: pip install httpx") + + self.base_url = base_url.rstrip("/") + self._client: Optional[httpx.AsyncClient] = None + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + async def connect(self, timeout: float = 5.0) -> bool: + """Connect to the HUD server.""" + import sys + try: + # Close existing client if any - ignore all errors since the loop might be closed + if self._client: + try: + await self._client.aclose() + except Exception: + pass + self._client = None + + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=timeout, + headers={ + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json; charset=utf-8" + } + ) + # Test connection + response = await self._client.get("/health") + if response.status_code == 200: + self._connected = True + return True + return False + except Exception as e: + self._connected = False + return False + + async def disconnect(self): + """Disconnect from the HUD server.""" + if self._client: + await self._client.aclose() + self._connected = False + self._client = None + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.disconnect() + + async def _request( + self, + method: str, + path: str, + json: Optional[dict] = None + ) -> Optional[dict]: + """Make an HTTP request to the server.""" + import sys + + # Reconnect if not connected (either no client or marked as disconnected) + if not self._client or not self._connected: + if not await self.connect(): + return None + + try: + if method == "GET": + response = await self._client.get(path) + elif method == "POST": + response = await self._client.post(path, json=json) + elif method == "PUT": + response = await self._client.put(path, json=json) + elif method == "DELETE": + response = await self._client.delete(path) + else: + return None + + if response.status_code >= 200 and response.status_code < 300: + return response.json() + else: + return None + except RuntimeError as e: + # Handle "Event loop is closed" error by reconnecting + if "loop" in str(e).lower() or "closed" in str(e).lower(): + self._connected = False + self._client = None + # Try to reconnect and retry once + if await self.connect(): + try: + if method == "GET": + response = await self._client.get(path) + elif method == "POST": + response = await self._client.post(path, json=json) + elif method == "PUT": + response = await self._client.put(path, json=json) + elif method == "DELETE": + response = await self._client.delete(path) + else: + return None + + if response.status_code >= 200 and response.status_code < 300: + return response.json() + except Exception as retry_e: + sys.stderr.write(f"[HUD HTTP] _request: retry failed: {retry_e}\n") + self._connected = False + return None + except Exception as e: + sys.stderr.write(f"[HUD HTTP] _request: {method} {path} exception: {e}\n") + self._connected = False + return None + + # ─────────────────────────────── Health ─────────────────────────────── # + + async def health_check(self) -> bool: + """Check if server is responsive.""" + result = await self._request("GET", "/health") + return result is not None and result.get("status") == "healthy" + + async def get_status(self) -> Optional[dict]: + """Get server status including all groups.""" + return await self._request("GET", "/health") + + # ─────────────────────────────── Groups ─────────────────────────────── # + + async def create_group( + self, + group_name: str, + props: Optional[dict] = None + ) -> Optional[dict]: + """Create or update a HUD group.""" + return await self._request("POST", "/groups", { + "group_name": group_name, + "props": props + }) + + async def update_group( + self, + group_name: str, + props: dict + ) -> bool: + """ + Update properties of an existing group. + The server will broadcast the updated props to the overlay for real-time updates. + Returns True if successful, False otherwise. + """ + encoded_group = quote(group_name, safe='') + result = await self._request("PATCH", f"/groups/{encoded_group}", { + "props": props + }) + return result is not None + + async def delete_group(self, group_name: str) -> Optional[dict]: + """Delete a HUD group.""" + encoded_group = quote(group_name, safe='') + return await self._request("DELETE", f"/groups/{encoded_group}") + + async def get_groups(self) -> Optional[dict]: + """Get list of all group names.""" + return await self._request("GET", "/groups") + + # ─────────────────────────────── State ─────────────────────────────── # + + async def get_state(self, group_name: str) -> Optional[dict]: + """Get the current state of a group for persistence.""" + encoded_group = quote(group_name, safe='') + return await self._request("GET", f"/state/{encoded_group}") + + async def restore_state(self, group_name: str, state: dict) -> Optional[dict]: + """Restore a group's state from a previous snapshot.""" + return await self._request("POST", "/state/restore", { + "group_name": group_name, + "state": state + }) + + # ─────────────────────────────── Messages ─────────────────────────────── # + + async def show_message( + self, + group_name: str, + title: str, + content: str, + color: Optional[str] = None, + tools: Optional[list] = None, + props: Optional[dict] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Show a message in a HUD group.""" + data: dict[str, Any] = { + "group_name": group_name, + "title": title, + "content": content + } + if color: + data["color"] = color + if tools: + data["tools"] = tools + if props: + data["props"] = props + if duration is not None: + data["duration"] = duration + + return await self._request("POST", "/message", data) + + async def append_message( + self, + group_name: str, + content: str + ) -> Optional[dict]: + """Append content to the current message (for streaming).""" + return await self._request("POST", "/message/append", { + "group_name": group_name, + "content": content + }) + + async def hide_message(self, group_name: str) -> Optional[dict]: + """Hide the current message in a group.""" + encoded_group = quote(group_name, safe='') + return await self._request("POST", f"/message/hide/{encoded_group}") + + # ─────────────────────────────── Loader ─────────────────────────────── # + + async def show_loader( + self, + group_name: str, + show: bool = True, + color: Optional[str] = None + ) -> Optional[dict]: + """Show or hide the loader animation.""" + data = {"group_name": group_name, "show": show} + if color: + data["color"] = color + return await self._request("POST", "/loader", data) + + # ─────────────────────────────── Items ─────────────────────────────── # + + async def add_item( + self, + group_name: str, + title: str, + description: str = "", + color: Optional[str] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Add a persistent item to a group.""" + data: dict[str, Any] = { + "group_name": group_name, + "title": title, + "description": description + } + if color: + data["color"] = color + if duration is not None: + data["duration"] = duration + + return await self._request("POST", "/items", data) + + async def update_item( + self, + group_name: str, + title: str, + description: Optional[str] = None, + color: Optional[str] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Update an existing item.""" + data: dict[str, Any] = {"group_name": group_name, "title": title} + if description is not None: + data["description"] = description + if color is not None: + data["color"] = color + if duration is not None: + data["duration"] = duration + + return await self._request("PUT", "/items", data) + + async def remove_item(self, group_name: str, title: str) -> Optional[dict]: + """Remove an item from a group.""" + encoded_title = quote(title, safe='') + return await self._request("DELETE", f"/items/{group_name}/{encoded_title}") + + async def clear_items(self, group_name: str) -> Optional[dict]: + """Clear all items from a group.""" + encoded_group = quote(group_name, safe='') + return await self._request("DELETE", f"/items/{encoded_group}") + + # ─────────────────────────────── Progress ─────────────────────────────── # + + async def show_progress( + self, + group_name: str, + title: str, + current: float, + maximum: float = 100, + description: str = "", + color: Optional[str] = None, + auto_close: bool = False, + props: Optional[dict] = None + ) -> Optional[dict]: + """Show or update a progress bar.""" + data: dict[str, Any] = { + "group_name": group_name, + "title": title, + "current": current, + "maximum": maximum, + "description": description, + "auto_close": auto_close + } + if color: + data["color"] = color + if props: + data["props"] = props + + return await self._request("POST", "/progress", data) + + async def show_timer( + self, + group_name: str, + title: str, + duration: float, + description: str = "", + color: Optional[str] = None, + auto_close: bool = True, + initial_progress: float = 0, + props: Optional[dict] = None + ) -> Optional[dict]: + """Show a timer-based progress bar.""" + data: dict[str, Any] = { + "group_name": group_name, + "title": title, + "duration": duration, + "description": description, + "auto_close": auto_close, + "initial_progress": initial_progress + } + if color: + data["color"] = color + if props: + data["props"] = props + + return await self._request("POST", "/timer", data) + + # ─────────────────────────────── Chat Window ─────────────────────────────── # + + async def create_chat_window( + self, + name: str, + # Layout (anchor-based) - preferred + anchor: str = "top_left", + priority: int = 5, + layout_mode: str = "auto", + # Legacy position - only used if layout_mode='manual' + x: int = 20, + y: int = 20, + # Size + width: int = 400, + max_height: int = 400, + # Behavior + auto_hide: bool = False, + auto_hide_delay: float = 10.0, + max_messages: int = 50, + sender_colors: Optional[dict[str, str]] = None, + fade_old_messages: bool = True, + **props + ) -> Optional[dict]: + """Create a new chat window.""" + data = { + "name": name, + # Layout + "anchor": anchor, + "priority": priority, + "layout_mode": layout_mode, + # Legacy (for manual mode) + "x": x, + "y": y, + # Size + "width": width, + "max_height": max_height, + # Behavior + "auto_hide": auto_hide, + "auto_hide_delay": auto_hide_delay, + "max_messages": max_messages, + "sender_colors": sender_colors, + "fade_old_messages": fade_old_messages, + "props": props if props else None + } + return await self._request("POST", "/chat/window", data) + + async def delete_chat_window(self, name: str) -> Optional[dict]: + """Delete a chat window.""" + encoded_name = quote(name, safe='') + return await self._request("DELETE", f"/chat/window/{encoded_name}") + + async def send_chat_message( + self, + window_name: str, + sender: str, + text: str, + color: Optional[str] = None + ) -> Optional[dict]: + """Send a message to a chat window.""" + data = { + "window_name": window_name, + "sender": sender, + "text": text + } + if color: + data["color"] = color + + return await self._request("POST", "/chat/message", data) + + async def clear_chat_window(self, name: str) -> Optional[dict]: + """Clear all messages from a chat window.""" + encoded_name = quote(name, safe='') + return await self._request("DELETE", f"/chat/messages/{encoded_name}") + + async def show_chat_window(self, name: str) -> Optional[dict]: + """Show a hidden chat window.""" + encoded_name = quote(name, safe='') + return await self._request("POST", f"/chat/show/{encoded_name}") + + async def hide_chat_window(self, name: str) -> Optional[dict]: + """Hide a chat window.""" + encoded_name = quote(name, safe='') + return await self._request("POST", f"/chat/hide/{encoded_name}") + + # ─────────────────────────────── Legacy ─────────────────────────────── # + + async def legacy_draw( + self, + title: str, + message: str, + color: Optional[str] = None, + tools: Optional[list] = None, + props: Optional[dict] = None, + group: str = "default", + duration: Optional[float] = None + ) -> Optional[dict]: + """Legacy draw command for backwards compatibility.""" + data = { + "group": group, + "title": title, + "message": message, + "color": color, + "tools": tools, + "props": props, + "duration": duration + } + return await self._request("POST", "/legacy/draw", data) + + async def legacy_hide(self, group: str = "default") -> Optional[dict]: + """Legacy hide command for backwards compatibility.""" + return await self._request("POST", "/legacy/hide", {"group": group}) + + async def legacy_loading( + self, + state: bool, + color: Optional[str] = None, + group: str = "default" + ) -> Optional[dict]: + """Legacy loading command for backwards compatibility.""" + return await self._request("POST", "/legacy/loading", { + "group": group, + "state": state, + "color": color + }) + + +class HudHttpClientSync: + """ + Synchronous wrapper for HudHttpClient. + + Useful for non-async code that needs to interact with the HUD server. + """ + + def __init__(self, base_url: str = "http://127.0.0.1:7862"): + self._base_url = base_url + self._client: Optional[HudHttpClient] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + + def _ensure_loop(self): + """Ensure event loop is running in background thread.""" + if self._loop is None or not self._loop.is_running(): + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + time.sleep(0.1) + + def _run_loop(self): + """Run event loop in background thread.""" + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + def _run_coro(self, coro): + """Run a coroutine in the background event loop.""" + self._ensure_loop() + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=10.0) + + @property + def connected(self) -> bool: + return self._client is not None and self._client.connected + + def connect(self, timeout: float = 5.0) -> bool: + with self._lock: + self._ensure_loop() + self._client = HudHttpClient(self._base_url) + return self._run_coro(self._client.connect(timeout)) + + def disconnect(self): + with self._lock: + if self._client: + self._run_coro(self._client.disconnect()) + self._client = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + + # Forward all methods to the async client + def health_check(self) -> bool: + return self._run_coro(self._client.health_check()) if self._client else False + + def get_status(self) -> Optional[dict]: + return self._run_coro(self._client.get_status()) if self._client else None + + def create_group(self, group_name: str, props: Optional[dict] = None): + return self._run_coro(self._client.create_group(group_name, props)) if self._client else None + + def update_group(self, group_name: str, props: dict) -> bool: + """Update properties for an existing group for real-time updates.""" + return self._run_coro(self._client.update_group(group_name, props)) if self._client else False + + def delete_group(self, group_name: str): + return self._run_coro(self._client.delete_group(group_name)) if self._client else None + + def get_groups(self): + return self._run_coro(self._client.get_groups()) if self._client else None + + def get_state(self, group_name: str): + return self._run_coro(self._client.get_state(group_name)) if self._client else None + + def restore_state(self, group_name: str, state: dict): + return self._run_coro(self._client.restore_state(group_name, state)) if self._client else None + + def show_message( + self, + group_name: str, + title: str, + content: str, + color: Optional[str] = None, + tools: Optional[list] = None, + props: Optional[dict] = None, + duration: Optional[float] = None + ): + return self._run_coro(self._client.show_message( + group_name, title, content, color, tools, props, duration + )) if self._client else None + + def append_message(self, group_name: str, content: str): + return self._run_coro(self._client.append_message(group_name, content)) if self._client else None + + def hide_message(self, group_name: str): + return self._run_coro(self._client.hide_message(group_name)) if self._client else None + + def show_loader(self, group_name: str, show: bool = True, color: Optional[str] = None): + return self._run_coro(self._client.show_loader(group_name, show, color)) if self._client else None + + def add_item( + self, + group_name: str, + title: str, + description: str = "", + color: Optional[str] = None, + duration: Optional[float] = None + ): + return self._run_coro(self._client.add_item( + group_name, title, description, color, duration + )) if self._client else None + + def update_item( + self, + group_name: str, + title: str, + description: Optional[str] = None, + color: Optional[str] = None, + duration: Optional[float] = None + ): + return self._run_coro(self._client.update_item( + group_name, title, description, color, duration + )) if self._client else None + + def remove_item(self, group_name: str, title: str): + return self._run_coro(self._client.remove_item(group_name, title)) if self._client else None + + def clear_items(self, group_name: str): + return self._run_coro(self._client.clear_items(group_name)) if self._client else None + + def show_progress( + self, + group_name: str, + title: str, + current: float, + maximum: float = 100, + description: str = "", + color: Optional[str] = None, + auto_close: bool = False, + props: Optional[dict] = None + ): + return self._run_coro(self._client.show_progress( + group_name, title, current, maximum, description, color, auto_close, props + )) if self._client else None + + def show_timer( + self, + group_name: str, + title: str, + duration: float, + description: str = "", + color: Optional[str] = None, + auto_close: bool = True, + initial_progress: float = 0, + props: Optional[dict] = None + ): + return self._run_coro(self._client.show_timer( + group_name, title, duration, description, color, auto_close, initial_progress, props + )) if self._client else None + + def create_chat_window( + self, + name: str, + # Layout (anchor-based) - preferred + anchor: str = "top_left", + priority: int = 5, + layout_mode: str = "auto", + # Legacy position - only used if layout_mode='manual' + x: int = 20, + y: int = 20, + # Size + width: int = 400, + max_height: int = 400, + # Behavior + auto_hide: bool = False, + auto_hide_delay: float = 10.0, + max_messages: int = 50, + sender_colors: Optional[dict[str, str]] = None, + fade_old_messages: bool = True, + **props + ): + return self._run_coro(self._client.create_chat_window( + name=name, + anchor=anchor, + priority=priority, + layout_mode=layout_mode, + x=x, y=y, + width=width, max_height=max_height, + auto_hide=auto_hide, auto_hide_delay=auto_hide_delay, + max_messages=max_messages, + sender_colors=sender_colors, + fade_old_messages=fade_old_messages, + **props + )) if self._client else None + + def delete_chat_window(self, name: str): + return self._run_coro(self._client.delete_chat_window(name)) if self._client else None + + def send_chat_message(self, window_name: str, sender: str, text: str, color: Optional[str] = None): + return self._run_coro(self._client.send_chat_message( + window_name, sender, text, color + )) if self._client else None + + def clear_chat_window(self, name: str): + return self._run_coro(self._client.clear_chat_window(name)) if self._client else None + + def show_chat_window(self, name: str): + return self._run_coro(self._client.show_chat_window(name)) if self._client else None + + def hide_chat_window(self, name: str): + return self._run_coro(self._client.hide_chat_window(name)) if self._client else None + + # Legacy methods + def legacy_draw( + self, + title: str, + message: str, + color: Optional[str] = None, + tools: Optional[list] = None, + props: Optional[dict] = None, + group: str = "default", + duration: Optional[float] = None + ): + return self._run_coro(self._client.legacy_draw( + title, message, color, tools, props, group, duration + )) if self._client else None + + def legacy_hide(self, group: str = "default"): + return self._run_coro(self._client.legacy_hide(group)) if self._client else None + + def legacy_loading(self, state: bool, color: Optional[str] = None, group: str = "default"): + return self._run_coro(self._client.legacy_loading(state, color, group)) if self._client else None + diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py new file mode 100644 index 000000000..cc3e82940 --- /dev/null +++ b/hud_server/hud_manager.py @@ -0,0 +1,733 @@ +""" +HUD Manager - State management for HUD groups. + +Manages the state of all HUD groups, supporting: +- Multiple independent groups +- State persistence for client-side restore +- Thread-safe operations +""" + +import threading +import time +from typing import Any, Optional +from dataclasses import dataclass, field + +from api.enums import LogType +from services.printr import Printr + +printr = Printr() + + +@dataclass +class HudMessage: + """A message displayed in a HUD group.""" + title: str + content: str + color: Optional[str] = None + tools: list[dict[str, Any]] = field(default_factory=list) + props: Optional[dict[str, Any]] = None + timestamp: float = field(default_factory=time.time) + duration: Optional[float] = None + + +@dataclass +class HudItem: + """A persistent item in a HUD group.""" + title: str + description: str = "" + color: Optional[str] = None + duration: Optional[float] = None + added_at: float = field(default_factory=time.time) + + # Progress bar support + is_progress: bool = False + progress_current: float = 0 + progress_maximum: float = 100 + progress_color: Optional[str] = None + + # Timer support + is_timer: bool = False + timer_duration: float = 0 + timer_start: float = 0 + auto_close: bool = True + + +@dataclass +class ChatMessage: + """A chat message.""" + sender: str + text: str + color: Optional[str] = None + timestamp: float = field(default_factory=time.time) + + +@dataclass +class GroupState: + """State of a HUD group.""" + props: dict[str, Any] = field(default_factory=dict) + current_message: Optional[HudMessage] = None + items: dict[str, HudItem] = field(default_factory=dict) + chat_messages: list[ChatMessage] = field(default_factory=list) + loader_visible: bool = False + loader_color: Optional[str] = None + is_chat_window: bool = False + visible: bool = True + + def to_dict(self) -> dict[str, Any]: + """Convert state to dictionary for persistence.""" + return { + "props": self.props, + "current_message": { + "title": self.current_message.title, + "content": self.current_message.content, + "color": self.current_message.color, + "tools": self.current_message.tools, + "props": self.current_message.props, + "timestamp": self.current_message.timestamp, + "duration": self.current_message.duration, + } if self.current_message else None, + "items": { + title: { + "title": item.title, + "description": item.description, + "color": item.color, + "duration": item.duration, + "added_at": item.added_at, + "is_progress": item.is_progress, + "progress_current": item.progress_current, + "progress_maximum": item.progress_maximum, + "progress_color": item.progress_color, + "is_timer": item.is_timer, + "timer_duration": item.timer_duration, + "timer_start": item.timer_start, + "auto_close": item.auto_close, + } + for title, item in self.items.items() + }, + "chat_messages": [ + { + "sender": msg.sender, + "text": msg.text, + "color": msg.color, + "timestamp": msg.timestamp, + } + for msg in self.chat_messages + ], + "loader_visible": self.loader_visible, + "loader_color": self.loader_color, + "is_chat_window": self.is_chat_window, + "visible": self.visible, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "GroupState": + """Create state from dictionary.""" + state = cls() + state.props = data.get("props", {}) + state.loader_visible = data.get("loader_visible", False) + state.loader_color = data.get("loader_color") + state.is_chat_window = data.get("is_chat_window", False) + state.visible = data.get("visible", True) + + # Restore current message + msg_data = data.get("current_message") + if msg_data: + state.current_message = HudMessage( + title=msg_data.get("title", ""), + content=msg_data.get("content", ""), + color=msg_data.get("color"), + tools=msg_data.get("tools", []), + props=msg_data.get("props"), + timestamp=msg_data.get("timestamp", time.time()), + duration=msg_data.get("duration"), + ) + + # Restore items + for title, item_data in data.get("items", {}).items(): + state.items[title] = HudItem( + title=item_data.get("title", title), + description=item_data.get("description", ""), + color=item_data.get("color"), + duration=item_data.get("duration"), + added_at=item_data.get("added_at", time.time()), + is_progress=item_data.get("is_progress", False), + progress_current=item_data.get("progress_current", 0), + progress_maximum=item_data.get("progress_maximum", 100), + progress_color=item_data.get("progress_color"), + is_timer=item_data.get("is_timer", False), + timer_duration=item_data.get("timer_duration", 0), + timer_start=item_data.get("timer_start", 0), + auto_close=item_data.get("auto_close", True), + ) + + # Restore chat messages + for msg_data in data.get("chat_messages", []): + state.chat_messages.append(ChatMessage( + sender=msg_data.get("sender", ""), + text=msg_data.get("text", ""), + color=msg_data.get("color"), + timestamp=msg_data.get("timestamp", time.time()), + )) + + return state + + +class HudManager: + """ + Manages all HUD groups and their state. + + Thread-safe for concurrent access from multiple clients. + """ + + def __init__(self): + self._groups: dict[str, GroupState] = {} + self._lock = threading.RLock() + self._command_callbacks: list = [] # Callbacks for overlay integration + + def register_command_callback(self, callback): + """Register a callback to receive commands for overlay rendering.""" + with self._lock: + if callback not in self._command_callbacks: + self._command_callbacks.append(callback) + + def unregister_command_callback(self, callback): + """Unregister a command callback.""" + with self._lock: + if callback in self._command_callbacks: + self._command_callbacks.remove(callback) + + def _notify_callbacks(self, command: dict[str, Any]): + """Notify all registered callbacks of a command.""" + cmd_type = command.get('type', 'unknown') + printr.print( + f"[HUD Manager] _notify_callbacks: command type='{cmd_type}', {len(self._command_callbacks)} callback(s)", + color=LogType.INFO, + server_only=True + ) + for i, callback in enumerate(self._command_callbacks): + try: + callback(command) + except Exception as e: + printr.print( + f"[HUD Manager] _notify_callbacks: callback {i} FAILED: {e}", + color=LogType.ERROR, + server_only=True + ) + + # ─────────────────────────────── Group Management ─────────────────────────────── # + + def create_group(self, group_name: str, props: Optional[dict[str, Any]] = None) -> bool: + """Create or update a HUD group.""" + with self._lock: + if group_name not in self._groups: + self._groups[group_name] = GroupState() + + if props: + self._groups[group_name].props.update(props) + self._groups[group_name].is_chat_window = props.get("is_chat_window", False) + + self._notify_callbacks({ + "type": "create_group", + "group": group_name, + "props": props or {} + }) + + return True + + def update_group(self, group_name: str, props: dict[str, Any]) -> bool: + """Update properties of an existing group.""" + printr.print( + f"[HUD Manager] update_group called: group='{group_name}', props keys={list(props.keys())}", + color=LogType.INFO, + server_only=True + ) + if 'width' in props: + printr.print( + f"[HUD Manager] update_group: width={props['width']}", + color=LogType.INFO, + server_only=True + ) + with self._lock: + if group_name not in self._groups: + printr.print( + f"[HUD Manager] update_group: group '{group_name}' NOT FOUND in groups: {list(self._groups.keys())}", + color=LogType.WARNING, + server_only=True + ) + return False + + printr.print( + f"[HUD Manager] update_group: group '{group_name}' found, updating props", + color=LogType.INFO, + server_only=True + ) + self._groups[group_name].props.update(props) + + self._notify_callbacks({ + "type": "update_group", + "group": group_name, + "props": props + }) + + return True + + def delete_group(self, group_name: str) -> bool: + """Delete a HUD group.""" + with self._lock: + if group_name in self._groups: + del self._groups[group_name] + + self._notify_callbacks({ + "type": "delete_group", + "group": group_name + }) + + return True + return False + + def get_groups(self) -> list[str]: + """Get list of all group names.""" + with self._lock: + return list(self._groups.keys()) + + def get_group_state(self, group_name: str) -> Optional[dict[str, Any]]: + """Get the current state of a group for persistence.""" + with self._lock: + if group_name in self._groups: + return self._groups[group_name].to_dict() + return None + + def restore_group_state(self, group_name: str, state: dict[str, Any]) -> bool: + """Restore a group's state from a previous snapshot.""" + with self._lock: + self._groups[group_name] = GroupState.from_dict(state) + + # Notify overlay to restore visuals + self._notify_callbacks({ + "type": "restore_state", + "group": group_name, + "state": state + }) + + return True + + # ─────────────────────────────── Messages ─────────────────────────────── # + + def show_message( + self, + group_name: str, + title: str, + content: str, + color: Optional[str] = None, + tools: Optional[list[dict]] = None, + props: Optional[dict[str, Any]] = None, + duration: Optional[float] = None + ) -> bool: + """Show a message in a group.""" + import sys + with self._lock: + if group_name not in self._groups: + self.create_group(group_name) + else: + sys.stderr.write(f"[HUD Manager] show_message: using existing group '{group_name}'\n") + + self._groups[group_name].current_message = HudMessage( + title=title, + content=content, + color=color, + tools=tools or [], + props=props, + duration=duration + ) + + # Build props dict for overlay + overlay_props = dict(self._groups[group_name].props) + if props: + overlay_props.update(props) + if duration is not None: + overlay_props["duration"] = duration + + self._notify_callbacks({ + "type": "show_message", + "group": group_name, + "title": title, + "content": content, + "color": color, + "tools": tools, + "props": overlay_props, + }) + + return True + + def append_message(self, group_name: str, content: str) -> bool: + """Append content to the current message (for streaming).""" + with self._lock: + if group_name not in self._groups: + return False + + state = self._groups[group_name] + if state.current_message: + state.current_message.content += content + + # Re-send the full message for streaming + overlay_props = dict(state.props) + if state.current_message.props: + overlay_props.update(state.current_message.props) + if state.current_message.duration is not None: + overlay_props["duration"] = state.current_message.duration + + self._notify_callbacks({ + "type": "show_message", + "group": group_name, + "title": state.current_message.title, + "content": state.current_message.content, + "color": state.current_message.color, + "tools": state.current_message.tools, + "props": overlay_props, + }) + + return True + + def hide_message(self, group_name: str) -> bool: + """Hide/fade out the current message.""" + with self._lock: + if group_name not in self._groups: + return False + + self._groups[group_name].current_message = None + + self._notify_callbacks({ + "type": "hide_message", + "group": group_name + }) + + return True + + # ─────────────────────────────── Loader ─────────────────────────────── # + + def set_loader(self, group_name: str, show: bool, color: Optional[str] = None) -> bool: + """Show or hide the loader animation.""" + with self._lock: + if group_name not in self._groups: + self.create_group(group_name) + + self._groups[group_name].loader_visible = show + if color: + self._groups[group_name].loader_color = color + + self._notify_callbacks({ + "type": "set_loader", + "group": group_name, + "show": show, + "color": color + }) + + return True + + # ─────────────────────────────── Items ─────────────────────────────── # + + def add_item( + self, + group_name: str, + title: str, + description: str = "", + color: Optional[str] = None, + duration: Optional[float] = None + ) -> bool: + """Add a persistent item to a group.""" + with self._lock: + if group_name not in self._groups: + self.create_group(group_name) + + self._groups[group_name].items[title] = HudItem( + title=title, + description=description, + color=color, + duration=duration + ) + + self._notify_callbacks({ + "type": "add_item", + "group": group_name, + "title": title, + "description": description, + "color": color, + "duration": duration + }) + + return True + + def update_item( + self, + group_name: str, + title: str, + description: Optional[str] = None, + color: Optional[str] = None, + duration: Optional[float] = None + ) -> bool: + """Update an existing item.""" + with self._lock: + if group_name not in self._groups: + return False + + if title not in self._groups[group_name].items: + return False + + item = self._groups[group_name].items[title] + if description is not None: + item.description = description + if color is not None: + item.color = color + if duration is not None: + item.duration = duration + + self._notify_callbacks({ + "type": "update_item", + "group": group_name, + "title": title, + "description": description, + "color": color, + "duration": duration + }) + + return True + + def remove_item(self, group_name: str, title: str) -> bool: + """Remove an item from a group.""" + with self._lock: + if group_name not in self._groups: + return False + + if title in self._groups[group_name].items: + del self._groups[group_name].items[title] + + self._notify_callbacks({ + "type": "remove_item", + "group": group_name, + "title": title + }) + + return True + return False + + def clear_items(self, group_name: str) -> bool: + """Clear all items from a group.""" + with self._lock: + if group_name not in self._groups: + return False + + self._groups[group_name].items.clear() + + self._notify_callbacks({ + "type": "clear_items", + "group": group_name + }) + + return True + + # ─────────────────────────────── Progress ─────────────────────────────── # + + def show_progress( + self, + group_name: str, + title: str, + current: float, + maximum: float = 100, + description: str = "", + color: Optional[str] = None, + auto_close: bool = False + ) -> bool: + """Show or update a progress bar.""" + with self._lock: + if group_name not in self._groups: + self.create_group(group_name) + + items = self._groups[group_name].items + if title in items: + item = items[title] + item.progress_current = current + item.progress_maximum = maximum + if description: + item.description = description + if color: + item.progress_color = color + else: + items[title] = HudItem( + title=title, + description=description, + is_progress=True, + progress_current=current, + progress_maximum=maximum, + progress_color=color, + auto_close=auto_close + ) + + self._notify_callbacks({ + "type": "show_progress", + "group": group_name, + "title": title, + "current": current, + "maximum": maximum, + "description": description, + "color": color, + "auto_close": auto_close + }) + + return True + + def show_timer( + self, + group_name: str, + title: str, + duration: float, + description: str = "", + color: Optional[str] = None, + auto_close: bool = True, + initial_progress: float = 0 + ) -> bool: + """Show a timer-based progress bar.""" + with self._lock: + if group_name not in self._groups: + self.create_group(group_name) + + now = time.time() + self._groups[group_name].items[title] = HudItem( + title=title, + description=description, + is_progress=True, + is_timer=True, + timer_duration=duration, + timer_start=now - initial_progress, # Adjust for initial progress + progress_current=initial_progress, + progress_maximum=duration, + progress_color=color, + auto_close=auto_close + ) + + self._notify_callbacks({ + "type": "show_timer", + "group": group_name, + "title": title, + "duration": duration, + "description": description, + "color": color, + "auto_close": auto_close, + "initial_progress": initial_progress + }) + + return True + + # ─────────────────────────────── Chat Window ─────────────────────────────── # + + def create_chat_window( + self, + name: str, + props: Optional[dict[str, Any]] = None + ) -> bool: + """Create a chat window group.""" + with self._lock: + final_props = props or {} + final_props["is_chat_window"] = True + + if name not in self._groups: + self._groups[name] = GroupState() + self._groups[name].props.update(final_props) + self._groups[name].is_chat_window = True + + # Use 'create_chat_window' command for overlay compatibility + self._notify_callbacks({ + "type": "create_chat_window", + "name": name, + "props": final_props + }) + + return True + + def send_chat_message( + self, + window_name: str, + sender: str, + text: str, + color: Optional[str] = None + ) -> bool: + """Send a message to a chat window.""" + with self._lock: + if window_name not in self._groups: + return False + + state = self._groups[window_name] + state.chat_messages.append(ChatMessage( + sender=sender, + text=text, + color=color + )) + + # Limit chat history + max_messages = state.props.get("max_messages", 50) + if len(state.chat_messages) > max_messages: + state.chat_messages = state.chat_messages[-max_messages:] + + self._notify_callbacks({ + "type": "chat_message", + "name": window_name, + "sender": sender, + "text": text, + "color": color + }) + + return True + + def clear_chat_window(self, name: str) -> bool: + """Clear all messages from a chat window.""" + with self._lock: + if name not in self._groups: + return False + + self._groups[name].chat_messages.clear() + + self._notify_callbacks({ + "type": "clear_chat_window", + "name": name + }) + + return True + + def show_chat_window(self, name: str) -> bool: + """Show a hidden chat window.""" + with self._lock: + if name not in self._groups: + return False + + self._groups[name].visible = True + + self._notify_callbacks({ + "type": "show_chat_window", + "name": name + }) + + return True + + def hide_chat_window(self, name: str) -> bool: + """Hide a chat window.""" + with self._lock: + if name not in self._groups: + return False + + self._groups[name].visible = False + + self._notify_callbacks({ + "type": "hide_chat_window", + "name": name + }) + + return True + + def clear_all(self): + """Clear all groups (fresh start).""" + with self._lock: + self._groups.clear() + + self._notify_callbacks({ + "type": "clear_all" + }) + diff --git a/hud_server/hud_types.py b/hud_server/hud_types.py new file mode 100644 index 000000000..e74068f5c --- /dev/null +++ b/hud_server/hud_types.py @@ -0,0 +1,228 @@ +""" +HeadsUp HUD - Generalized HUD overlay system with named groups. + +Each HUD group has its own: +- Position (x, y) +- Size (width, max_height) +- Visual properties (colors, opacity, fonts) +- Behavior (typewriter effect, loader, auto-fade) + +Props can be: +- Set when creating a group +- Updated at any time via update_group() +- Overridden per-message/item + +This allows multiple independent HUD areas on screen with full flexibility. +""" + +from dataclasses import dataclass, field +from typing import Dict, Any, Optional, List + + +@dataclass +class HUDGroupProps: + """ + Properties for a HUD group. + + All properties are optional when updating - only provided values will be changed. + When creating a new group, defaults will be used for any unspecified properties. + """ + # Position & Size + x: int = 20 + y: int = 20 + width: int = 400 + max_height: int = 600 + + # Colors + bg_color: str = "#1e212b" + text_color: str = "#f0f0f0" + accent_color: str = "#00aaff" + title_color: Optional[str] = None # If None, uses accent_color + + # Visual + opacity: float = 0.85 + border_radius: int = 12 + font_size: int = 16 + font_family: str = "Segoe UI" + content_padding: int = 16 + + # Behavior + typewriter_effect: bool = True + typewriter_speed: int = 200 # chars per second + show_loader: bool = True + auto_fade: bool = True + fade_delay: float = 8.0 # seconds before fade starts + fade_duration: float = 0.5 # fade animation duration + + # Rendering + z_order: int = 0 # Higher = rendered on top + + # Layout Management + layout_mode: str = "auto" # 'auto', 'manual', or 'hybrid' + anchor: str = "top_left" # 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center' + priority: int = 10 # Stacking priority (higher = closer to anchor) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary, resolving defaults.""" + return { + 'x': self.x, + 'y': self.y, + 'width': self.width, + 'max_height': self.max_height, + 'bg_color': self.bg_color, + 'text_color': self.text_color, + 'accent_color': self.accent_color, + 'title_color': self.title_color or self.accent_color, + 'opacity': self.opacity, + 'border_radius': self.border_radius, + 'font_size': self.font_size, + 'font_family': self.font_family, + 'content_padding': self.content_padding, + 'typewriter_effect': self.typewriter_effect, + 'typewriter_speed': self.typewriter_speed, + 'show_loader': self.show_loader, + 'auto_fade': self.auto_fade, + 'fade_delay': self.fade_delay, + 'fade_duration': self.fade_duration, + 'z_order': self.z_order, + 'layout_mode': self.layout_mode, + 'anchor': self.anchor, + 'priority': self.priority, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "HUDGroupProps": + """Create from dictionary, ignoring unknown keys.""" + import dataclasses + valid_fields = {f.name for f in dataclasses.fields(cls)} + return cls(**{k: v for k, v in data.items() if k in valid_fields}) + + def merge_with(self, overrides: Dict[str, Any]) -> "HUDGroupProps": + """Create a new HUDGroupProps with overrides applied.""" + base = self.to_dict() + base.update({k: v for k, v in overrides.items() if v is not None}) + return HUDGroupProps.from_dict(base) + + +@dataclass +class HUDMessage: + """A message to display in a HUD group.""" + title: str = "" + content: str = "" + color: Optional[str] = None # Override title/accent color for this message + tools: List[Dict[str, Any]] = field(default_factory=list) + id: Optional[str] = None # For tracking/updating specific messages + + # Per-message prop overrides (optional) + props: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + result = { + 'title': self.title, + 'content': self.content, + 'color': self.color, + 'tools': self.tools, + 'id': self.id, + } + if self.props: + result['props'] = self.props + return result + + +@dataclass +class HUDItem: + """A persistent item in a HUD group.""" + title: str + description: str = "" + color: Optional[str] = None + duration: Optional[float] = None # Auto-remove after duration (seconds) + + # Progress bar support + is_progress: bool = False + progress_current: float = 0 + progress_maximum: float = 100 + progress_color: Optional[str] = None + + # Timer support + is_timer: bool = False + timer_duration: float = 0 + auto_close: bool = True + + # Per-item prop overrides (optional) + props: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + result = { + 'title': self.title, + 'description': self.description, + 'color': self.color, + 'duration': self.duration, + 'is_progress': self.is_progress, + 'progress_current': self.progress_current, + 'progress_maximum': self.progress_maximum, + 'progress_color': self.progress_color, + 'is_timer': self.is_timer, + 'timer_duration': self.timer_duration, + 'auto_close': self.auto_close, + } + if self.props: + result['props'] = self.props + return result + + +@dataclass +class ChatMessage: + """A single chat message for the chat window.""" + sender: str + text: str + color: Optional[str] = None # Override sender color + timestamp: Optional[float] = None # When the message was added + + def to_dict(self) -> Dict[str, Any]: + return { + 'sender': self.sender, + 'text': self.text, + 'color': self.color, + 'timestamp': self.timestamp, + } + + +@dataclass +class ChatWindowProps(HUDGroupProps): + """ + Properties for a Chat Window HUD group. + + Extends HUDGroupProps with chat-specific settings. + """ + # Chat-specific settings + auto_hide: bool = False # Hide window after auto_hide_delay seconds + auto_hide_delay: float = 10.0 # Seconds after last message before hiding + max_messages: int = 50 # Maximum messages to keep in history + sender_colors: Optional[Dict[str, str]] = None # Map sender names to colors + show_timestamps: bool = False # Show timestamp next to messages + message_spacing: int = 8 # Vertical spacing between messages + fade_old_messages: bool = True # Fade out messages that overflow at top + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary, including chat-specific props.""" + base = super().to_dict() + base.update({ + 'auto_hide': self.auto_hide, + 'auto_hide_delay': self.auto_hide_delay, + 'max_messages': self.max_messages, + 'sender_colors': self.sender_colors or {}, + 'show_timestamps': self.show_timestamps, + 'message_spacing': self.message_spacing, + 'fade_old_messages': self.fade_old_messages, + 'is_chat_window': True, # Flag to identify chat windows + }) + return base + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChatWindowProps": + """Create from dictionary, ignoring unknown keys.""" + import dataclasses + valid_fields = {f.name for f in dataclasses.fields(cls)} + return cls(**{k: v for k, v in data.items() if k in valid_fields}) + + diff --git a/hud_server/layout/README.md b/hud_server/layout/README.md new file mode 100644 index 000000000..6831ff202 --- /dev/null +++ b/hud_server/layout/README.md @@ -0,0 +1,230 @@ +# HUD Layout Manager + +The Layout Manager provides automatic positioning and stacking for HUD elements to prevent overlapping windows. + +## Overview + +When multiple HUD groups are active (e.g., messages from different wingmen, persistent info panels, chat windows), they can overlap if positioned at similar coordinates. The Layout Manager solves this by: + +1. **Anchor-based positioning**: Windows anchor to screen corners (top-left, top-right, bottom-left, bottom-right) +2. **Automatic stacking**: Windows at the same anchor stack vertically with configurable spacing +3. **Priority ordering**: Higher priority windows are positioned closer to the anchor point +4. **Dynamic reflow**: When window heights change, other windows reposition automatically +5. **Visibility awareness**: Hidden windows don't take up space in the layout + +## Configuration + +### Layout Properties + +These properties can be set when creating or updating a HUD group: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `layout_mode` | string | `"auto"` | `"auto"`, `"manual"`, or `"hybrid"` | +| `anchor` | string | `"top_left"` | `"top_left"`, `"top_right"`, `"bottom_left"`, `"bottom_right"`, `"center"` | +| `priority` | int | `10` | Stacking priority (higher = closer to anchor) | +| `margin` | int | `20` | Margin from screen edge (pixels) | +| `spacing` | int | `10` | Space between stacked windows (pixels) | + +### Layout Modes + +- **`auto`** (default): Windows are automatically positioned and stacked based on anchor and priority +- **`manual`**: Windows use the `x` and `y` properties directly (no auto-stacking) +- **`hybrid`**: Not yet implemented; reserved for future use with offset adjustments + +### Anchor Points + +``` + ┌───────────────────────────────────────────────────────────┐ + │ │ + │ TOP_LEFT TOP_CENTER TOP_RIGHT │ + │ ↓ ↓ ↓ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↓ ↓ ↓ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ │ + │ LEFT_CENTER RIGHT_CENTER │ + │ (vertically (vertically │ + │ centered) ┌─────┐ centered) │ + │ ┌─────┐ │ C │ ┌─────┐ │ + │ │ │ └─────┘ │ │ │ + │ └─────┘ └─────┘ │ + │ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ + │ └─────┘ └─────┘ │ + │ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↑ ↑ ↑ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↑ ↑ ↑ │ + │ BOTTOM_LEFT BOTTOM_CENTER BOTTOM_RIGHT │ + │ │ + └───────────────────────────────────────────────────────────┘ +``` + +**9 Anchor Points:** + +| Anchor | Position | Stacking Direction | +|--------|----------|-------------------| +| `top_left` | Top-left corner | Downward | +| `top_center` | Top edge, centered | Downward | +| `top_right` | Top-right corner | Downward | +| `left_center` | Left edge, vertically centered | Downward (centered) | +| `center` | Screen center | No stacking | +| `right_center` | Right edge, vertically centered | Downward (centered) | +| `bottom_left` | Bottom-left corner | Upward | +| `bottom_center` | Bottom edge, centered | Upward | +| `bottom_right` | Bottom-right corner | Upward | + +## Usage Examples + +### API Example: Create groups with auto-layout + +```python +import httpx + +# Create a high-priority wingman group (messages appear at top) +httpx.post("http://127.0.0.1:7862/group", json={ + "group_name": "ATC", + "props": { + "anchor": "top_left", + "priority": 20, + "margin": 20, + "spacing": 10, + "layout_mode": "auto" + } +}) + +# Create a lower-priority group (stacks below ATC) +httpx.post("http://127.0.0.1:7862/group", json={ + "group_name": "Navigation", + "props": { + "anchor": "top_left", + "priority": 10, + "margin": 20, + "spacing": 10, + "layout_mode": "auto" + } +}) + +# Create a group on the right side of the screen +httpx.post("http://127.0.0.1:7862/group", json={ + "group_name": "System", + "props": { + "anchor": "top_right", + "priority": 15, + "width": 350 + } +}) +``` + +### Skill Configuration Example + +In a Wingman's config, you can set layout properties: + +```yaml +wingmen: + atc: + name: "ATC" + hud: + anchor: "top_left" + priority: 20 + margin: 20 + spacing: 10 + + computer: + name: "Computer" + hud: + anchor: "top_left" + priority: 15 + margin: 20 + spacing: 10 + + status: + name: "Status Display" + hud: + anchor: "bottom_right" + priority: 10 + width: 300 +``` + +## Behavior Details + +### Priority-based Stacking + +Windows with higher priority values are positioned closer to the anchor point: + +``` +Anchor: TOP_LEFT + +Priority 20: ┌─────────────┐ ← Closest to corner (y=20) + │ ATC Message │ + └─────────────┘ +Priority 15: ┌─────────────┐ ← Stacks below (y=130) + │ Navigation │ + └─────────────┘ +Priority 10: ┌─────────────┐ ← Stacks below (y=240) + │ Persistent │ + └─────────────┘ +``` + +### Dynamic Height Adjustment + +When a window's content changes and its height increases/decreases, windows below it automatically reposition: + +``` +Before (ATC height=100): After (ATC height=200): +┌─────────────┐ y=20 ┌─────────────┐ y=20 +│ ATC Message │ │ │ +└─────────────┘ │ ATC Message │ +┌─────────────┐ y=130 │ │ +│ Navigation │ └─────────────┘ +└─────────────┘ ┌─────────────┐ y=230 ← Moved down + │ Navigation │ + └─────────────┘ +``` + +### Visibility and Layout + +Hidden windows (faded out, no content) don't occupy space: + +``` +All visible: Navigation hidden: +┌─────────────┐ y=20 ┌─────────────┐ y=20 +│ ATC │ │ ATC │ +└─────────────┘ └─────────────┘ +┌─────────────┐ y=130 ┌─────────────┐ y=130 ← Moved up! +│ Navigation │ │ Status │ +└─────────────┘ └─────────────┘ +┌─────────────┐ y=240 +│ Status │ +└─────────────┘ +``` + +## Fallback Behavior + +If the layout manager cannot determine a position (edge cases), the system falls back to using the `x` and `y` properties directly from the group props. + +## Testing + +Run layout manager tests: + +```bash +python -m hud_server.tests.run_tests --layout +``` + +This runs unit tests that verify: +- Basic vertical stacking +- Multiple anchor support +- Visibility handling +- Dynamic height updates +- Manual mode positioning +- Collision detection diff --git a/hud_server/layout/__init__.py b/hud_server/layout/__init__.py new file mode 100644 index 000000000..18d93f269 --- /dev/null +++ b/hud_server/layout/__init__.py @@ -0,0 +1,9 @@ +""" +Layout Manager for HUD elements. + +Provides automatic positioning and stacking to prevent overlapping. +""" + +from hud_server.layout.manager import LayoutManager, Anchor, LayoutMode + +__all__ = ["LayoutManager", "Anchor", "LayoutMode"] diff --git a/hud_server/layout/manager.py b/hud_server/layout/manager.py new file mode 100644 index 000000000..f6e948014 --- /dev/null +++ b/hud_server/layout/manager.py @@ -0,0 +1,506 @@ +""" +Layout Manager - Automatic positioning and stacking for HUD elements. + +This module provides intelligent layout management to prevent HUD element overlap: + +1. **Anchor System**: Elements anchor to screen corners (top-left, top-right, bottom-left, bottom-right) +2. **Automatic Stacking**: Elements at the same anchor stack vertically with configurable spacing +3. **Dynamic Reflow**: When element heights change, others reposition automatically +4. **Priority Ordering**: Elements can be ordered by priority within an anchor zone + +Usage: + from hud_server.layout import LayoutManager, Anchor + + # Create manager + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Register windows with anchors + layout.register_window("message_ATC", Anchor.TOP_LEFT, priority=10, margin=20) + layout.register_window("persistent_ATC", Anchor.TOP_LEFT, priority=5, margin=20) + layout.register_window("message_Computer", Anchor.TOP_RIGHT, priority=10, margin=20) + + # Update a window's content height + layout.update_window_height("message_ATC", 200) + + # Get computed positions for all windows + positions = layout.compute_positions() + # Returns: {"message_ATC": (20, 20), "persistent_ATC": (20, 230), ...} +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional, Tuple +import threading + + +class Anchor(Enum): + """Screen anchor points for window positioning.""" + TOP_LEFT = "top_left" + TOP_CENTER = "top_center" + TOP_RIGHT = "top_right" + RIGHT_CENTER = "right_center" + BOTTOM_RIGHT = "bottom_right" + BOTTOM_CENTER = "bottom_center" + BOTTOM_LEFT = "bottom_left" + LEFT_CENTER = "left_center" + CENTER = "center" # Fixed position, no stacking + + +class LayoutMode(Enum): + """Layout modes for window positioning.""" + AUTO = "auto" # Automatic stacking based on anchor + MANUAL = "manual" # User-specified x, y (no auto-adjustment) + HYBRID = "hybrid" # Auto-stack but allow offset adjustments + + +@dataclass +class WindowInfo: + """Information about a window for layout calculations.""" + name: str + anchor: Anchor = Anchor.TOP_LEFT + mode: LayoutMode = LayoutMode.AUTO + priority: int = 0 # Higher = rendered first (closer to anchor) + width: int = 400 + height: int = 100 + margin_x: int = 20 # Margin from screen edge + margin_y: int = 20 + spacing: int = 10 # Spacing between stacked windows + visible: bool = True + group: Optional[str] = None # Group name for collision grouping + + # For manual/hybrid mode - user-specified offsets + manual_x: Optional[int] = None + manual_y: Optional[int] = None + + # Computed position (updated by layout manager) + computed_x: int = 0 + computed_y: int = 0 + + +class LayoutManager: + """ + Manages automatic layout and positioning of HUD windows. + + Thread-safe for use from multiple contexts. + """ + + def __init__( + self, + screen_width: int = 1920, + screen_height: int = 1080, + default_margin: int = 20, + default_spacing: int = 10 + ): + self._lock = threading.RLock() + self._screen_width = screen_width + self._screen_height = screen_height + self._default_margin = default_margin + self._default_spacing = default_spacing + + # Window registry: name -> WindowInfo + self._windows: Dict[str, WindowInfo] = {} + + # Cached positions - invalidated on changes + self._position_cache: Optional[Dict[str, Tuple[int, int]]] = None + self._cache_valid = False + + def set_screen_size(self, width: int, height: int): + """Update screen dimensions and invalidate cache.""" + with self._lock: + if self._screen_width != width or self._screen_height != height: + self._screen_width = width + self._screen_height = height + self._invalidate_cache() + + @property + def screen_size(self) -> Tuple[int, int]: + """Get current screen dimensions.""" + return (self._screen_width, self._screen_height) + + def register_window( + self, + name: str, + anchor: Anchor = Anchor.TOP_LEFT, + mode: LayoutMode = LayoutMode.AUTO, + priority: int = 0, + width: int = 400, + height: int = 100, + margin_x: Optional[int] = None, + margin_y: Optional[int] = None, + spacing: Optional[int] = None, + group: Optional[str] = None, + manual_x: Optional[int] = None, + manual_y: Optional[int] = None, + ) -> WindowInfo: + """ + Register a window for layout management. + + Args: + name: Unique window identifier + anchor: Screen anchor point for positioning + mode: Layout mode (auto, manual, or hybrid) + priority: Stacking priority (higher = closer to anchor) + width: Window width in pixels + height: Current window height in pixels + margin_x: Horizontal margin from screen edge + margin_y: Vertical margin from screen edge + spacing: Vertical spacing between stacked windows + group: Optional group name for related windows + manual_x: Manual x position (for manual/hybrid mode) + manual_y: Manual y position (for manual/hybrid mode) + + Returns: + The WindowInfo object (can be modified directly, but call invalidate_cache after) + """ + with self._lock: + window = WindowInfo( + name=name, + anchor=anchor, + mode=mode, + priority=priority, + width=width, + height=height, + margin_x=margin_x if margin_x is not None else self._default_margin, + margin_y=margin_y if margin_y is not None else self._default_margin, + spacing=spacing if spacing is not None else self._default_spacing, + group=group, + manual_x=manual_x, + manual_y=manual_y, + ) + self._windows[name] = window + self._invalidate_cache() + return window + + def unregister_window(self, name: str) -> bool: + """Remove a window from layout management.""" + with self._lock: + if name in self._windows: + del self._windows[name] + self._invalidate_cache() + return True + return False + + def get_window(self, name: str) -> Optional[WindowInfo]: + """Get window info by name.""" + with self._lock: + return self._windows.get(name) + + def update_window( + self, + name: str, + height: Optional[int] = None, + width: Optional[int] = None, + visible: Optional[bool] = None, + priority: Optional[int] = None, + anchor: Optional[Anchor] = None, + mode: Optional[LayoutMode] = None, + ) -> bool: + """ + Update window properties. + + Returns True if window exists and was updated. + """ + import sys + with self._lock: + window = self._windows.get(name) + if not window: + return False + + changed = False + if height is not None and window.height != height: + window.height = height + changed = True + if width is not None and window.width != width: + window.width = width + changed = True + if visible is not None and window.visible != visible: + window.visible = visible + changed = True + if priority is not None and window.priority != priority: + window.priority = priority + changed = True + if anchor is not None and window.anchor != anchor: + window.anchor = anchor + changed = True + if mode is not None and window.mode != mode: + window.mode = mode + changed = True + + if changed: + self._invalidate_cache() + + return True + + def update_window_height(self, name: str, height: int) -> bool: + """Convenience method to update just the height.""" + return self.update_window(name, height=height) + + def set_window_visible(self, name: str, visible: bool) -> bool: + """Convenience method to set visibility.""" + return self.update_window(name, visible=visible) + + def _invalidate_cache(self): + """Mark position cache as invalid.""" + self._cache_valid = False + self._position_cache = None + + def compute_positions(self, force: bool = False) -> Dict[str, Tuple[int, int]]: + """ + Compute positions for all windows. + + Returns a dict mapping window name to (x, y) position. + Results are cached until windows change. + """ + with self._lock: + if self._cache_valid and self._position_cache and not force: + return self._position_cache + + positions: Dict[str, Tuple[int, int]] = {} + + # Group windows by anchor + by_anchor: Dict[Anchor, List[WindowInfo]] = {a: [] for a in Anchor} + for window in self._windows.values(): + if window.visible: + by_anchor[window.anchor].append(window) + + # Process each anchor zone + for anchor, windows in by_anchor.items(): + if not windows: + continue + + # Sort by priority (higher first, then by name for stability) + windows.sort(key=lambda w: (-w.priority, w.name)) + + # Calculate positions + anchor_positions = self._compute_anchor_positions(anchor, windows) + positions.update(anchor_positions) + + # Update computed positions in window objects + for name, (x, y) in positions.items(): + if name in self._windows: + self._windows[name].computed_x = x + self._windows[name].computed_y = y + + self._position_cache = positions + self._cache_valid = True + return positions + + def _compute_anchor_positions( + self, + anchor: Anchor, + windows: List[WindowInfo] + ) -> Dict[str, Tuple[int, int]]: + """Compute positions for windows at a specific anchor.""" + positions: Dict[str, Tuple[int, int]] = {} + + if anchor == Anchor.CENTER: + # Center mode: each window is centered, no stacking + for window in windows: + if window.mode == LayoutMode.MANUAL: + x = window.manual_x if window.manual_x is not None else self._screen_width // 2 - window.width // 2 + y = window.manual_y if window.manual_y is not None else self._screen_height // 2 - window.height // 2 + else: + x = self._screen_width // 2 - window.width // 2 + y = self._screen_height // 2 - window.height // 2 + positions[window.name] = (x, y) + return positions + + # Calculate starting position based on anchor + if anchor == Anchor.TOP_LEFT: + # Stack downward from top-left + current_y = windows[0].margin_y if windows else self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = window.margin_x + positions[window.name] = (x, current_y) + current_y += window.height + window.spacing + + elif anchor == Anchor.TOP_RIGHT: + # Stack downward from top-right + current_y = windows[0].margin_y if windows else self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = self._screen_width - window.width - window.margin_x + positions[window.name] = (x, current_y) + current_y += window.height + window.spacing + + elif anchor == Anchor.BOTTOM_LEFT: + # Stack upward from bottom-left + current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = window.margin_x + y = current_y - window.height + positions[window.name] = (x, y) + current_y = y - window.spacing + + elif anchor == Anchor.BOTTOM_RIGHT: + # Stack upward from bottom-right + current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = self._screen_width - window.width - window.margin_x + y = current_y - window.height + positions[window.name] = (x, y) + current_y = y - window.spacing + + elif anchor == Anchor.TOP_CENTER: + # Stack downward from top-center + current_y = windows[0].margin_y if windows else self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = self._screen_width // 2 - window.width // 2 + positions[window.name] = (x, current_y) + current_y += window.height + window.spacing + + elif anchor == Anchor.BOTTOM_CENTER: + # Stack upward from bottom-center + current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = self._screen_width // 2 - window.width // 2 + y = current_y - window.height + positions[window.name] = (x, y) + current_y = y - window.spacing + + elif anchor == Anchor.LEFT_CENTER: + # Stack downward from left-center (starting at vertical middle) + total_height = sum(w.height + w.spacing for w in windows) - (windows[-1].spacing if windows else 0) + current_y = (self._screen_height - total_height) // 2 + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = window.margin_x + positions[window.name] = (x, current_y) + current_y += window.height + window.spacing + + elif anchor == Anchor.RIGHT_CENTER: + # Stack downward from right-center (starting at vertical middle) + total_height = sum(w.height + w.spacing for w in windows) - (windows[-1].spacing if windows else 0) + current_y = (self._screen_height - total_height) // 2 + for window in windows: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + positions[window.name] = (window.manual_x, window.manual_y) + else: + x = self._screen_width - window.width - window.margin_x + positions[window.name] = (x, current_y) + current_y += window.height + window.spacing + + return positions + + def get_position(self, name: str) -> Optional[Tuple[int, int]]: + """Get the computed position for a window.""" + positions = self.compute_positions() + return positions.get(name) + + def get_all_windows(self) -> Dict[str, WindowInfo]: + """Get all registered windows.""" + with self._lock: + return dict(self._windows) + + def get_windows_at_anchor(self, anchor: Anchor) -> List[WindowInfo]: + """Get all windows at a specific anchor, sorted by priority.""" + with self._lock: + windows = [w for w in self._windows.values() if w.anchor == anchor and w.visible] + windows.sort(key=lambda w: (-w.priority, w.name)) + return windows + + def check_collision(self, name1: str, name2: str) -> bool: + """Check if two windows overlap.""" + positions = self.compute_positions() + + if name1 not in positions or name2 not in positions: + return False + + w1 = self._windows.get(name1) + w2 = self._windows.get(name2) + if not w1 or not w2: + return False + + x1, y1 = positions[name1] + x2, y2 = positions[name2] + + # AABB collision test + return not ( + x1 + w1.width <= x2 or + x2 + w2.width <= x1 or + y1 + w1.height <= y2 or + y2 + w2.height <= y1 + ) + + def find_collisions(self) -> List[Tuple[str, str]]: + """Find all pairs of overlapping windows.""" + collisions = [] + positions = self.compute_positions() + names = list(positions.keys()) + + for i, name1 in enumerate(names): + for name2 in names[i + 1:]: + if self.check_collision(name1, name2): + collisions.append((name1, name2)) + + return collisions + + def to_dict(self) -> Dict[str, dict]: + """Export layout state to dictionary.""" + with self._lock: + positions = self.compute_positions() + result = {} + for name, window in self._windows.items(): + pos = positions.get(name, (0, 0)) + result[name] = { + "anchor": window.anchor.value, + "mode": window.mode.value, + "priority": window.priority, + "width": window.width, + "height": window.height, + "visible": window.visible, + "computed_x": pos[0], + "computed_y": pos[1], + } + return result + + @classmethod + def from_dict(cls, data: Dict[str, dict], screen_width: int = 1920, screen_height: int = 1080) -> "LayoutManager": + """Create layout manager from dictionary.""" + manager = cls(screen_width=screen_width, screen_height=screen_height) + for name, window_data in data.items(): + manager.register_window( + name=name, + anchor=Anchor(window_data.get("anchor", "top_left")), + mode=LayoutMode(window_data.get("mode", "auto")), + priority=window_data.get("priority", 0), + width=window_data.get("width", 400), + height=window_data.get("height", 100), + ) + return manager + + def debug_print(self): + """Print layout state for debugging.""" + positions = self.compute_positions() + print(f"Layout Manager - Screen: {self._screen_width}x{self._screen_height}") + print("-" * 60) + + for anchor in Anchor: + windows = self.get_windows_at_anchor(anchor) + if windows: + print(f"\n{anchor.value.upper()}:") + for w in windows: + pos = positions.get(w.name, (0, 0)) + print(f" {w.name}: ({pos[0]}, {pos[1]}) " + f"size={w.width}x{w.height} " + f"priority={w.priority} " + f"visible={w.visible}") diff --git a/hud_server/models.py b/hud_server/models.py new file mode 100644 index 000000000..cfa0c5f3a --- /dev/null +++ b/hud_server/models.py @@ -0,0 +1,314 @@ +""" +Pydantic Models for HUD Server API. +""" + +from typing import Optional, Any +from pydantic import BaseModel + + +# ─────────────────────────────── Configuration ─────────────────────────────── # + + +class HudServerSettings(BaseModel): + """HUD Server settings for global configuration.""" + + enabled: bool = False + """Whether the HUD server should auto-start with Wingman AI Core.""" + + host: str = "127.0.0.1" + """The interface to listen on. Use '127.0.0.1' for local only, '0.0.0.0' for LAN access.""" + + port: int = 7862 + """The port to listen on.""" + + framerate: int = 60 + """HUD overlay rendering framerate. Minimum 1.""" + + layout_margin: int = 20 + """Margin from screen edges in pixels for HUD elements.""" + + layout_spacing: int = 15 + """Spacing between stacked HUD windows in pixels.""" + + +# ─────────────────────────────── Group Properties ─────────────────────────────── # + + +class HudGroupProps(BaseModel): + """Properties for a HUD group. All properties are optional when updating.""" + + # Position & Size + x: int = 20 + y: int = 20 + width: int = 400 + max_height: int = 600 + + # Colors + bg_color: str = "#1e212b" + text_color: str = "#f0f0f0" + accent_color: str = "#00aaff" + title_color: Optional[str] = None + + # Visual + opacity: float = 0.85 + border_radius: int = 12 + font_size: int = 16 + font_family: str = "Segoe UI" + content_padding: int = 16 + + # Behavior + typewriter_effect: bool = True + typewriter_speed: int = 200 + show_loader: bool = True + auto_fade: bool = True + fade_delay: float = 8.0 + fade_duration: float = 0.5 + + # Rendering + z_order: int = 0 + + # Layout Management + layout_mode: str = "auto" + """Layout mode: 'auto' (automatic stacking), 'manual' (fixed x,y), 'hybrid' (auto with offset).""" + + anchor: str = "top_left" + """Screen anchor for auto layout: 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center'.""" + + priority: int = 10 + """Stacking priority within anchor zone. Higher = closer to anchor point.""" + + + +class ChatWindowProps(HudGroupProps): + """Extended properties for chat window groups.""" + + auto_hide: bool = False + auto_hide_delay: float = 10.0 + max_messages: int = 50 + sender_colors: Optional[dict[str, str]] = None + show_timestamps: bool = False + message_spacing: int = 8 + fade_old_messages: bool = True + is_chat_window: bool = True + + +# ─────────────────────────────── State Management ─────────────────────────────── # + + +class GroupState(BaseModel): + """State of a HUD group for persistence.""" + + props: dict[str, Any] = {} + """Group properties.""" + + messages: list[dict[str, Any]] = [] + """Current messages in the group.""" + + items: list[dict[str, Any]] = [] + """Persistent items in the group.""" + + chat_messages: list[dict[str, Any]] = [] + """Chat messages (for chat windows).""" + + +# ─────────────────────────────── API Requests ─────────────────────────────── # + + +class CreateGroupRequest(BaseModel): + """Request to create a new HUD group.""" + + group_name: str + """Unique name for this group.""" + + props: Optional[dict[str, Any]] = None + """Optional properties for the group.""" + + +class UpdateGroupRequest(BaseModel): + """Request to update group properties.""" + + group_name: str + """Name of the group to update.""" + + props: dict[str, Any] + """Properties to update.""" + + +class MessageRequest(BaseModel): + """Request to show a message in a group.""" + + group_name: str + """Name of the HUD group.""" + + title: str + """Message title.""" + + content: str + """Message content (supports Markdown).""" + + color: Optional[str] = None + """Optional title/accent color override.""" + + tools: Optional[list[dict[str, Any]]] = None + """Optional tool information for display.""" + + props: Optional[dict[str, Any]] = None + """Optional property overrides for this message.""" + + duration: Optional[float] = None + """Optional duration in seconds before auto-hide.""" + + +class AppendMessageRequest(BaseModel): + """Request to append content to current message (streaming).""" + + group_name: str + content: str + + +class LoaderRequest(BaseModel): + """Request to show/hide loader animation.""" + + group_name: str + show: bool = True + color: Optional[str] = None + + +class ItemRequest(BaseModel): + """Request to add/update a persistent item.""" + + group_name: str + """Name of the HUD group.""" + + title: str + """Item title/identifier (unique within group).""" + + description: str = "" + """Item description.""" + + color: Optional[str] = None + """Optional title color.""" + + duration: Optional[float] = None + """Auto-remove after this many seconds.""" + + +class UpdateItemRequest(BaseModel): + """Request to update an existing item.""" + + group_name: str + title: str + description: Optional[str] = None + color: Optional[str] = None + duration: Optional[float] = None + + +class RemoveItemRequest(BaseModel): + """Request to remove an item.""" + + group_name: str + title: str + + +class ProgressRequest(BaseModel): + """Request to show/update a progress bar.""" + + group_name: str + title: str + current: float + maximum: float = 100 + description: str = "" + color: Optional[str] = None + auto_close: bool = False + props: Optional[dict[str, Any]] = None + + +class TimerRequest(BaseModel): + """Request to show a timer-based progress bar.""" + + group_name: str + title: str + duration: float + description: str = "" + color: Optional[str] = None + auto_close: bool = True + initial_progress: float = 0 + props: Optional[dict[str, Any]] = None + + +class ChatMessageRequest(BaseModel): + """Request to send a chat message.""" + + window_name: str + """Name of the chat window.""" + + sender: str + """Sender name.""" + + text: str + """Message text.""" + + color: Optional[str] = None + """Optional sender color override.""" + + +class CreateChatWindowRequest(BaseModel): + """Request to create a chat window.""" + + name: str + x: int = 20 + y: int = 20 + width: int = 400 + max_height: int = 400 + auto_hide: bool = False + auto_hide_delay: float = 10.0 + max_messages: int = 50 + sender_colors: Optional[dict[str, str]] = None + fade_old_messages: bool = True + props: Optional[dict[str, Any]] = None + + +class StateRestoreRequest(BaseModel): + """Request to restore group state.""" + + group_name: str + """Name of the group to restore.""" + + state: dict[str, Any] + """The state to restore (from get_state endpoint).""" + + +# ─────────────────────────────── API Responses ─────────────────────────────── # + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "healthy" + groups: list[str] = [] + """List of active group names.""" + + version: str = "1.0.0" + + +class GroupStateResponse(BaseModel): + """Response containing group state.""" + + group_name: str + state: dict[str, Any] + + +class OperationResponse(BaseModel): + """Generic operation response.""" + + status: str = "ok" + message: Optional[str] = None + + +class ErrorResponse(BaseModel): + """Error response.""" + + status: str = "error" + message: str + detail: Optional[str] = None + diff --git a/hud_server/overlay/__init__.py b/hud_server/overlay/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/hud_server/overlay/__init__.py @@ -0,0 +1 @@ + diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py new file mode 100644 index 000000000..f312cb638 --- /dev/null +++ b/hud_server/overlay/overlay.py @@ -0,0 +1,3438 @@ +""" +HeadsUp Overlay - PIL-based implementation with sophisticated Markdown rendering + +This implementation uses ONLY: +- PIL (Pillow) for rendering (text, shapes, images) +- Win32 API for window management +""" + +import os +import sys +import json +import threading +import time +import queue +import math +import re +import ctypes +from ctypes import wintypes +from typing import Tuple, Dict, List, Optional +import traceback +import io +import urllib.request +import urllib.error + +# PIL for rendering +try: + from PIL import Image, ImageDraw, ImageFont, ImageChops + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + Image = None + ImageDraw = None + ImageFont = None + ImageChops = None + +from hud_server.rendering.markdown import MarkdownRenderer +from hud_server.platform import win32 +from hud_server.platform.win32 import ( + user32, gdi32, kernel32, + WNDCLASSEXW, BITMAPINFOHEADER, BITMAPINFO, MSG, POINT, + GWL_EXSTYLE, WS_POPUP, WS_EX_LAYERED, WS_EX_TRANSPARENT, WS_EX_TOPMOST, WS_EX_TOOLWINDOW, + WS_EX_NOACTIVATE, LWA_ALPHA, LWA_COLORKEY, SWP_NOSIZE, SWP_NOMOVE, SWP_SHOWWINDOW, + SWP_NOACTIVATE, SWP_ASYNCWINDOWPOS, SRCCOPY, DIB_RGB_COLORS, BI_RGB, + SW_SHOWNOACTIVATE, HWND_TOPMOST, PM_REMOVE, + _ensure_window_class, _class_name +) +from hud_server.layout import LayoutManager, Anchor, LayoutMode + +class HeadsUpOverlay: + """HUD Overlay with sophisticated Markdown rendering. + + Architecture (Rework v2): + - All HUD elements are managed through a unified window system + - Each group (wingman) can have its own message window and persistent window + - Windows are created on-demand and identified by unique names + - Window types: 'message', 'persistent', 'chat' + """ + + # Window type constants + WINDOW_TYPE_MESSAGE = 'message' + WINDOW_TYPE_PERSISTENT = 'persistent' + WINDOW_TYPE_CHAT = 'chat' + + def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, + layout_margin: int = 20, layout_spacing: int = 15): + self.running = True + self.msg_queue = command_queue if command_queue else queue.Queue() + self.error_queue = error_queue + self._next_heartbeat = time.time() + 1.0 + self.use_stdin = command_queue is None + self.dt = 0.0 + self.last_update_time = 0.0 + self._global_framerate = max(1, framerate) + self._layout_margin = layout_margin + self._layout_spacing = layout_spacing + + # ===================================================================== + # UNIFIED WINDOW SYSTEM + # ===================================================================== + # All windows are stored in this dictionary, keyed by unique window name. + # Window name format: "{type}_{group}" e.g. "message_ATC", "persistent_Computer" + # + # Each window state contains: + # - 'type': str - 'message', 'persistent', or 'chat' + # - 'group': str - the group name this window belongs to + # - 'props': dict - display properties (x, y, width, colors, etc.) + # - 'hwnd': window handle + # - 'window_dc': device context + # - 'mem_dc': memory device context + # - 'canvas': PIL Image + # - 'canvas_dirty': bool + # - 'dib_bitmap', 'dib_bits', 'old_bitmap', 'dib_width', 'dib_height': DIB resources + # - 'fade_state': 0=hidden, 1=fade_in, 2=visible, 3=fade_out + # - 'opacity': current opacity (0-255) + # - 'target_opacity': target opacity (0-255) + # - 'last_render_state': for caching + # + # Type-specific fields: + # Message windows: + # - 'current_message': dict or None + # - 'is_loading': bool + # - 'loading_color': tuple + # - 'typewriter_active': bool + # - 'typewriter_char_count': float + # - 'last_typewriter_update': float + # - 'min_display_time': float + # - 'current_blocks': parsed markdown blocks + # + # Persistent windows: + # - 'items': dict[title -> item_info] + # - 'progress_animations': dict[title -> animation_state] + # + # Chat windows: + # - 'messages': list of chat messages + # - 'last_message_time': float + # - 'visible': bool + self._windows: Dict[str, Dict] = {} + + # Default properties for new windows + self._default_props = { + 'width': 400, 'x': 20, 'y': 20, + 'bg_color': '#1e212b', 'text_color': '#f0f0f0', 'accent_color': '#00aaff', + 'opacity': 0.85, 'duration': 8.0, 'border_radius': 12, 'content_padding': 16, + 'max_height': 600, 'font_size': 16, 'color_emojis': True, + 'typewriter_effect': True, + # Persistent window defaults + 'persistent_x': 20, 'persistent_y': 300, 'persistent_width': 300, + } + + # Per-group props storage (set via create_group/update_group) + self._group_props: Dict[str, Dict] = {} + + # Progress animation transition duration + self._progress_transition_duration = 0.5 + + # ===================================================================== + # LEGACY COMPATIBILITY LAYER + # ===================================================================== + # These are kept for backward compatibility with code that doesn't use groups. + # They point to the "_default" group windows. + self.is_loading = False + self.loading_color = (0, 170, 255) + self.current_message = None + self.display_props = dict(self._default_props) + self.target_opacity = 216 + self.current_opacity = 0 + self.fade_state = 0 + self.min_display_time = 0 + self.typewriter_active = False + self.typewriter_char_count = 0 + self.last_typewriter_update = 0 + + # Legacy persistent infos (global, merged from all groups for backward compat) + self.persistent_infos = {} + self.persistent_fade_state = 0 + self.persistent_opacity = 0 + self._progress_animations = {} + self._persistent_render_time = 0.0 + + # Legacy Win32 resources (for _default group, created in run()) + self.hwnd = None + self.window_dc = None + self.mem_dc = None + self.dib_bitmap = None + self.dib_bits = None + self.old_bitmap = None + self.dib_width = 0 + self.dib_height = 0 + + self.hwnd_persistent = None + self.window_dc_persistent = None + self.mem_dc_persistent = None + self.dib_bitmap_persistent = None + self.dib_bits_persistent = None + self.old_bitmap_persistent = None + self.dib_width_persistent = 0 + self.dib_height_persistent = 0 + + # Legacy PIL resources + self.canvas = None + self.canvas_persistent = None + self.temp_image = None + self.temp_draw = None + self.fonts = {} + self.image_cache = {} + self.md_renderer = None + self.last_render_state = None + self.last_render_state_persistent = None + self.current_blocks = None + self.canvas_dirty = False + self.canvas_persistent_dirty = False + + # Legacy chat window state (will be migrated to unified system) + self._chat_windows: Dict[str, Dict] = {} + self._chat_window_dirty: Dict[str, bool] = {} + self._chat_canvases: Dict[str, Image.Image] = {} + self._chat_hwnds: Dict[str, int] = {} + self._chat_window_dcs: Dict[str, tuple] = {} + self._chat_last_render_state: Dict[str, tuple] = {} + + # ===================================================================== + # LAYOUT MANAGER + # ===================================================================== + # Automatic positioning and stacking to prevent window overlap + self._layout_manager = LayoutManager( + screen_width=user32.GetSystemMetrics(0) if hasattr(user32, 'GetSystemMetrics') else 1920, + screen_height=user32.GetSystemMetrics(1) if hasattr(user32, 'GetSystemMetrics') else 1080, + default_margin=self._layout_margin, + default_spacing=self._layout_spacing, + ) + + # ========================================================================= + # UNIFIED WINDOW MANAGEMENT + # ========================================================================= + + def _get_window_name(self, window_type: str, group: str) -> str: + """Generate a unique window name from type and group.""" + return f"{window_type}_{group}" + + def _get_default_window_props(self, window_type: str, group: str) -> dict: + """Get default properties for a new window, merging group props if available.""" + props = dict(self._default_props) + + # Apply group-specific props if available + if group in self._group_props: + props.update(self._group_props[group]) + + # Adjust defaults based on window type + if window_type == self.WINDOW_TYPE_PERSISTENT: + # Use persistent_* props for position + props['x'] = props.get('persistent_x', 20) + props['y'] = props.get('persistent_y', 300) + props['width'] = props.get('persistent_width', 300) + + return props + + def _create_window_state(self, window_type: str, group: str, props: dict = None) -> Dict: + """Create a new window state dictionary.""" + merged_props = self._get_default_window_props(window_type, group) + if props: + merged_props.update(props) + + state = { + 'type': window_type, + 'group': group, + 'props': merged_props, + 'hwnd': None, + 'window_dc': None, + 'mem_dc': None, + 'canvas': None, + 'canvas_dirty': False, + 'dib_bitmap': None, + 'dib_bits': None, + 'old_bitmap': None, + 'dib_width': 0, + 'dib_height': 0, + 'fade_state': 0, # hidden + 'opacity': 0, + 'target_opacity': int(merged_props.get('opacity', 0.85) * 255), + 'last_render_state': None, + } + + # Type-specific initialization + if window_type == self.WINDOW_TYPE_MESSAGE: + state.update({ + 'current_message': None, + 'is_loading': False, + 'loading_color': (0, 170, 255), + 'typewriter_active': False, + 'typewriter_char_count': 0, + 'last_typewriter_update': 0, + 'min_display_time': 0, + 'current_blocks': None, + }) + elif window_type == self.WINDOW_TYPE_PERSISTENT: + state.update({ + 'items': {}, + 'progress_animations': {}, + }) + elif window_type == self.WINDOW_TYPE_CHAT: + state.update({ + 'messages': [], + 'last_message_time': 0, + 'visible': True, + }) + + return state + + def _ensure_window(self, window_type: str, group: str, props: dict = None) -> Dict: + """Get or create a window for the given type and group.""" + name = self._get_window_name(window_type, group) + + if name not in self._windows: + # Create new window state + state = self._create_window_state(window_type, group, props) + self._windows[name] = state + + # Create the actual Win32 window + window_props = state['props'] + w = int(window_props.get('width', 400)) + h = 100 # Initial height, will be adjusted during rendering + + # Register with layout manager + layout_mode_str = window_props.get('layout_mode', 'auto') + anchor_str = window_props.get('anchor', 'top_left') + priority = int(window_props.get('priority', 10)) + margin = int(window_props.get('margin', 20)) + spacing = int(window_props.get('spacing', 10)) + + # Map string to enum + try: + anchor = Anchor(anchor_str) + except ValueError: + anchor = Anchor.TOP_LEFT + + try: + layout_mode = LayoutMode(layout_mode_str) + except ValueError: + layout_mode = LayoutMode.AUTO + + # Adjust priority for persistent windows (lower so they stack below messages) + if window_type == self.WINDOW_TYPE_PERSISTENT: + priority = max(0, priority - 5) + + self._layout_manager.register_window( + name=name, + anchor=anchor, + mode=layout_mode, + priority=priority, + width=w, + height=h, + margin_x=margin, + margin_y=margin, + spacing=spacing, + group=group, + manual_x=int(window_props.get('x', 20)) if layout_mode == LayoutMode.MANUAL else None, + manual_y=int(window_props.get('y', 20)) if layout_mode == LayoutMode.MANUAL else None, + ) + + # Get initial position from layout manager + pos = self._layout_manager.get_position(name) + if pos: + x, y = pos + else: + x = int(window_props.get('x', 20)) + y = int(window_props.get('y', 20)) + + hwnd = self._create_overlay_window(f"HUD_{name}", x, y, w, h) + if hwnd: + window_dc, mem_dc = self._init_gdi(hwnd) + state['hwnd'] = hwnd + state['window_dc'] = window_dc + state['mem_dc'] = mem_dc + elif props: + # Update existing window props + self._windows[name]['props'].update(props) + self._windows[name]['target_opacity'] = int( + self._windows[name]['props'].get('opacity', 0.85) * 255 + ) + + # Update layout manager if layout props changed + window_props = self._windows[name]['props'] + layout_mode_str = window_props.get('layout_mode', 'auto') + anchor_str = window_props.get('anchor', 'top_left') + + try: + anchor = Anchor(anchor_str) + except ValueError: + anchor = Anchor.TOP_LEFT + + try: + layout_mode = LayoutMode(layout_mode_str) + except ValueError: + layout_mode = LayoutMode.AUTO + + self._layout_manager.update_window( + name, + anchor=anchor, + mode=layout_mode, + priority=int(window_props.get('priority', 10)), + ) + + return self._windows[name] + + def _get_window(self, window_type: str, group: str) -> Dict: + """Get a window state, or None if it doesn't exist.""" + name = self._get_window_name(window_type, group) + return self._windows.get(name) + + def _destroy_window(self, name: str): + """Destroy a window and clean up its resources.""" + if name not in self._windows: + return + + state = self._windows[name] + + # Cleanup DIB + if state.get('old_bitmap') and state.get('mem_dc'): + try: + gdi32.SelectObject(state['mem_dc'], state['old_bitmap']) + except: + pass + if state.get('dib_bitmap'): + try: + gdi32.DeleteObject(state['dib_bitmap']) + except: + pass + + # Cleanup DCs + if state.get('mem_dc'): + try: + gdi32.DeleteDC(state['mem_dc']) + except: + pass + if state.get('window_dc') and state.get('hwnd'): + try: + user32.ReleaseDC(state['hwnd'], state['window_dc']) + except: + pass + + # Destroy window + if state.get('hwnd'): + try: + user32.DestroyWindow(state['hwnd']) + except: + pass + + # Unregister from layout manager + self._layout_manager.unregister_window(name) + + del self._windows[name] + + def _destroy_group_windows(self, group: str): + """Destroy all windows for a group.""" + names_to_destroy = [ + name for name in self._windows + if self._windows[name].get('group') == group + ] + for name in names_to_destroy: + self._destroy_window(name) + + # ========================================================================= + # UNIFIED WINDOW UPDATE AND RENDER LOOP + # ========================================================================= + + def _update_all_windows(self): + """Update and render all windows in the unified system.""" + # First pass: update and draw all windows + message_windows = {} + persistent_windows = {} + + for name, win in list(self._windows.items()): + try: + win_type = win.get('type') + group = win.get('group', 'default') + + if win_type == self.WINDOW_TYPE_MESSAGE: + message_windows[group] = win + self._update_message_window(name, win) + self._draw_message_window(name, win) + self._blit_window(name, win) + + elif win_type == self.WINDOW_TYPE_PERSISTENT: + persistent_windows[group] = win + self._update_persistent_window(name, win) + self._draw_persistent_window(name, win) + # Don't blit yet - wait for collision check + + # Note: Chat windows use the legacy system for now + + except Exception as e: + sys.stderr.write(f"[HUD] Window {name} update error: {e}\n") + + # Second pass: check collisions and update persistent windows + for group, pers_win in persistent_windows.items(): + try: + msg_win = message_windows.get(group) + collision = self._check_window_collision(msg_win, pers_win) + self._update_persistent_fade(pers_win, collision) + self._blit_window(self._get_window_name(self.WINDOW_TYPE_PERSISTENT, group), pers_win) + except Exception as e: + sys.stderr.write(f"[HUD] Persistent window {group} collision error: {e}\n") + + # Third pass: Update ALL window positions from layout manager + # This ensures windows reposition when others hide/show/resize + self._update_all_window_positions() + + def _update_all_window_positions(self): + """Update positions of all windows based on layout manager calculations.""" + # Force recompute positions + positions = self._layout_manager.compute_positions(force=True) + + for name, win in self._windows.items(): + hwnd = win.get('hwnd') + if not hwnd: + continue + + # Skip windows that are completely hidden (fade_state 0 AND opacity 0) + fade_state = win.get('fade_state', 0) + opacity = win.get('opacity', 0) + if fade_state == 0 and opacity == 0: + continue + + canvas = win.get('canvas') + if not canvas: + continue + + # For windows that are visible or fading in, use layout position + # For windows fading out (state 3), keep their current position (don't move during fade) + if fade_state in (1, 2): # Fading in or fully visible + pos = positions.get(name) + if pos: + x, y = pos + w, h = canvas.size + + # Check if position actually changed + old_x = win.get('_last_x', -1) + old_y = win.get('_last_y', -1) + + if x != old_x or y != old_y: + # Position changed - move window and mark for reblit + user32.MoveWindow(hwnd, x, y, w, h, True) # True = repaint + win['_last_x'] = x + win['_last_y'] = y + win['canvas_dirty'] = True # Force reblit after move + + def _update_message_window(self, name: str, win: Dict): + """Update message window state (typewriter, fade, etc.).""" + # Typewriter progression + if win.get('typewriter_active') and win.get('current_message'): + now = time.time() + chars = (now - win.get('last_typewriter_update', now)) * 200 + if chars > 0: + win['typewriter_char_count'] = win.get('typewriter_char_count', 0) + chars + win['last_typewriter_update'] = now + msg_len = len(win['current_message'].get('message', '')) + if win['typewriter_char_count'] >= msg_len: + win['typewriter_active'] = False + win['typewriter_char_count'] = float(msg_len) + + # Fade logic + self._update_window_fade(win, has_content=bool(win.get('current_message') or win.get('is_loading'))) + + # Auto-hide check + if win['fade_state'] == 2: # visible + should_fade = True + if win.get('is_loading'): + should_fade = False + elif win.get('current_message'): + if time.time() <= win.get('min_display_time', 0): + should_fade = False + if should_fade: + win['fade_state'] = 3 + # Clear message so has_content becomes False and fade-out can proceed + win['current_message'] = None + # Notify layout manager immediately + window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, win.get('group', 'default')) + self._layout_manager.set_window_visible(window_name, False) + + def _update_persistent_window(self, name: str, win: Dict): + """Update persistent window state (progress animations, expiry, etc.).""" + now = time.time() + items = win.get('items', {}) + progress_anims = win.get('progress_animations', {}) + + # Check for expired items + expired = [title for title, info in items.items() + if info.get('expiry') and now > info['expiry']] + for title in expired: + del items[title] + if title in progress_anims: + del progress_anims[title] + + # Update progress animations + items_to_remove = [] + for title, info in list(items.items()): + if title not in progress_anims: + continue + + anim = progress_anims[title] + + if anim.get('is_timer'): + # Timer-based progress + timer_elapsed = now - anim.get('timer_start', now) + timer_duration = anim.get('timer_duration', 1) + timer_progress = min(100, (timer_elapsed / timer_duration) * 100) + anim['current'] = timer_progress + info['progress_current'] = timer_progress + + if timer_elapsed >= timer_duration and info.get('auto_close') and not info.get('auto_close_triggered'): + info['auto_close_triggered'] = True + info['auto_close_time'] = now + 2.0 + else: + # Regular progress animation + elapsed = now - anim.get('start_time', now) + duration = self._progress_transition_duration + + if duration > 0 and elapsed < duration: + t = elapsed / duration + t = 1 - (1 - t) ** 3 # ease-out cubic + anim['current'] = anim.get('start_value', 0) + (anim.get('target', 0) - anim.get('start_value', 0)) * t + else: + anim['current'] = anim.get('target', 0) + + # Check for auto-close at 100% + percentage = (anim['current'] / info.get('progress_maximum', 100)) * 100 + if percentage >= 100 and info.get('auto_close') and not info.get('auto_close_triggered'): + info['auto_close_triggered'] = True + info['auto_close_time'] = now + 2.0 + + # Handle auto-close removal + if info.get('auto_close_triggered') and info.get('auto_close_time'): + if now >= info['auto_close_time']: + items_to_remove.append(title) + + for title in items_to_remove: + if title in items: + del items[title] + if title in progress_anims: + del progress_anims[title] + + # Fade logic + self._update_window_fade(win, has_content=bool(items)) + + def _update_window_fade(self, win: Dict, has_content: bool): + """Update fade animation for a window.""" + hwnd = win.get('hwnd') + if not hwnd: + return + + key = 0x00FF00FF + fade_amount = int(1080 * self.dt) + if fade_amount < 1: + fade_amount = 1 + + target = win.get('target_opacity', 216) + old_fade_state = win['fade_state'] + + # Determine target state + if has_content and win['fade_state'] in (0, 3): + win['fade_state'] = 1 # start fade in + elif not has_content and win['fade_state'] in (1, 2): + win['fade_state'] = 3 # start fade out + + # Update layout manager visibility when fade state changes + window_name = self._get_window_name(win.get('type', 'message'), win.get('group', 'default')) + if old_fade_state != win['fade_state']: + # Window is visible for layout purposes only when fading in (1) or fully visible (2) + # When fading out (3) or hidden (0), it should NOT take up layout space + is_visible = win['fade_state'] in (1, 2) + self._layout_manager.set_window_visible(window_name, is_visible) + + if win['fade_state'] == 1: # Fade in + win['opacity'] = min(target, win.get('opacity', 0) + fade_amount) + user32.SetLayeredWindowAttributes(hwnd, key, win['opacity'], LWA_ALPHA | LWA_COLORKEY) + if win['opacity'] >= target: + win['fade_state'] = 2 + + elif win['fade_state'] == 3: # Fade out + win['opacity'] = max(0, win.get('opacity', 0) - fade_amount) + user32.SetLayeredWindowAttributes(hwnd, key, win['opacity'], LWA_ALPHA | LWA_COLORKEY) + if win['opacity'] <= 0: + win['fade_state'] = 0 + # Update layout visibility when fully hidden + self._layout_manager.set_window_visible(window_name, False) + if win['type'] == self.WINDOW_TYPE_MESSAGE: + win['current_message'] = None + + elif win['fade_state'] == 2: # Visible - maintain target opacity + if win['opacity'] != target: + if win['opacity'] < target: + win['opacity'] = min(target, win['opacity'] + fade_amount) + else: + win['opacity'] = max(target, win['opacity'] - fade_amount) + user32.SetLayeredWindowAttributes(hwnd, key, win['opacity'], LWA_ALPHA | LWA_COLORKEY) + + def _draw_message_window(self, name: str, win: Dict): + """Draw content for a message window.""" + current_message = win.get('current_message') + is_loading = win.get('is_loading', False) + + if not current_message and not is_loading: + return + + props = win.get('props', {}) + bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + + width = int(props.get('width', 400)) + max_height = int(props.get('max_height', 600)) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 16)) + + # Build state hash for caching + # Force repaint every frame while loading (animation needs continuous updates) + if is_loading: + # Use time-based state to force repaint each frame + current_state = ('loading', time.time()) + else: + try: + # Include tools in state hash + tools = current_message.get('tools', []) if current_message else [] + tools_hash = tuple((t.get('source', ''), t.get('name', '')) for t in tools) if tools else () + + msg_state = ( + current_message.get('message', '') if current_message else '', + current_message.get('title', '') if current_message else '', + int(win.get('typewriter_char_count', 0)), + tools_hash, + ) + except: + msg_state = str(current_message) + + # Include visual props in state hash for real-time config updates + visual_props_hash = ( + width, max_height, radius, padding, + bg, text_color, accent, + props.get('opacity', 0.85), + props.get('font_size', 16), + props.get('font_family', ''), + ) + current_state = (msg_state, win.get('opacity', 0), visual_props_hash) + + if win.get('last_render_state') == current_state and win.get('canvas'): + return + + win['last_render_state'] = current_state + win['canvas_dirty'] = True + + # Ensure renderer exists + if not self.md_renderer: + self._init_fonts() + colors = {'text': text_color, 'accent': accent, 'bg': bg} + self.md_renderer = MarkdownRenderer(self.fonts, colors, props.get('color_emojis', True)) + + # Update renderer colors + self.md_renderer.set_colors(text_color, accent, bg) + + # Create temp canvas + temp_h = max_height + 500 + temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(temp) + + y = padding + + # Title + if current_message: + title = current_message.get('title', '') + if title: + title = self._strip_emotions(title) + font_bold = self.fonts.get('bold', self.fonts.get('normal', self.fonts.get('regular'))) + if font_bold: + draw.text((padding, y), title, fill=accent + (255,), font=font_bold) + try: + bbox = font_bold.getbbox(title) + y += bbox[3] - bbox[1] + 12 + except: + y += 24 + + # Message content with typewriter + message = current_message.get('message', '') + if message: + message = self._strip_emotions(message) + + # Use max_chars for typewriter effect (don't truncate message directly) + typewriter_active = win.get('typewriter_active', False) + max_chars = int(win.get('typewriter_char_count', 0)) if typewriter_active else None + + # Use cached blocks if available and message hasn't changed + cached = win.get('current_blocks') + if cached is None or cached.get('msg') != message: + win['current_blocks'] = { + 'msg': message, + 'blocks': self.md_renderer.parse_blocks(message) + } + cached = win['current_blocks'] + + if self.md_renderer: + y = self.md_renderer.render( + draw, temp, message, padding, y, width - padding * 2, max_chars, + pre_parsed_blocks=cached['blocks'] + ) + + # Tool chips - display skill/tool information + if current_message: + tools = current_message.get('tools', []) + if tools: + y += 10 + tx = padding + th = 30 + + # Group tools by source (skill/mcp name) + counts = {} + for t in tools: + key = (t.get('source', 'System'), t.get('icon')) + counts[key] = counts.get(key, 0) + 1 + + for (src, icon), cnt in counts.items(): + font = self.fonts.get('code', self.fonts.get('normal', self.fonts.get('regular'))) + sw, sh = self._get_text_size(src, font) + icon_w = 24 if icon and os.path.exists(str(icon)) else 0 + badge_w = 26 if cnt > 1 else 0 + chip_w = sw + icon_w + badge_w + 26 + + if tx + chip_w > width - padding: + tx = padding + y += th + 10 + + # Modern chip with subtle background + chip_bg = (42, 48, 60, 235) + draw.rounded_rectangle([tx, y, tx + chip_w, y + th], radius=th//2, + fill=chip_bg, outline=accent) + + ix = tx + 12 + if icon and os.path.exists(str(icon)): + try: + if icon not in self.image_cache: + img = Image.open(icon).convert('RGBA').resize((18, 18), Image.Resampling.LANCZOS) + self.image_cache[icon] = img + temp.paste(self.image_cache[icon], (ix, y + 6), self.image_cache[icon]) + ix += 22 + except: + pass + + draw.text((ix, y + (th - sh) // 2), src, fill=text_color, font=font) + + # Badge for multiple tool calls from same source + if cnt > 1: + bw, bh = self._get_text_size(str(cnt), font) + bx = tx + chip_w - bw - 16 + draw.ellipse([bx - 4, y + 5, bx + bw + 8, y + th - 5], fill=accent) + draw.text((bx + 2, y + 7), str(cnt), fill=bg, font=font) + + tx += chip_w + 10 + + y += th + 10 + + # Loading animation + if win.get('is_loading'): + y += 6 + loading_color = win.get('loading_color', (0, 170, 255)) + self._draw_loading(draw, temp, padding, y, width - padding * 2, loading_color) + y += 24 + + # Calculate final height + bottom_padding = padding - 4 + final_h = min(max(60, y + bottom_padding), max_height) + + # Create final canvas - ALWAYS create fresh to prevent ghosting + old_canvas = win.get('canvas') + if old_canvas is None or old_canvas.width != width or old_canvas.height != final_h: + canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + win['canvas'] = canvas + else: + canvas = old_canvas + # Completely clear the canvas with magenta (transparency key) + # Use a new image to ensure complete overwrite + canvas.paste(Image.new('RGBA', (width, final_h), (255, 0, 255, 255)), (0, 0)) + + final_draw = ImageDraw.Draw(canvas) + # Draw solid background first (covers everything) + final_draw.rectangle([0, 0, width, final_h], fill=(255, 0, 255, 255)) + # Then draw the rounded rectangle on top + final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, + fill=bg + (255,), outline=(55, 62, 74)) + + crop_height = min(final_h, temp.height) + crop = temp.crop((0, 0, width, crop_height)) + # Composite the text onto the background properly + # Use Image.alpha_composite to blend correctly without leaving ghost pixels + # First, create a version of the canvas portion and composite + canvas_region = canvas.crop((0, 0, width, crop_height)) + composited = Image.alpha_composite(canvas_region, crop) + canvas.paste(composited, (0, 0)) + + # Update layout manager with new height and get position + self._layout_manager.update_window_height(name, final_h) + + # Get position from layout manager + pos = self._layout_manager.get_position(name) + + hwnd = win.get('hwnd') + if hwnd: + if pos: + x, y_pos = pos + else: + # Fallback to props + x = int(props.get('x', 20)) + y_pos = int(props.get('y', 20)) + user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) + + def _draw_persistent_window(self, name: str, win: Dict): + """Draw content for a persistent window.""" + items = win.get('items', {}) + if not items: + return + + props = win.get('props', {}) + bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + + width = int(props.get('width', 300)) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 16)) + + # State hash for caching - include visual props so config changes trigger re-render + now = time.time() + progress_anims = win.get('progress_animations', {}) + + items_state = [] + for title, info in sorted(items.items()): + if title in progress_anims: + items_state.append((title, progress_anims[title].get('current', 0))) + else: + items_state.append((title, info.get('description', ''))) + + # Include visual props in state hash for real-time config updates + visual_props_hash = ( + width, radius, padding, + bg, text_color, accent, + props.get('opacity', 0.85), + props.get('font_size', 16), + props.get('font_family', ''), + ) + current_state = (tuple(items_state), int(now), visual_props_hash) + + last_state = win.get('last_render_state') + cache_hit = (last_state == current_state and win.get('canvas')) + + if cache_hit: + return + + win['last_render_state'] = current_state + win['canvas_dirty'] = True + + # Ensure renderer + if not self.md_renderer: + self._init_fonts() + colors = {'text': text_color, 'accent': accent, 'bg': bg} + self.md_renderer = MarkdownRenderer(self.fonts, colors, props.get('color_emojis', True)) + + self.md_renderer.set_colors(text_color, accent, bg) + + # Create temp canvas + temp = Image.new('RGBA', (width, 2000), (0, 0, 0, 0)) + draw = ImageDraw.Draw(temp) + + y = padding + font_bold = self.fonts.get('bold', self.fonts.get('normal', self.fonts.get('regular'))) + font_normal = self.fonts.get('normal', self.fonts.get('regular')) + now = time.time() + + for title, info in sorted(items.items(), key=lambda x: x[1].get('added_at', 0)): + # Check expiry but don't delete (logic does that) + if info.get('expiry') and now > info['expiry']: + continue + + # Calculate timer width/draw timer for expiry OR progress timer + timer_w = 0 + timer_text = None + + # For progress items with timer, calculate remaining time + if info.get('is_progress') and info.get('is_timer'): + timer_start = info.get('timer_start', now) + timer_duration = info.get('timer_duration', 0) + elapsed_time = now - timer_start + remaining_seconds = max(0, timer_duration - elapsed_time) + remaining = int(remaining_seconds + 0.999) + + r = remaining + d = r // 86400 + r %= 86400 + h = r // 3600 + r %= 3600 + m = r // 60 + s = r % 60 + + parts = [] + if d > 0: parts.append(f"{d}d") + if h > 0: parts.append(f"{h}h") + if m > 0: parts.append(f"{m}m") + parts.append(f"{s}s") + + timer_text = " ".join(parts) + elif info.get('expiry'): + remaining = max(0, int(info['expiry'] - now + 0.999)) + r = remaining + d = r // 86400 + r %= 86400 + h = r // 3600 + r %= 3600 + m = r // 60 + s = r % 60 + + parts = [] + if d > 0: parts.append(f"{d}d") + if h > 0: parts.append(f"{h}h") + if m > 0: parts.append(f"{m}m") + parts.append(f"{s}s") + + timer_text = " ".join(parts) + + # Draw timer text on the right side + if timer_text and font_bold: + timer_w, _ = self._get_text_size(timer_text, font_bold) + draw.text((width - padding - timer_w, y), timer_text, fill=text_color + (255,), font=font_bold) + timer_w += 10 + + # Title - render with emoji support (account for timer width) + title_text = info.get('title', title) + max_title_w = width - (padding * 2) - timer_w + if font_bold: + self._render_text_with_emoji(draw, title_text, padding, y, accent + (255,), font_bold) + y += 22 + + # Progress bar + if info.get('is_progress'): + progress_max = float(info.get('progress_maximum', 100)) + if title in progress_anims: + progress_current = progress_anims[title].get('current', 0) + else: + progress_current = float(info.get('progress_current', 0)) + + if progress_max <= 0: + progress_max = 100 + percentage = min(100, max(0, (progress_current / progress_max) * 100)) + + progress_color = accent + if info.get('progress_color'): + progress_color = self._hex_to_rgb(info['progress_color']) + + bar_width = width - padding * 2 + bar_height = 16 + y += 4 + + # Draw progress bar using existing method + y = self._draw_progress_bar(draw, temp, padding, y, bar_width, bar_height, + percentage, bg, progress_color, text_color) + + # Percentage text + if font_normal: + pct_text = f"{percentage:.0f}%" + try: + bbox = font_normal.getbbox(pct_text) + pct_w = bbox[2] - bbox[0] + except: + pct_w = len(pct_text) * 7 + pct_x = padding + (bar_width - pct_w) // 2 + draw.text((pct_x, y), pct_text, fill=text_color + (200,), font=font_normal) + y += 18 + + # Description + desc = info.get('description', '') + if desc: + desc = self._strip_emotions(desc) + if self.md_renderer: + y = self.md_renderer.render(draw, temp, desc, padding, y, width - padding * 2) + + y += 8 + + # Finalize canvas + bottom_padding = padding - 4 + final_h = max(60, y + bottom_padding) + + # Create final canvas - ALWAYS create fresh to prevent ghosting + old_canvas = win.get('canvas') + if old_canvas is None or old_canvas.width != width or old_canvas.height != final_h: + canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + win['canvas'] = canvas + else: + canvas = old_canvas + # Completely clear the canvas with magenta (transparency key) + canvas.paste(Image.new('RGBA', (width, final_h), (255, 0, 255, 255)), (0, 0)) + + final_draw = ImageDraw.Draw(canvas) + # Draw solid background first (covers everything) + final_draw.rectangle([0, 0, width, final_h], fill=(255, 0, 255, 255)) + # Then draw the rounded rectangle on top + final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, + fill=bg + (255,), outline=(55, 62, 74)) + + crop = temp.crop((0, 0, width, final_h)) + # Composite the content onto the background properly + canvas_region = canvas.crop((0, 0, width, final_h)) + composited = Image.alpha_composite(canvas_region, crop) + canvas.paste(composited, (0, 0)) + + # Update layout manager with new height and get position + self._layout_manager.update_window_height(name, final_h) + + # Get position from layout manager + pos = self._layout_manager.get_position(name) + + hwnd = win.get('hwnd') + if hwnd: + if pos: + x, y_pos = pos + else: + # Fallback to props + x = int(props.get('x', 20)) + y_pos = int(props.get('y', 20)) + user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) + + def _blit_window(self, name: str, win: Dict): + """Blit a window's canvas to its Win32 window.""" + if win.get('opacity', 0) <= 0: + return + if not win.get('canvas_dirty', False): + return + + canvas = win.get('canvas') + hwnd = win.get('hwnd') + window_dc = win.get('window_dc') + mem_dc = win.get('mem_dc') + + if not all([canvas, hwnd, window_dc, mem_dc]): + return + + w, h = canvas.size + + # Check if DIB needs resize + if w != win.get('dib_width', 0) or h != win.get('dib_height', 0): + # Cleanup old DIB + if win.get('old_bitmap'): + gdi32.SelectObject(mem_dc, win['old_bitmap']) + if win.get('dib_bitmap'): + gdi32.DeleteObject(win['dib_bitmap']) + + # Create new DIB + win['dib_width'] = w + win['dib_height'] = h + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w + bmi.bmiHeader.biHeight = -h # Top-down + bmi.bmiHeader.biPlanes = 1 + bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + + dib_bits = ctypes.c_void_p() + dib_bitmap = gdi32.CreateDIBSection(mem_dc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(dib_bits), None, 0) + if dib_bitmap: + win['old_bitmap'] = gdi32.SelectObject(mem_dc, dib_bitmap) + win['dib_bitmap'] = dib_bitmap + win['dib_bits'] = dib_bits + + dib_bits = win.get('dib_bits') + if not dib_bits: + return + + try: + rgba = canvas.tobytes('raw', 'BGRA') + # Clear the entire DIB buffer first to prevent any ghosting + buffer_size = w * h * 4 + # Overwrite entire buffer with new content + ctypes.memmove(dib_bits, rgba, buffer_size) + gdi32.BitBlt(window_dc, 0, 0, w, h, mem_dc, 0, 0, SRCCOPY) + win['canvas_dirty'] = False + except Exception as e: + pass + + def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]: + try: + hex_color = hex_color.lstrip('#') + if len(hex_color) == 3: + hex_color = ''.join([c*2 for c in hex_color]) + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + except: + return (0, 170, 255) + + def _strip_emotions(self, text: str) -> str: + """Remove emotion tags like [happy], [sad], [breathe] but preserve markdown links and checkboxes.""" + # First, temporarily protect markdown links + link_pattern = r'\[([^]]+)]\(([^)]+)\)' + links = [] + def save_link(m): + links.append(m.group(0)) + return f'__LINK_{len(links)-1}__' + + text = re.sub(link_pattern, save_link, text) + + # Temporarily protect checkboxes [ ], [x], [X] + checkbox_pattern = r'\[[ xX]\]' + checkboxes = [] + def save_checkbox(m): + checkboxes.append(m.group(0)) + return f'__CHECKBOX_{len(checkboxes)-1}__' + + text = re.sub(checkbox_pattern, save_checkbox, text) + + # Remove emotion tags (single words in brackets, must be 2+ chars to avoid single letters) + # This matches [word] where word is 2 or more letters/underscores + text = re.sub(r'\[[a-zA-Z_]{2,}]\s*', '', text) + + # Restore checkboxes + for i, checkbox in enumerate(checkboxes): + text = text.replace(f'__CHECKBOX_{i}__', checkbox) + + # Restore links + for i, link in enumerate(links): + text = text.replace(f'__LINK_{i}__', link) + + # Restore links + for i, link in enumerate(links): + text = text.replace(f'__LINK_{i}__', link) + + # Clean up whitespace + text = text.strip() + # Remove leading newlines + text = re.sub(r'^\n+', '', text) + # Collapse multiple consecutive newlines into one (paragraph breaks become single empty lines) + text = re.sub(r'\n{3,}', '\n\n', text) + + return text + + def _init_fonts(self): + size = int(self.display_props.get('font_size', 16)) + font_family = self.display_props.get('font_family', 'Segoe UI') + + # Map font family names to Windows font files + font_map = { + 'segoe ui': {'normal': 'segoeuisl.ttf', 'bold': 'segoeuib.ttf', 'italic': 'segoeuii.ttf', 'bold_italic': 'segoeuiz.ttf'}, + 'arial': {'normal': 'arial.ttf', 'bold': 'arialbd.ttf', 'italic': 'ariali.ttf', 'bold_italic': 'arialbi.ttf'}, + 'verdana': {'normal': 'verdana.ttf', 'bold': 'verdanab.ttf', 'italic': 'verdanai.ttf', 'bold_italic': 'verdanaz.ttf'}, + 'tahoma': {'normal': 'tahoma.ttf', 'bold': 'tahomabd.ttf', 'italic': 'tahoma.ttf', 'bold_italic': 'tahomabd.ttf'}, + 'trebuchet ms': {'normal': 'trebuc.ttf', 'bold': 'trebucbd.ttf', 'italic': 'trebucit.ttf', 'bold_italic': 'trebucbi.ttf'}, + 'calibri': {'normal': 'calibri.ttf', 'bold': 'calibrib.ttf', 'italic': 'calibrii.ttf', 'bold_italic': 'calibriz.ttf'}, + 'consolas': {'normal': 'consola.ttf', 'bold': 'consolab.ttf', 'italic': 'consolai.ttf', 'bold_italic': 'consolaz.ttf'}, + 'courier new': {'normal': 'cour.ttf', 'bold': 'courbd.ttf', 'italic': 'couri.ttf', 'bold_italic': 'courbi.ttf'}, + 'roboto': {'normal': 'Roboto-Regular.ttf', 'bold': 'Roboto-Bold.ttf', 'italic': 'Roboto-Italic.ttf', 'bold_italic': 'Roboto-BoldItalic.ttf'}, + } + + # Get font files for the specified family (case-insensitive) + family_lower = font_family.lower() + font_files = font_map.get(family_lower, font_map['segoe ui']) + + fonts_dir = "C:/Windows/Fonts/" + + # Use configured font size directly + pil_size = size + pil_code_size = size - 1 # Code font slightly smaller + + # Load emoji font separately (may fail on some systems) + emoji_font = None + emoji_font_paths = [ + fonts_dir + "seguiemj.ttf", # Windows 10/11 Segoe UI Emoji + fonts_dir + "seguisym.ttf", # Fallback to Segoe UI Symbol + ] + for emoji_path in emoji_font_paths: + try: + emoji_font = ImageFont.truetype(emoji_path, pil_size) + break + except: + pass + + # Load emoji fonts at different sizes for headers + emoji_fonts = {'emoji': emoji_font} + emoji_font_path = None + for path in emoji_font_paths: + try: + ImageFont.truetype(path, pil_size) + emoji_font_path = path + break + except: + pass + + if emoji_font_path: + try: + emoji_fonts['emoji_h1'] = ImageFont.truetype(emoji_font_path, pil_size + 10) + emoji_fonts['emoji_h2'] = ImageFont.truetype(emoji_font_path, pil_size + 6) + emoji_fonts['emoji_h3'] = ImageFont.truetype(emoji_font_path, pil_size + 3) + emoji_fonts['emoji_h4'] = ImageFont.truetype(emoji_font_path, pil_size + 1) + emoji_fonts['emoji_h5'] = ImageFont.truetype(emoji_font_path, pil_size) + emoji_fonts['emoji_h6'] = ImageFont.truetype(emoji_font_path, pil_size - 1) + except: + pass + + try: + self.fonts = { + 'normal': ImageFont.truetype(fonts_dir + font_files['normal'], pil_size), + 'bold': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size), + 'italic': ImageFont.truetype(fonts_dir + font_files['italic'], pil_size), + 'bold_italic': ImageFont.truetype(fonts_dir + font_files['bold_italic'], pil_size), + 'code': ImageFont.truetype(fonts_dir + "consola.ttf", pil_code_size), + # Header fonts H1-H6 with decreasing sizes + 'h1': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 10), # Largest + 'h2': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 6), + 'h3': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 3), + 'h4': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 1), + 'h5': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size), + 'h6': ImageFont.truetype(fonts_dir + font_files['bold_italic'], pil_size - 1), # Smallest, italic + 'header': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 4), # Legacy + 'emoji': emoji_font if emoji_font else ImageFont.truetype(fonts_dir + font_files['normal'], pil_size), + # Emoji fonts for headers + 'emoji_h1': emoji_fonts.get('emoji_h1', emoji_font), + 'emoji_h2': emoji_fonts.get('emoji_h2', emoji_font), + 'emoji_h3': emoji_fonts.get('emoji_h3', emoji_font), + 'emoji_h4': emoji_fonts.get('emoji_h4', emoji_font), + 'emoji_h5': emoji_fonts.get('emoji_h5', emoji_font), + 'emoji_h6': emoji_fonts.get('emoji_h6', emoji_font), + } + except Exception as e: + # Fallback: try loading font by name directly (for custom fonts) + try: + self.fonts = { + 'normal': ImageFont.truetype(font_family, pil_size), + 'bold': ImageFont.truetype(font_family, pil_size), + 'italic': ImageFont.truetype(font_family, pil_size), + 'bold_italic': ImageFont.truetype(font_family, pil_size), + 'code': ImageFont.truetype("consola.ttf", pil_code_size), + 'h1': ImageFont.truetype(font_family, pil_size + 10), + 'h2': ImageFont.truetype(font_family, pil_size + 6), + 'h3': ImageFont.truetype(font_family, pil_size + 3), + 'h4': ImageFont.truetype(font_family, pil_size + 1), + 'h5': ImageFont.truetype(font_family, pil_size), + 'h6': ImageFont.truetype(font_family, pil_size - 1), + 'header': ImageFont.truetype(font_family, pil_size + 4), + 'emoji': emoji_font if emoji_font else ImageFont.truetype(font_family, pil_size), + } + except: + # Final fallback to default + default = ImageFont.load_default() + self.fonts = {k: default for k in ['normal', 'bold', 'italic', 'bold_italic', 'code', 'header', 'emoji']} + + colors = { + 'text': self._hex_to_rgb(self.display_props.get('text_color', '#f0f0f0')), + 'accent': self._hex_to_rgb(self.display_props.get('accent_color', '#00aaff')), + 'bg': self._hex_to_rgb(self.display_props.get('bg_color', '#1e212b')) + } + color_emojis = self.display_props.get('color_emojis', True) + self.md_renderer = MarkdownRenderer(self.fonts, colors, color_emojis) + + def _get_text_size(self, text: str, font) -> Tuple[int, int]: + try: + bbox = font.getbbox(text) + return int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1]) + except: + return len(text) * 8, 16 + + def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, font, emoji_y_offset: int = 5): + """Render text with inline emoji support for titles and labels. + + Args: + draw: ImageDraw object + text: Text to render (may contain emojis) + x: X position + y: Y position + color: Text color (RGBA tuple) + font: Font to use for text + emoji_y_offset: Vertical offset for emojis (default 5 for bold titles) + """ + if not text: + return + + current_x = x + i = 0 + emoji_font = self.fonts.get('emoji', font) + + while i < len(text): + # Check for emoji at current position + emoji_len = self.md_renderer._get_emoji_length(text, i) if self.md_renderer else 0 + if emoji_len > 0: + # Render emoji with emoji font and color support + emoji_text = text[i:i+emoji_len] + if self.md_renderer and self.md_renderer.color_emojis: + draw.text((current_x, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font, embedded_color=True) + else: + draw.text((current_x, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font) + emoji_w, _ = self._get_text_size(emoji_text, emoji_font) + # Reduce emoji width - more aggressive for variation selector emojis + has_variation_selector = '\ufe0f' in emoji_text + if has_variation_selector: + current_x += int(emoji_w * 0.55) + else: + current_x += int(emoji_w * 0.85) + i += emoji_len + else: + # Find the next emoji or end of text + text_start = i + while i < len(text): + if self.md_renderer and self.md_renderer._get_emoji_length(text, i) > 0: + break + i += 1 + # Render text segment + text_segment = text[text_start:i] + if text_segment: + draw.text((current_x, y), text_segment, fill=color, font=font) + text_w, _ = self._get_text_size(text_segment, font) + current_x += text_w + + def _draw_loading(self, draw, canvas, x: int, y: int, width: int, color: Tuple): + """Modern animated loading bars with full width wave.""" + # Initialize loading phase if not exists + if not hasattr(self, '_loading_phase'): + self._loading_phase = 0.0 + + # Update phase based on time (approx 9.0 rad/s matches original 0.15/frame at 60fps) + self._loading_phase += 9.0 * self.dt + + # Use full available width (padding already handled by caller) + available_w = width + + bar_w = 4 + spacing = 4 + num_bars = int(available_w // (bar_w + spacing)) + + # Center the array of bars within the given area + total_bars_w = num_bars * (bar_w + spacing) - spacing + start_x = x + (width - total_bars_w) // 2 + + max_h = 14 + min_h = 2 + + center_y = y + 15 + + for i in range(num_bars): + # Create a gentle wave using two sine waves for organic feel + wave1 = math.sin(self._loading_phase + (i * 0.2)) + wave2 = math.sin((self._loading_phase * 0.5) - (i * 0.1)) + + normalized = (wave1 + wave2 + 2) / 4 # Normalize to 0-1 + + # Sharpen the peak + normalized = normalized ** 2 + + h = int(min_h + (normalized * (max_h - min_h))) + if h < 1: + h = 1 + + bar_x = start_x + i * (bar_w + spacing) + bar_y = int(center_y - (h / 2)) + + # Solid color without alpha + bar_color = color[:3] + (255,) + + # Draw rounded bar (pill shape) + radius = min(bar_w // 2, h // 2) + if radius < 1: + radius = 1 + + # Create small surface for the bar + bar_surf = Image.new('RGBA', (bar_w, max(1, h)), (0, 0, 0, 0)) + bar_draw = ImageDraw.Draw(bar_surf) + bar_draw.rounded_rectangle([0, 0, bar_w - 1, h - 1], radius=radius, fill=bar_color) + canvas.paste(bar_surf, (bar_x, bar_y), bar_surf) + + def _draw_main_frame(self): + if not self.current_message and not self.is_loading: + return + + # Check if redraw is needed + # We include display_props in state because it affects rendering (colors, size) and window position + # We convert display_props to a tuple of items for hashing + try: + props_hash = tuple(sorted((k, v) for k, v in self.display_props.items() if isinstance(v, (str, int, float, bool, tuple)))) + except: + props_hash = str(self.display_props) + + current_msg_id = self.current_message.get('id') if self.current_message else None + current_msg_content = self.current_message.get('message') if self.current_message else None + + # Quantize typewriter position to whole characters for state comparison + # This prevents unnecessary redraws for sub-character movements + typewriter_state = int(self.typewriter_char_count) if self.typewriter_active else -1 + + # For loading animation, use current time to ensure redraw every frame + # This keeps the animation smooth at configured FPS + loading_frame = time.time() if self.is_loading else -1 + + current_state = ( + current_msg_id, + current_msg_content, + typewriter_state, + loading_frame, + props_hash + ) + + # Skip render if state hasn't changed + if self.last_render_state == current_state and self.canvas: + return + + self.last_render_state = current_state + self.canvas_dirty = True # Mark canvas as needing blit + + props = self.display_props + bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + width = int(props.get('width', 400)) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 16)) + max_height = int(props.get('max_height', 600)) + + # Update renderer colors + if self.md_renderer: + self.md_renderer.set_colors(text_color, accent, bg) + + # Reuse temp canvas if possible + temp_h = 2000 + if self.temp_image is None or self.temp_image.width != width or self.temp_image.height < temp_h: + self.temp_image = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) + self.temp_draw = ImageDraw.Draw(self.temp_image) + else: + # Clear existing canvas + self.temp_draw.rectangle([(0, 0), (width, temp_h)], fill=(0, 0, 0, 0)) + + temp = self.temp_image + draw = self.temp_draw + + y = padding + + # Determine if we should draw message components + should_draw_message = self.current_message is not None + + # Title pill + if should_draw_message: + title = self.current_message.get('title', '') + if title: + title_color = self._hex_to_rgb(self.current_message.get('color', '#00aaff')) + font = self.fonts.get('bold', self.fonts['normal']) + + # Get text bounding box for accurate sizing + bbox = font.getbbox(title) + tw = bbox[2] - bbox[0] # width + th = bbox[3] - bbox[1] # height + + # Pill dimensions - fixed height for consistency + pill_padding_x = 14 + pill_h = 28 # Fixed pill height for consistent look + pill_w = tw + pill_padding_x * 2 + + # Modern pill with subtle shadow + shadow_offset = 2 + draw.rounded_rectangle([padding + shadow_offset, y + shadow_offset, + padding + pill_w + shadow_offset, y + pill_h + shadow_offset], + radius=pill_h//2, fill=(0, 0, 0, 40)) + draw.rounded_rectangle([padding, y, padding + pill_w, y + pill_h], + radius=pill_h//2, fill=title_color) + + # Center text using anchor='mm' (middle-middle) for true centering + center_x = padding + pill_w // 2 + center_y = y + pill_h // 2 + draw.text((center_x, center_y), title, fill=bg, font=font, anchor='mm') + y += pill_h + 10 # Spacing after title pill + + # Message content with Markdown + msg = self.current_message.get('message', '') + if msg: + msg = self._strip_emotions(msg) + max_chars = self.typewriter_char_count if self.typewriter_active else None + + # Use cached blocks if available and message hasn't changed + if self.current_blocks is None or self.current_blocks.get('msg') != msg: + self.current_blocks = { + 'msg': msg, + 'blocks': self.md_renderer.parse_blocks(msg) + } + + y = self.md_renderer.render( + draw, temp, msg, padding, y, width - padding * 2, max_chars, + pre_parsed_blocks=self.current_blocks['blocks'] + ) + + # Tool chips + tools = self.current_message.get('tools', []) + if tools: + y += 10 + tx = padding + th = 30 + + # Group by source + counts = {} + for t in tools: + key = (t.get('source', 'System'), t.get('icon')) + counts[key] = counts.get(key, 0) + 1 + + for (src, icon), cnt in counts.items(): + font = self.fonts.get('code', self.fonts['normal']) + sw, sh = self._get_text_size(src, font) + icon_w = 24 if icon and os.path.exists(str(icon)) else 0 + badge_w = 26 if cnt > 1 else 0 + chip_w = sw + icon_w + badge_w + 26 + + if tx + chip_w > width - padding: + tx = padding + y += th + 10 + + # Modern chip with gradient-like effect + chip_bg = (42, 48, 60, 235) + draw.rounded_rectangle([tx, y, tx + chip_w, y + th], radius=th//2, + fill=chip_bg, outline=accent) + + ix = tx + 12 + if icon and os.path.exists(str(icon)): + try: + if icon not in self.image_cache: + img = Image.open(icon).convert('RGBA').resize((18, 18), Image.Resampling.LANCZOS) + self.image_cache[icon] = img + temp.paste(self.image_cache[icon], (ix, y + 6), self.image_cache[icon]) + ix += 22 + except: + pass + + draw.text((ix, y + (th - sh) // 2), src, fill=text_color, font=font) + + if cnt > 1: + bw, bh = self._get_text_size(str(cnt), font) + bx = tx + chip_w - bw - 16 + draw.ellipse([bx - 4, y + 5, bx + bw + 8, y + th - 5], fill=accent) + draw.text((bx + 2, y + 7), str(cnt), fill=bg, font=font) + + tx += chip_w + 10 + + y += th + 10 + + # Loading animation + if self.is_loading: + y += 6 + self._draw_loading(draw, temp, padding, y, width - padding * 2, self.loading_color) + y += 24 + + # Calculate final height with configured bottom padding + # Add extra padding at bottom to match visual balance with top/sides + bottom_padding = padding - 4 + final_h = min(max(60, y + bottom_padding), max_height) + + # Create final canvas with background (reuse if possible) + if self.canvas is None or self.canvas.width != width or self.canvas.height != final_h: + self.canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + else: + # Clear canvas (fill with transparent or background color) + # Actually we draw a full rounded rectangle over it so clearing might not be strictly needed + # if the rounded rect covers everything, but for safety (corners): + self.canvas.paste((255, 0, 255, 255), (0, 0, width, final_h)) + + final_draw = ImageDraw.Draw(self.canvas) + + # Modern background with subtle gradient feel + final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, + fill=bg + (255,), outline=(55, 62, 74)) + + # Composite content + content_height = y + bottom_padding + crop_height = min(final_h, temp.height) + crop = temp.crop((0, 0, width, crop_height)) + + # Apply fade out if content exceeds max height + if content_height > max_height: + fade_height = 60 + if crop_height > fade_height: + # Create alpha mask + mask = Image.new('L', (width, crop_height), 255) + mask_draw = ImageDraw.Draw(mask) + + # Draw gradient at the bottom + for i in range(fade_height): + alpha = int(255 * (1 - (i / fade_height))) + line_y = crop_height - fade_height + i + mask_draw.line([(0, line_y), (width, line_y)], fill=alpha) + + # Apply mask to crop's alpha channel + r, g, b, a = crop.split() + new_alpha = ImageChops.multiply(a, mask) + crop.putalpha(new_alpha) + + self.canvas.paste(crop, (0, 0), crop) + + # Update window + if self.hwnd: + user32.MoveWindow(self.hwnd, int(props.get('x', 20)), int(props.get('y', 20)), width, final_h, True) + user32.SetLayeredWindowAttributes(self.hwnd, 0x00FF00FF, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) + + def _blit_to_window(self, hwnd, canvas, wdc, mdc, is_persistent=False): + if not hwnd or not canvas or not wdc or not mdc: + return + + w, h = canvas.size + + # Check if DIB needs resize + if is_persistent: + if w != self.dib_width_persistent or h != self.dib_height_persistent: + # Cleanup old + if self.old_bitmap_persistent: gdi32.SelectObject(mdc, self.old_bitmap_persistent) + if self.dib_bitmap_persistent: gdi32.DeleteObject(self.dib_bitmap_persistent) + # Create new + self.dib_width_persistent = w + self.dib_height_persistent = h + bmi = BITMAPINFO(); bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h + bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + self.dib_bits_persistent = ctypes.c_void_p() + self.dib_bitmap_persistent = gdi32.CreateDIBSection(mdc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(self.dib_bits_persistent), None, 0) + if self.dib_bitmap_persistent: self.old_bitmap_persistent = gdi32.SelectObject(mdc, self.dib_bitmap_persistent) + + dib_bits = self.dib_bits_persistent + else: + if w != self.dib_width or h != self.dib_height: + if self.old_bitmap: gdi32.SelectObject(mdc, self.old_bitmap) + if self.dib_bitmap: gdi32.DeleteObject(self.dib_bitmap) + self.dib_width = w + self.dib_height = h + bmi = BITMAPINFO(); bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h + bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + self.dib_bits = ctypes.c_void_p() + self.dib_bitmap = gdi32.CreateDIBSection(mdc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(self.dib_bits), None, 0) + if self.dib_bitmap: self.old_bitmap = gdi32.SelectObject(mdc, self.dib_bitmap) + + dib_bits = self.dib_bits + + if not dib_bits: + return + + try: + rgba = canvas.tobytes('raw', 'BGRA') + ctypes.memmove(dib_bits, rgba, len(rgba)) + gdi32.BitBlt(wdc, 0, 0, w, h, mdc, 0, 0, SRCCOPY) + except: + pass + + def _create_dib(self, w, h): + self.dib_width = w + self.dib_height = h + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w + bmi.bmiHeader.biHeight = -h + bmi.bmiHeader.biPlanes = 1 + bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + self.dib_bits = ctypes.c_void_p() + self.dib_bitmap = gdi32.CreateDIBSection(self.mem_dc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(self.dib_bits), None, 0) + if self.dib_bitmap: + self.old_bitmap = gdi32.SelectObject(self.mem_dc, self.dib_bitmap) + + def _cleanup_dib(self): + if self.old_bitmap: + gdi32.SelectObject(self.mem_dc, self.old_bitmap) + self.old_bitmap = None + if self.dib_bitmap: + gdi32.DeleteObject(self.dib_bitmap) + self.dib_bitmap = None + self.dib_bits = None + self.dib_width = self.dib_height = 0 + + def _cleanup_gdi(self): + self._cleanup_dib() + + # Cleanup persistent DIB + if self.old_bitmap_persistent and self.mem_dc_persistent: + gdi32.SelectObject(self.mem_dc_persistent, self.old_bitmap_persistent) + self.old_bitmap_persistent = None + if self.dib_bitmap_persistent: + gdi32.DeleteObject(self.dib_bitmap_persistent) + self.dib_bitmap_persistent = None + + if self.mem_dc: + gdi32.DeleteDC(self.mem_dc) + self.mem_dc = None + if self.mem_dc_persistent: + gdi32.DeleteDC(self.mem_dc_persistent) + self.mem_dc_persistent = None + + if self.window_dc and self.hwnd: + user32.ReleaseDC(self.hwnd, self.window_dc) + self.window_dc = None + if self.window_dc_persistent and self.hwnd_persistent: + user32.ReleaseDC(self.hwnd_persistent, self.window_dc_persistent) + self.window_dc_persistent = None + + # Cleanup chat windows + for chat_name in list(self._chat_windows.keys()): + self._cleanup_chat_window(chat_name) + + def _cleanup_chat_window(self, chat_name: str): + """Clean up resources for a specific chat window.""" + # Unregister from layout manager + self._layout_manager.unregister_window(f"chat_{chat_name}") + + # Cleanup DCs + if chat_name in self._chat_window_dcs: + window_dc, mem_dc = self._chat_window_dcs[chat_name] + hwnd = self._chat_hwnds.get(chat_name) + if mem_dc: + gdi32.DeleteDC(mem_dc) + if window_dc and hwnd: + user32.ReleaseDC(hwnd, window_dc) + del self._chat_window_dcs[chat_name] + + # Destroy window + if chat_name in self._chat_hwnds: + hwnd = self._chat_hwnds[chat_name] + if hwnd: + user32.DestroyWindow(hwnd) + del self._chat_hwnds[chat_name] + + # Clean up state + self._chat_windows.pop(chat_name, None) + self._chat_window_dirty.pop(chat_name, None) + self._chat_canvases.pop(chat_name, None) + self._chat_last_render_state.pop(chat_name, None) + + def _safe_report(self, payload): + if not self.error_queue: + return + try: + self.error_queue.put_nowait(payload) + except Exception: + pass + + def _emit_heartbeat(self): + now = time.time() + if now >= self._next_heartbeat: + self._next_heartbeat = now + 1.0 + self._safe_report({"type": "heartbeat", "ts": now}) + + def _report_exception(self, context: str, exc: Exception): + self._safe_report({ + "type": "error", + "context": context, + "error": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc(), + "ts": time.time(), + }) + + def _update_logic_main(self): + if not self.hwnd: + return + + # Typewriter progression (only affects main message) + if self.typewriter_active and self.current_message: + now = time.time() + chars = (now - self.last_typewriter_update) * 200 + if chars > 0: + self.typewriter_char_count += chars + self.last_typewriter_update = now + msg_len = len(self.current_message.get('message', '')) + if self.typewriter_char_count >= msg_len: + self.typewriter_active = False + self.typewriter_char_count = float(msg_len) + + key = 0x00FF00FF + fade_amount = int(1080 * self.dt) + if fade_amount < 1: fade_amount = 1 + + # Fade logic for main window + if self.fade_state == 1: # Fade in + self.current_opacity = min(self.target_opacity, self.current_opacity + fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) + if self.current_opacity >= self.target_opacity: + self.fade_state = 2 + + elif self.fade_state == 3: # Fade out + self.current_opacity = max(0, self.current_opacity - fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) + if self.current_opacity <= 0: + self.fade_state = 0 + self.current_message = None + + if self.fade_state == 2: + # Tracking opacity target + if self.current_opacity != self.target_opacity: + if self.current_opacity < self.target_opacity: + self.current_opacity = min(self.target_opacity, self.current_opacity + fade_amount) + else: + self.current_opacity = max(self.target_opacity, self.current_opacity - fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) + + # Auto fade out check + should_fade_out = True + + # Don't fade out if loading + if self.is_loading: + should_fade_out = False + # Check if message duration has passed + elif self.current_message: + now = time.time() + if now <= self.min_display_time: + should_fade_out = False + else: + # No message, no loading -> fade out + should_fade_out = True + + if should_fade_out: + self.fade_state = 3 + + def _check_collision(self) -> bool: + """Check if main window overlaps with persistent window.""" + if not self.current_message or not self.persistent_infos: + return False + + # Get Main Window Rect + main_x = int(self.display_props.get('x', 20)) + main_y = int(self.display_props.get('y', 20)) + main_w = int(self.display_props.get('width', 400)) + # Use existing canvas height if available, otherwise estimate or assume max + # It's safer to rely on canvas if _draw_main_frame ran at least once for this content + main_h = self.canvas.height if self.canvas else 200 + + # Get Persistent Window Rect + pers_x = int(self.display_props.get('persistent_x', 20)) + pers_y = int(self.display_props.get('persistent_y', 300)) + pers_w = int(self.display_props.get('persistent_width', 300)) + pers_h = self.canvas_persistent.height if self.canvas_persistent else 200 + + # Check intersection + # Rect1: (main_x, main_y, main_x + main_w, main_y + main_h) + # Rect2: (pers_x, pers_y, pers_x + pers_w, pers_y + pers_h) + + return not (main_x + main_w <= pers_x or + pers_x + pers_w <= main_x or + main_y + main_h <= pers_y or + pers_y + pers_h <= main_y) + + def _update_logic_persistent(self, collision_detected=False): + if not self.hwnd_persistent: + return + + now = time.time() + + # Check expiry + expired = [k for k, v in self.persistent_infos.items() if v.get('expiry') and now > v['expiry']] + for k in expired: + del self.persistent_infos[k] + + # Determine target state + has_content = bool(self.persistent_infos) + + # If collision detected, force hide irrespective of content + should_show = has_content and not collision_detected + + if should_show and self.persistent_fade_state in (0, 3): + self.persistent_fade_state = 1 # Start Fade in + elif not should_show and self.persistent_fade_state in (1, 2): + self.persistent_fade_state = 3 # Start Fade out + + key = 0x00FF00FF + fade_amount = int(1080 * self.dt) + if fade_amount < 1: fade_amount = 1 + + if self.persistent_fade_state == 1: # Fade in + self.persistent_opacity = min(self.target_opacity, self.persistent_opacity + fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) + if self.persistent_opacity >= self.target_opacity: + self.persistent_fade_state = 2 + + elif self.persistent_fade_state == 3: # Fade out + self.persistent_opacity = max(0, self.persistent_opacity - fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) + if self.persistent_opacity <= 0: + self.persistent_fade_state = 0 + + elif self.persistent_fade_state == 2: # Visible + if self.persistent_opacity != self.target_opacity: + if self.persistent_opacity < self.target_opacity: + self.persistent_opacity = min(self.target_opacity, self.persistent_opacity + fade_amount) + else: + self.persistent_opacity = max(self.target_opacity, self.persistent_opacity - fade_amount) + user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) + + def _check_window_collision(self, msg_win: Optional[Dict], pers_win: Dict) -> bool: + """Check if message window overlaps with persistent window (unified system).""" + # No collision if no message window or message not visible + if not msg_win: + return False + if not msg_win.get('current_message') and not msg_win.get('is_loading'): + return False + if msg_win.get('fade_state', 0) in (0, 3): # Hidden or fading out + return False + + # No collision if persistent window has no items + if not pers_win.get('items'): + return False + + # Get message window rect + msg_props = msg_win.get('props', {}) + msg_x = int(msg_props.get('x', 20)) + msg_y = int(msg_props.get('y', 20)) + msg_w = int(msg_props.get('width', 400)) + msg_canvas = msg_win.get('canvas') + msg_h = msg_canvas.height if msg_canvas else 200 + + # Get persistent window rect + pers_props = pers_win.get('props', {}) + pers_x = int(pers_props.get('x', pers_props.get('persistent_x', 20))) + pers_y = int(pers_props.get('y', pers_props.get('persistent_y', 300))) + pers_w = int(pers_props.get('width', pers_props.get('persistent_width', 300))) + pers_canvas = pers_win.get('canvas') + pers_h = pers_canvas.height if pers_canvas else 200 + + # Check intersection (AABB test) + return not (msg_x + msg_w <= pers_x or + pers_x + pers_w <= msg_x or + msg_y + msg_h <= pers_y or + pers_y + pers_h <= msg_y) + + def _update_persistent_fade(self, win: Dict, collision_detected: bool = False): + """Update persistent window fade based on content and collision.""" + items = win.get('items', {}) + has_content = bool(items) + + # If collision detected, force hide + should_show = has_content and not collision_detected + + fade_state = win.get('fade_state', 0) + + if should_show and fade_state in (0, 3): + win['fade_state'] = 1 # Start fade in + elif not should_show and fade_state in (1, 2): + win['fade_state'] = 3 # Start fade out + + def run(self): + try: + if not PIL_AVAILABLE: + self._report_exception("init", ImportError("PIL not available")) + return + + if self.use_stdin: + threading.Thread(target=self._read_stdin, daemon=True).start() + + if not _ensure_window_class(): + self._report_exception("init", RuntimeError("Failed to register window class")) + return + + # Initialize Main Window + w = int(self.display_props.get('width', 400)) + h = 100 + x = int(self.display_props.get('x', 20)) + y = int(self.display_props.get('y', 20)) + + self.hwnd = self._create_overlay_window("HeadsUp", x, y, w, h) + if not self.hwnd: + self._report_exception("init", RuntimeError("Failed to create main window")) + return + + self.window_dc, self.mem_dc = self._init_gdi(self.hwnd) + + # Initialize Persistent Window + pw = int(self.display_props.get('persistent_width', 300)) + ph = 100 + px = int(self.display_props.get('persistent_x', 20)) + py = int(self.display_props.get('persistent_y', 300)) + + try: + self.hwnd_persistent = self._create_overlay_window("HeadsUpPersistent", px, py, pw, ph) + if self.hwnd_persistent: + self.window_dc_persistent, self.mem_dc_persistent = self._init_gdi(self.hwnd_persistent) + except Exception as e: + self._report_exception("init_persistent", e) + # Continue without persistent window if it fails? + pass + + self._init_fonts() + + last_z = time.time() + self.last_update_time = time.time() + + # Signal successful start + self._emit_heartbeat() + + while self.running: + try: + start = time.time() + self.dt = start - self.last_update_time + self.last_update_time = start + + # Pump the Win32 message queue (handles both windows) + msg = MSG() + while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_REMOVE): + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + target_fps = self._global_framerate + frame_time = 1.0 / target_fps + + try: + while True: + msg = self.msg_queue.get_nowait() + msg_type = msg.get('type', 'unknown') if isinstance(msg, dict) else 'non-dict' + msg_group = msg.get('group', 'unknown') if isinstance(msg, dict) else 'n/a' + self._handle_message(msg) + except queue.Empty: + pass + + # ========================================================= + # UPDATE AND RENDER ALL UNIFIED WINDOWS + # ========================================================= + self._update_all_windows() + + # Update and draw chat windows + if self._chat_windows: + try: + self._update_chat_windows() + self._draw_chat_windows() + except Exception as e: + sys.stderr.write(f"Draw chat windows error: {e}\n") + + now = time.time() + if now - last_z > 0.1: + # Bring all windows to top + flags = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS + # Unified windows + for win in self._windows.values(): + if win.get('hwnd'): + user32.SetWindowPos(win['hwnd'], HWND_TOPMOST, 0, 0, 0, 0, flags) + # Chat windows + for chat_hwnd in self._chat_hwnds.values(): + if chat_hwnd: + user32.SetWindowPos(chat_hwnd, HWND_TOPMOST, 0, 0, 0, 0, flags) + last_z = now + + self._emit_heartbeat() + + elapsed = time.time() - start + if elapsed < frame_time: + time.sleep(frame_time - elapsed) + + except Exception as e: + self._report_exception("run_loop", e) + time.sleep(0.05) + + except Exception as e: + self._report_exception("run_crash", e) + finally: + # Cleanup unified windows + for name in list(self._windows.keys()): + self._destroy_window(name) + # Cleanup legacy windows + self._cleanup_gdi() + if self.hwnd: + user32.DestroyWindow(self.hwnd) + if self.hwnd_persistent: + user32.DestroyWindow(self.hwnd_persistent) + + def _handle_message(self, msg): + try: + t = msg.get('type') + + # Normalize command type names (support both modern and legacy names) + type_aliases = { + # Modern name -> handled as + 'show_message': 'draw', + 'hide_message': 'hide', + 'set_loader': 'loading', + 'add_item': 'add_persistent_info', + 'update_item': 'update_persistent_info', + 'remove_item': 'remove_persistent_info', + 'clear_items': 'clear_all_persistent_info', + 'show_timer': 'show_progress_timer', + } + t = type_aliases.get(t, t) + + # Normalize field names (support both 'content' and 'message', 'show' and 'state') + if 'content' in msg and 'message' not in msg: + msg['message'] = msg['content'] + if 'show' in msg and 'state' not in msg: + msg['state'] = msg['show'] + if 'color' in msg and 'progress_color' not in msg and t in ('show_progress', 'show_progress_timer'): + msg['progress_color'] = msg['color'] + + # Extract group name (default to 'default' for backward compatibility) + group = msg.get('group', 'default') + + # ===================================================================== + # GROUP MANAGEMENT COMMANDS + # ===================================================================== + if t == 'create_group': + props = msg.get('props', {}) + if group not in self._group_props: + self._group_props[group] = {} + self._group_props[group].update(props) + return + + elif t == 'update_group': + props = msg.get('props', {}) + if group not in self._group_props: + self._group_props[group] = {} + self._group_props[group].update(props) + + # Update existing windows for this group and force re-render + matched_count = 0 + for name, state in self._windows.items(): + if state.get('group') == group: + matched_count += 1 + old_width = state['props'].get('width') + state['props'].update(props) + new_width = state['props'].get('width') + state['target_opacity'] = int(state['props'].get('opacity', 0.85) * 255) + # Invalidate render cache to force re-render with new props + state['last_render_state'] = None + state['canvas_dirty'] = True + # Clear cached canvas if width changed (forces new canvas creation) + if 'width' in props: + state['canvas'] = None + # Update layout manager with new layout properties + layout_kwargs = {} + if 'width' in props: + layout_kwargs['width'] = int(props['width']) + if 'anchor' in props: + try: + layout_kwargs['anchor'] = Anchor(props['anchor']) + except ValueError: + pass + if 'priority' in props: + layout_kwargs['priority'] = int(props['priority']) + if layout_kwargs: + self._layout_manager.update_window(name, **layout_kwargs) + + # Re-init fonts in case font properties changed + old_size = self.fonts.get('_font_size') if self.fonts else None + old_family = self.fonts.get('_font_family') if self.fonts else None + new_size = props.get('font_size') + new_family = props.get('font_family') + if (new_size is not None and new_size != old_size) or (new_family is not None and new_family != old_family): + self._init_fonts() + # Rebuild markdown renderer with new fonts + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent_color = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + bg_color = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + colors = {'text': text_color, 'accent': accent_color, 'bg': bg_color} + color_emojis = props.get('color_emojis', True) + self.md_renderer = MarkdownRenderer(self.fonts, colors, color_emojis) + return + + elif t == 'delete_group': + self._group_props.pop(group, None) + self._destroy_group_windows(group) + return + + # ===================================================================== + # SYSTEM COMMANDS + # ===================================================================== + if t == 'quit': + self.running = False + return + + # ===================================================================== + # MESSAGE WINDOW COMMANDS + # ===================================================================== + elif t == 'hide': + # Hide message window for this group + win = self._get_window(self.WINDOW_TYPE_MESSAGE, group) + if win: + win['fade_state'] = 3 + win['current_message'] = None + win['is_loading'] = False + # Immediately notify layout manager that this window is now hidden + window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, group) + self._layout_manager.set_window_visible(window_name, False) + + elif t == 'draw': + # Get or create message window for this group + props = msg.get('props', {}) + win = self._ensure_window(self.WINDOW_TYPE_MESSAGE, group, props) + + new_msg = msg.get('message', '') + old_msg = win['current_message'].get('message', '') if win['current_message'] else '' + is_append = win['current_message'] and old_msg and new_msg.startswith(old_msg) + + if props: + # Check for font changes + old_size = win['props'].get('font_size') + old_family = win['props'].get('font_family') + + # Update window props (excluding persistent_* keys) + msg_props = {k: v for k, v in props.items() + if not k.startswith('persistent_')} + win['props'].update(msg_props) + win['target_opacity'] = int(win['props'].get('opacity', 0.85) * 255) + + new_size = win['props'].get('font_size') + new_family = win['props'].get('font_family') + + # Re-init fonts if size or family changed + if old_size != new_size or old_family != new_family: + self._init_fonts() + colors = { + 'text': self._hex_to_rgb(win['props'].get('text_color', '#f0f0f0')), + 'accent': self._hex_to_rgb(win['props'].get('accent_color', '#00aaff')), + 'bg': self._hex_to_rgb(win['props'].get('bg_color', '#1e212b')) + } + color_emojis = win['props'].get('color_emojis', True) + self.md_renderer = MarkdownRenderer(self.fonts, colors, color_emojis) + win['current_blocks'] = None + win['last_render_state'] = None + + win['current_message'] = msg + + if not is_append: + if win['props'].get('typewriter_effect', True): + win['typewriter_active'] = True + win['typewriter_char_count'] = 0 + win['last_typewriter_update'] = time.time() + else: + win['typewriter_active'] = False + win['typewriter_char_count'] = len(new_msg) + # Clear cached blocks and render state for new message + win['current_blocks'] = None + win['last_render_state'] = None + + if win['fade_state'] != 2: + win['fade_state'] = 1 + # Immediately notify layout manager that this window is now visible + window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, group) + self._layout_manager.set_window_visible(window_name, True) + + win['min_display_time'] = time.time() + win['props'].get('duration', 8.0) + + elif t == 'loading': + # Get or create message window for this group (loader can work without message) + win = self._ensure_window(self.WINDOW_TYPE_MESSAGE, group, msg.get('props', {})) + win['is_loading'] = msg.get('state', False) + if msg.get('color'): + win['loading_color'] = self._hex_to_rgb(msg['color']) + # If showing loader, ensure window is visible + if win['is_loading'] and win['fade_state'] in (0, 3): + win['fade_state'] = 1 + # Immediately notify layout manager that this window is now visible + window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, group) + self._layout_manager.set_window_visible(window_name, True) + + # ===================================================================== + # PERSISTENT WINDOW COMMANDS + # ===================================================================== + elif t == 'add_persistent_info': + title = msg.get('title') + if title: + props = msg.get('props', {}) + win = self._ensure_window(self.WINDOW_TYPE_PERSISTENT, group, props) + + now = time.time() + info = { + 'title': title, + 'description': msg.get('description', ''), + 'added_at': win['items'].get(title, {}).get('added_at', now), + '_group': group, + } + if msg.get('duration'): + info['expiry'] = now + float(msg['duration']) + win['items'][title] = info + + + elif t == 'update_persistent_info': + title = msg.get('title') + if title: + win = self._get_window(self.WINDOW_TYPE_PERSISTENT, group) + if win and title in win['items']: + info = win['items'][title] + if msg.get('description') is not None: + info['description'] = msg['description'] + if msg.get('duration') is not None: + info['expiry'] = time.time() + float(msg['duration']) + + elif t == 'show_progress': + title = msg.get('title') + if title: + props = msg.get('props', {}) + win = self._ensure_window(self.WINDOW_TYPE_PERSISTENT, group, props) + + target_current = float(msg.get('current', 0)) + target_maximum = float(msg.get('maximum', 100)) + auto_close = msg.get('auto_close', False) + now = time.time() + + # Initialize or update animation state + if title in win['progress_animations']: + anim = win['progress_animations'][title] + anim['start_value'] = anim['current'] + anim['target'] = target_current + anim['start_time'] = now + else: + win['progress_animations'][title] = { + 'current': 0.0, + 'start_value': 0.0, + 'target': target_current, + 'start_time': now, + } + + info = { + 'title': title, + 'description': msg.get('description', ''), + 'added_at': win['items'].get(title, {}).get('added_at', now), + 'is_progress': True, + 'progress_current': target_current, + 'progress_maximum': target_maximum, + 'progress_color': msg.get('progress_color'), + 'auto_close': auto_close, + 'auto_close_triggered': False, + '_group': group, + } + win['items'][title] = info + + + elif t == 'show_progress_timer': + title = msg.get('title') + if title: + props = msg.get('props', {}) + win = self._ensure_window(self.WINDOW_TYPE_PERSISTENT, group, props) + + duration = float(msg.get('duration', 10)) + auto_close = msg.get('auto_close', True) + now = time.time() + + initial_progress = float(msg.get('initial_progress', 0.0)) + timer_start_time = now - initial_progress + + start_percentage = 0.0 + if initial_progress > 0 and duration > 0: + start_percentage = min(100, (initial_progress / duration) * 100) + + win['progress_animations'][title] = { + 'current': start_percentage, + 'start_value': start_percentage, + 'target': start_percentage, + 'start_time': now, + 'is_timer': True, + 'timer_start': timer_start_time, + 'timer_duration': duration, + } + + info = { + 'title': title, + 'description': msg.get('description', ''), + 'added_at': now, + 'is_progress': True, + 'is_timer': True, + 'timer_start': timer_start_time, + 'timer_duration': duration, + 'progress_current': start_percentage, + 'progress_maximum': 100, + 'progress_color': msg.get('progress_color'), + 'auto_close': auto_close, + 'auto_close_triggered': False, + '_group': group, + } + win['items'][title] = info + + + elif t == 'update_progress': + title = msg.get('title') + if title: + win = self._get_window(self.WINDOW_TYPE_PERSISTENT, group) + if win and title in win['items']: + info = win['items'][title] + if info.get('is_progress'): + now = time.time() + target_current = float(msg.get('current', info.get('progress_current', 0))) + + if title in win['progress_animations']: + anim = win['progress_animations'][title] + anim['start_value'] = anim['current'] + anim['target'] = target_current + anim['start_time'] = now + + info['progress_current'] = target_current + if msg.get('maximum') is not None: + info['progress_maximum'] = float(msg['maximum']) + if msg.get('description') is not None: + info['description'] = msg['description'] + + + elif t == 'remove_persistent_info': + title = msg.get('title') + if title: + win = self._get_window(self.WINDOW_TYPE_PERSISTENT, group) + if win and title in win['items']: + del win['items'][title] + if title in win['progress_animations']: + del win['progress_animations'][title] + + elif t == 'clear_all_persistent_info': + win = self._get_window(self.WINDOW_TYPE_PERSISTENT, group) + if win: + win['items'].clear() + win['progress_animations'].clear() + + # ===================================================================== + # Chat Window Commands + # ===================================================================== + elif t == 'create_chat_window': + chat_name = msg.get('name') + if chat_name: + props = msg.get('props', {}) + # Set default chat window props + default_props = { + 'width': 400, 'max_height': 400, + 'bg_color': '#1e212b', 'text_color': '#f0f0f0', + 'accent_color': '#00aaff', 'opacity': 0.85, + 'border_radius': 12, 'content_padding': 12, + 'font_size': 14, 'auto_hide': False, + 'auto_hide_delay': 10.0, 'max_messages': 50, + 'sender_colors': {}, 'show_timestamps': False, + 'message_spacing': 8, 'fade_old_messages': True, + 'is_chat_window': True, + # Layout manager props (margin/spacing now global) + 'anchor': 'top_left', + 'priority': 5, # Lower than messages by default + 'layout_mode': 'auto', + } + default_props.update(props) + # Also merge top-level msg properties for backwards compatibility + for key in ['x', 'y', 'width', 'max_height', 'auto_hide', 'auto_hide_delay', + 'max_messages', 'sender_colors', 'fade_old_messages', + 'anchor', 'priority', 'layout_mode']: + if key in msg and msg[key] is not None: + default_props[key] = msg[key] + + self._chat_windows[chat_name] = { + 'messages': [], + 'props': default_props, + 'last_message_time': 0, + 'visible': True, + 'opacity': 0, + 'fade_state': 0, # hidden + } + self._chat_window_dirty[chat_name] = True + + # Create window for this chat + w = int(default_props.get('width', 400)) + h = int(default_props.get('max_height', 400)) + + # Register with layout manager + layout_mode = default_props.get('layout_mode', 'auto') + if layout_mode == 'auto': + anchor_str = default_props.get('anchor', 'top_left') + priority = int(default_props.get('priority', 5)) + + # Convert string anchor to Anchor enum + anchor_map = { + 'top_left': Anchor.TOP_LEFT, + 'top_center': Anchor.TOP_CENTER, + 'top_right': Anchor.TOP_RIGHT, + 'left_center': Anchor.LEFT_CENTER, + 'center': Anchor.CENTER, + 'right_center': Anchor.RIGHT_CENTER, + 'bottom_left': Anchor.BOTTOM_LEFT, + 'bottom_center': Anchor.BOTTOM_CENTER, + 'bottom_right': Anchor.BOTTOM_RIGHT, + } + anchor_enum = anchor_map.get(anchor_str, Anchor.TOP_LEFT) + + # Register with layout manager (uses global margin/spacing defaults) + self._layout_manager.register_window( + name=f"chat_{chat_name}", + anchor=anchor_enum, + mode=LayoutMode.AUTO, + priority=priority, + width=w, + height=h, + ) + # Get initial position from layout manager + pos = self._layout_manager.get_position(f"chat_{chat_name}") + x = pos[0] if pos else int(default_props.get('x', 20)) + y = pos[1] if pos else int(default_props.get('y', 20)) + else: + # Manual mode - use x/y directly + x = int(default_props.get('x', 20)) + y = int(default_props.get('y', 20)) + + hwnd = self._create_overlay_window(f"HeadsUpChat_{chat_name}", x, y, w, h) + if hwnd: + self._chat_hwnds[chat_name] = hwnd + window_dc, mem_dc = self._init_gdi(hwnd) + self._chat_window_dcs[chat_name] = (window_dc, mem_dc) + + elif t == 'update_chat_window': + chat_name = msg.get('name') + if chat_name and chat_name in self._chat_windows: + props = msg.get('props', {}) + self._chat_windows[chat_name]['props'].update(props) + self._chat_window_dirty[chat_name] = True + + # Update window position if changed + if 'x' in props or 'y' in props or 'width' in props or 'max_height' in props: + hwnd = self._chat_hwnds.get(chat_name) + if hwnd: + chat_props = self._chat_windows[chat_name]['props'] + x = int(chat_props.get('x', 20)) + y = int(chat_props.get('y', 20)) + w = int(chat_props.get('width', 400)) + h = int(chat_props.get('max_height', 400)) + user32.SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE) + + elif t == 'delete_chat_window': + chat_name = msg.get('name') + if chat_name: + self._cleanup_chat_window(chat_name) + + elif t == 'chat_message': + chat_name = msg.get('name') + if chat_name and chat_name in self._chat_windows: + now = time.time() + message = { + 'sender': msg.get('sender', ''), + 'text': msg.get('text', ''), + 'color': msg.get('color'), + 'timestamp': now, + } + chat = self._chat_windows[chat_name] + chat['messages'].append(message) + chat['last_message_time'] = now + + # Trim old messages if over limit + max_messages = chat['props'].get('max_messages', 50) + if len(chat['messages']) > max_messages: + chat['messages'] = chat['messages'][-max_messages:] + + # Show window if auto-hide was triggered + if chat['fade_state'] == 0 or chat['fade_state'] == 3: + chat['fade_state'] = 1 # fade in + chat['visible'] = True + # Immediately notify layout manager + self._layout_manager.set_window_visible(f"chat_{chat_name}", True) + + self._chat_window_dirty[chat_name] = True + + elif t == 'clear_chat_window': + chat_name = msg.get('name') + if chat_name and chat_name in self._chat_windows: + self._chat_windows[chat_name]['messages'] = [] + self._chat_window_dirty[chat_name] = True + + elif t == 'show_chat_window': + chat_name = msg.get('name') + if chat_name and chat_name in self._chat_windows: + chat = self._chat_windows[chat_name] + chat['visible'] = True + chat['fade_state'] = 1 # fade in + # Immediately notify layout manager + self._layout_manager.set_window_visible(f"chat_{chat_name}", True) + self._chat_window_dirty[chat_name] = True + + elif t == 'hide_chat_window': + chat_name = msg.get('name') + if chat_name and chat_name in self._chat_windows: + chat = self._chat_windows[chat_name] + chat['fade_state'] = 3 # fade out + # Immediately notify layout manager + self._layout_manager.set_window_visible(f"chat_{chat_name}", False) + self._chat_window_dirty[chat_name] = True + + except Exception as e: + self._report_exception("handle_message", e) + + def _create_overlay_window(self, name, x, y, w, h): + ex = WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE + hwnd = user32.CreateWindowExW(ex, _class_name, name, WS_POPUP, x, y, w, h, + None, None, kernel32.GetModuleHandleW(None), None) + if hwnd: + user32.SetLayeredWindowAttributes(hwnd, 0x00FF00FF, 0, LWA_ALPHA | LWA_COLORKEY) + user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE) + user32.SetWindowPos(hwnd, HWND_TOPMOST, x, y, w, h, SWP_NOACTIVATE | SWP_SHOWWINDOW) + return hwnd + + def _init_gdi(self, hwnd): + if not hwnd: return None, None + window_dc = user32.GetDC(hwnd) + mem_dc = gdi32.CreateCompatibleDC(window_dc) + return window_dc, mem_dc + + def _draw_progress_bar(self, draw: ImageDraw.Draw, img: Image.Image, x: int, y: int, + width: int, height: int, percentage: float, + bg_color: Tuple[int, int, int], + fill_color: Tuple[int, int, int], + text_color: Tuple[int, int, int]) -> int: + """ + Draw a modern, sleek progress bar with antialiasing via supersampling. + + Uses 2x supersampling for smooth edges on rounded corners. + + Args: + draw: ImageDraw object + img: The PIL Image to draw on (for advanced effects) + x: X position + y: Y position + width: Width of the progress bar + height: Height of the progress bar + percentage: Progress percentage (0-100) + bg_color: Background track color + fill_color: Progress fill color (accent color) + text_color: Text color for percentage + + Returns: + The Y position after the progress bar (for layout) + """ + percentage = max(0, min(100, percentage)) + + # Supersampling scale factor for antialiasing + scale = 2 + + # Create high-resolution buffer for the progress bar + bar_buffer = Image.new('RGBA', (width * scale, height * scale), (0, 0, 0, 0)) + bar_draw = ImageDraw.Draw(bar_buffer) + + scaled_height = height * scale + scaled_width = width * scale + radius = scaled_height // 2 # Fully rounded ends at scaled size + + # Draw background track + track_color = tuple(max(0, c - 30) for c in bg_color) + bar_draw.rounded_rectangle( + [0, 0, scaled_width - 1, scaled_height - 1], + radius=radius, + fill=track_color + (255,), + outline=tuple(max(0, c - 50) for c in bg_color) + (150,) + ) + + # Calculate fill width at scaled size + fill_width = int((scaled_width - 2 * scale) * percentage / 100) + + if fill_width > radius: # Only draw if there's meaningful progress + fill_x = scale + fill_y = scale + fill_h = scaled_height - 2 * scale + inner_radius = max(1, radius - scale) + + # Draw the main fill + bar_draw.rounded_rectangle( + [fill_x, fill_y, fill_x + fill_width, fill_y + fill_h], + radius=inner_radius, + fill=fill_color + (255,) + ) + + # Create gradient overlay for depth effect + gradient_overlay = Image.new('RGBA', (fill_width + 1, fill_h + 1), (0, 0, 0, 0)) + gradient_draw = ImageDraw.Draw(gradient_overlay) + + # Top highlight (lighter) + highlight_height = fill_h // 3 + for i in range(highlight_height): + alpha = int(60 * (1 - i / highlight_height)) + highlight_color = (255, 255, 255, alpha) + gradient_draw.line([(0, i), (fill_width, i)], fill=highlight_color) + + # Bottom shadow (darker) + shadow_height = fill_h // 4 + for i in range(shadow_height): + alpha = int(40 * (i / shadow_height)) + shadow_color = (0, 0, 0, alpha) + gradient_draw.line([(0, fill_h - shadow_height + i), (fill_width, fill_h - shadow_height + i)], fill=shadow_color) + + # Create a mask from the fill shape to apply gradient only within the bar + mask = Image.new('L', (scaled_width, scaled_height), 0) + mask_draw = ImageDraw.Draw(mask) + mask_draw.rounded_rectangle( + [fill_x, fill_y, fill_x + fill_width, fill_y + fill_h], + radius=inner_radius, + fill=255 + ) + + # Composite the gradient onto the bar buffer + gradient_full = Image.new('RGBA', (scaled_width, scaled_height), (0, 0, 0, 0)) + gradient_full.paste(gradient_overlay, (fill_x, fill_y)) + bar_buffer = Image.composite( + Image.alpha_composite(bar_buffer, gradient_full), + bar_buffer, + mask + ) + + # Add a subtle inner glow/shine at the top edge + shine_buffer = Image.new('RGBA', (scaled_width, scaled_height), (0, 0, 0, 0)) + shine_draw = ImageDraw.Draw(shine_buffer) + + # Draw a thin highlight line at the top of the fill + shine_y = fill_y + scale + shine_start = fill_x + inner_radius + shine_end = fill_x + fill_width - inner_radius + if shine_end > shine_start: + shine_color = tuple(min(255, c + 80) for c in fill_color) + (120,) + shine_draw.line([(shine_start, shine_y), (shine_end, shine_y)], fill=shine_color, width=scale) + shine_draw.line([(shine_start, shine_y + scale), (shine_end, shine_y + scale)], + fill=tuple(min(255, c + 40) for c in fill_color) + (60,), width=scale) + + bar_buffer = Image.alpha_composite(bar_buffer, shine_buffer) + + # Downsample with high-quality resampling (antialiasing) + bar_final = bar_buffer.resize((width, height), Image.Resampling.LANCZOS) + + # Paste the antialiased progress bar onto the main image + img.paste(bar_final, (x, y), bar_final) + + return y + height + 2 # Return next Y position with minimal spacing + + def _read_stdin(self): + while self.running: + try: + line = sys.stdin.readline() + if not line: + break + try: + msg = json.loads(line) + self.msg_queue.put(msg) + except: + pass + except: + break + + def _draw_persistent_frame(self): + if not self.persistent_infos: + return + + # Check render state + try: + props_hash = tuple(sorted((k, v) for k, v in self.display_props.items() if isinstance(v, (str, int, float, bool, tuple)))) + except: + props_hash = str(self.display_props) + + # Synchronize ALL timer updates to the same tick + now = time.time() + current_second = int(now) + sync_time = float(current_second) + self._persistent_render_time = sync_time + + # Update progress animations and check if any are active + animations_active = False + items_to_remove = [] + + for title, anim in list(self._progress_animations.items()): + if title not in self.persistent_infos: + continue + + info = self.persistent_infos[title] + + # Handle timer-based progress bars + if anim.get('is_timer'): + timer_elapsed = now - anim['timer_start'] + timer_duration = anim['timer_duration'] + timer_progress = min(100, (timer_elapsed / timer_duration) * 100) + + # Update both animation current and target for timer + anim['current'] = timer_progress + anim['target'] = timer_progress + info['progress_current'] = timer_progress + + if timer_elapsed < timer_duration: + # Optimization: For timers > 100s, changes are <1% per second + # No need for smooth animation, just update once per second + if timer_duration <= 100: + animations_active = True # Short timers get smooth animation + elif info.get('auto_close') and not info.get('auto_close_triggered'): + # Timer completed, schedule auto-close + info['auto_close_triggered'] = True + info['auto_close_time'] = now + 2.0 # 2 second delay + animations_active = True # Keep animating for auto-close + else: + # Regular progress bar animation + elapsed = now - anim.get('start_time', now) + duration = self._progress_transition_duration + + if duration > 0 and elapsed < duration: + # Easing function (ease-out cubic) + t = elapsed / duration + t = 1 - (1 - t) ** 3 + anim['current'] = anim['start_value'] + (anim['target'] - anim['start_value']) * t + animations_active = True + else: + anim['current'] = anim['target'] + + # Check for auto-close on 100% + percentage = (anim['current'] / info.get('progress_maximum', 100)) * 100 + if percentage >= 100 and info.get('auto_close') and not info.get('auto_close_triggered'): + info['auto_close_triggered'] = True + info['auto_close_time'] = now + 2.0 # 2 second delay + animations_active = True + + # Handle auto-close removal + if info.get('auto_close_triggered') and info.get('auto_close_time'): + if now >= info['auto_close_time']: + items_to_remove.append(title) + else: + animations_active = True + + # Remove items scheduled for auto-close + for title in items_to_remove: + if title in self.persistent_infos: + del self.persistent_infos[title] + if title in self._progress_animations: + del self._progress_animations[title] + + persistent_state_list = [] + has_active_timers = False + for k, v in sorted(self.persistent_infos.items()): + if v.get('expiry'): + rem = max(0, int(v['expiry'] - sync_time + 0.999)) + else: + rem = -1 + progress_state = None + if v.get('is_progress'): + # Check if this is an active timer + if v.get('is_timer'): + timer_elapsed = now - v.get('timer_start', now) + if timer_elapsed < v.get('timer_duration', 0): + has_active_timers = True + # Use animated value for state hash if animating + if k in self._progress_animations: + animated_value = self._progress_animations[k]['current'] + progress_state = (animated_value, v.get('progress_maximum', 100)) + else: + progress_state = (v.get('progress_current', 0), v.get('progress_maximum', 100)) + persistent_state_list.append((k, v['description'], rem, progress_state)) + persistent_state = tuple(persistent_state_list) + + # Determine render frequency based on what's active: + # - Smooth animations (progress bar transitions) need configured framerate + # - Timers only need 1fps (once per second) since we show whole seconds only + configured_fps = int(self.display_props.get('framerate', 60)) + if animations_active: + # Smooth transitions need configured framerate + anim_frame = int(now * configured_fps) + needs_continuous_render = True + elif has_active_timers: + # Timers only need to update once per second (whole seconds display) + anim_frame = current_second + needs_continuous_render = True + else: + anim_frame = 0 + needs_continuous_render = False + + current_state = (persistent_state, props_hash, current_second, anim_frame) + + # Skip re-render if state unchanged + if not needs_continuous_render and self.last_render_state_persistent == current_state and self.canvas_persistent: + return + + self.last_render_state_persistent = current_state + self.canvas_persistent_dirty = True + + props = self.display_props + bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + + width = int(props.get('persistent_width', 300)) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 16)) + + # Update renderer colors + if self.md_renderer: + self.md_renderer.set_colors(text_color, accent, bg) + + # We need a temp canvas + temp_h = 2000 + temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(temp) + + y = padding + font_bold = self.fonts.get('bold', self.fonts['normal']) + font_normal = self.fonts['normal'] + + for title, info in sorted(self.persistent_infos.items(), key=lambda x: x[1]['added_at']): + # Check expiry but don't delete (logic does that) + if info.get('expiry') and self._persistent_render_time > info['expiry']: + continue + + # Calculate timer width/draw timer for expiry OR progress timer + timer_w = 0 + timer_text = None + + # For progress items with timer, calculate remaining time + if info.get('is_progress') and info.get('is_timer'): + timer_start = info.get('timer_start', self._persistent_render_time) + timer_duration = info.get('timer_duration', 0) + elapsed_time = self._persistent_render_time - timer_start + remaining_seconds = max(0, timer_duration - elapsed_time) + remaining = int(remaining_seconds + 0.999) + + r = remaining + d = r // 86400 + r %= 86400 + h = r // 3600 + r %= 3600 + m = r // 60 + s = r % 60 + + parts = [] + if d > 0: parts.append(f"{d}d") + if h > 0: parts.append(f"{h}h") + if m > 0: parts.append(f"{m}m") + parts.append(f"{s}s") + + timer_text = " ".join(parts) + elif info.get('expiry'): + remaining = max(0, int(info['expiry'] - self._persistent_render_time + 0.999)) + r = remaining + d = r // 86400 + r %= 86400 + h = r // 3600 + r %= 3600 + m = r // 60 + s = r % 60 + + parts = [] + if d > 0: parts.append(f"{d}d") + if h > 0: parts.append(f"{h}h") + if m > 0: parts.append(f"{m}m") + parts.append(f"{s}s") + + timer_text = " ".join(parts) + + if timer_text: + timer_w, _ = self._get_text_size(timer_text, font_bold) + draw.text((width - padding - timer_w, y), timer_text, fill=text_color + (255,), font=font_bold) + timer_w += 10 + + # Title Row - render with emoji support + max_title_w = width - (padding * 2) - timer_w + title_lines = title.split('\n') + final_lines = [] + for line in title_lines: + if self.md_renderer: + wrapped = self.md_renderer._wrap_text(line, font_bold, max_title_w) + final_lines.extend(wrapped) + else: + final_lines.append(line) + + for i, line in enumerate(final_lines): + # Render title with emoji support + self._render_text_with_emoji(draw, line, padding, y, accent + (255,), font_bold) + y += 20 + + if final_lines: + y += 8 + else: + y += 20 + + # Check if this is a progress bar item + if info.get('is_progress'): + progress_maximum = float(info.get('progress_maximum', 100)) + if title in self._progress_animations: + progress_current = self._progress_animations[title]['current'] + else: + progress_current = float(info.get('progress_current', 0)) + + if progress_maximum <= 0: + progress_maximum = 100 + + percentage = min(100, max(0, (progress_current / progress_maximum) * 100)) + progress_color = accent + if info.get('progress_color'): + progress_color = self._hex_to_rgb(info['progress_color']) + + bar_height = 16 + bar_width = width - (padding * 2) + + y += 4 # Margin above progress bar + y = self._draw_progress_bar( + draw, temp, padding, y, bar_width, bar_height, + percentage, bg, progress_color, text_color + ) + + # Draw percentage below progress bar + values_text = f"{percentage:.0f}%" + + values_font = self.fonts.get('normal', font_normal) + try: + bbox = values_font.getbbox(values_text) + values_width = bbox[2] - bbox[0] + except: + values_width = len(values_text) * 7 + + values_x = padding + (bar_width - values_width) // 2 + values_color = tuple(c - 40 if c > 40 else c for c in text_color) + (200,) + draw.text((values_x, y), values_text, fill=values_color, font=values_font) + y += 18 + + desc = info.get('description', '') + if desc: + desc = self._strip_emotions(desc) + y += 4 + y = self.md_renderer.render( + draw, temp, desc, padding, y, width - padding * 2 + ) + else: + desc = info.get('description', '') + if desc: + desc = self._strip_emotions(desc) + y = self.md_renderer.render( + draw, temp, desc, padding, y, width - padding * 2 + ) + + # Add spacing after item + y += 8 + + # Finish + bottom_padding = padding - 4 + final_h = max(60, y + bottom_padding) + + if self.canvas_persistent is None or self.canvas_persistent.width != width or self.canvas_persistent.height != final_h: + self.canvas_persistent = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + else: + self.canvas_persistent.paste((255, 0, 255, 255), (0, 0, width, final_h)) + + final_draw = ImageDraw.Draw(self.canvas_persistent) + final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, + fill=bg + (255,), outline=(55, 62, 74)) + + crop = temp.crop((0, 0, width, final_h)) + self.canvas_persistent.paste(crop, (0, 0), crop) + + if self.hwnd_persistent: + user32.MoveWindow(self.hwnd_persistent, + int(props.get('persistent_x', 20)), + int(props.get('persistent_y', 300)), + width, final_h, True) + user32.SetLayeredWindowAttributes(self.hwnd_persistent, 0x00FF00FF, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) + + # ========================================================================= + # Chat Window Rendering + # ========================================================================= + + def _update_chat_windows(self): + """Update all chat windows (fade logic and auto-hide).""" + now = time.time() + fade_speed = 600 # opacity units per second + + for chat_name, chat in list(self._chat_windows.items()): + props = chat['props'] + auto_hide = props.get('auto_hide', False) + auto_hide_delay = props.get('auto_hide_delay', 10.0) + old_fade_state = chat['fade_state'] + + # Check auto-hide + if auto_hide and chat['messages'] and chat['fade_state'] == 2: + if now - chat['last_message_time'] > auto_hide_delay: + chat['fade_state'] = 3 # Start fade out + + # Handle fade states + if chat['fade_state'] == 1: # Fade in + chat['opacity'] = min(255, chat['opacity'] + fade_speed * self.dt) + if chat['opacity'] >= 255: + chat['opacity'] = 255 + chat['fade_state'] = 2 # Visible + self._chat_window_dirty[chat_name] = True + + elif chat['fade_state'] == 3: # Fade out + chat['opacity'] = max(0, chat['opacity'] - fade_speed * self.dt) + if chat['opacity'] <= 0: + chat['opacity'] = 0 + chat['fade_state'] = 0 # Hidden + chat['visible'] = False + self._chat_window_dirty[chat_name] = True + + # Update layout manager visibility when fade state changes + if old_fade_state != chat['fade_state']: + layout_name = f"chat_{chat_name}" + is_visible = chat['fade_state'] in (1, 2) # Visible when fading in or fully visible + self._layout_manager.set_window_visible(layout_name, is_visible) + + # Update position from layout manager for visible windows + if chat['fade_state'] in (1, 2): + layout_mode = props.get('layout_mode', 'auto') + if layout_mode == 'auto': + layout_name = f"chat_{chat_name}" + pos = self._layout_manager.get_position(layout_name) + if pos: + hwnd = self._chat_hwnds.get(chat_name) + if hwnd: + x, y = pos + w = int(props.get('width', 400)) + h = int(props.get('max_height', 400)) + # Check if position changed + old_x = chat.get('_last_x', -1) + old_y = chat.get('_last_y', -1) + if x != old_x or y != old_y: + user32.MoveWindow(hwnd, x, y, w, h, True) + chat['_last_x'] = x + chat['_last_y'] = y + self._chat_window_dirty[chat_name] = True + + def _draw_chat_windows(self): + """Draw all visible chat windows.""" + for chat_name, chat in self._chat_windows.items(): + if chat['opacity'] <= 0: + continue + + try: + self._draw_chat_frame(chat_name, chat) + except Exception as e: + sys.stderr.write(f"Draw chat window error: {e}\n") + + def _draw_chat_frame(self, chat_name: str, chat: Dict): + """Draw a single chat window frame with full markdown support.""" + props = chat['props'] + messages = chat['messages'] + + # Build state hash for caching + msg_state = tuple((m['sender'], m['text'], m.get('color')) for m in messages[-50:]) + props_hash = ( + props.get('width'), props.get('max_height'), + props.get('bg_color'), props.get('text_color'), + props.get('accent_color'), props.get('font_size'), + props.get('message_spacing'), props.get('fade_old_messages'), + ) + current_state = (msg_state, props_hash, int(chat['opacity'])) + + # Skip redraw if unchanged + if chat_name in self._chat_last_render_state: + if self._chat_last_render_state[chat_name] == current_state: + if chat_name in self._chat_canvases and not self._chat_window_dirty.get(chat_name, False): + # Just update opacity and position + hwnd = self._chat_hwnds.get(chat_name) + if hwnd: + user32.SetLayeredWindowAttributes( + hwnd, 0x00FF00FF, int(chat['opacity']), LWA_ALPHA | LWA_COLORKEY + ) + # Also update position from layout manager + layout_mode = props.get('layout_mode', 'auto') + if layout_mode == 'auto': + layout_name = f"chat_{chat_name}" + pos = self._layout_manager.get_position(layout_name) + if pos: + canvas = self._chat_canvases[chat_name] + w, h = canvas.size + x, y = pos + old_x = chat.get('_last_x', -1) + old_y = chat.get('_last_y', -1) + if x != old_x or y != old_y: + user32.MoveWindow(hwnd, x, y, w, h, True) + chat['_last_x'] = x + chat['_last_y'] = y + return + + self._chat_last_render_state[chat_name] = current_state + self._chat_window_dirty[chat_name] = True + + # Extract props + width = int(props.get('width', 400)) + max_height = int(props.get('max_height', 400)) + bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 12)) + message_spacing = int(props.get('message_spacing', 8)) + fade_old = props.get('fade_old_messages', True) + sender_colors = props.get('sender_colors', {}) + + # Get fonts + font_bold = self.fonts.get('bold', self.fonts['normal']) + font_normal = self.fonts['normal'] + + # Update markdown renderer colors for this chat + if self.md_renderer: + self.md_renderer.set_colors(text_color, accent, bg) + + # Render messages to temp canvas + temp_h = max(2000, max_height * 3) + temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(temp) + + content_width = width - (padding * 2) + y = padding + + for msg in messages: + sender = msg.get('sender', '') + text = msg.get('text', '') + msg_color = msg.get('color') + + # Determine sender color + if msg_color: + sender_color = self._hex_to_rgb(msg_color) + elif sender in sender_colors: + sender_color = self._hex_to_rgb(sender_colors[sender]) + else: + sender_color = accent + + # Draw sender name with emoji support + sender_display = sender + ":" + self._render_text_with_emoji(draw, sender_display, padding, y, sender_color + (255,), font_bold, emoji_y_offset=3) + y += 20 + + # Render message text with full markdown support + if self.md_renderer and text.strip(): + # Use the markdown renderer for full formatting + y = self.md_renderer.render(draw, temp, text, padding, y, content_width) + else: + # Fallback: simple text + draw.text((padding, y), text, fill=text_color + (255,), font=font_normal) + y += 20 + + y += message_spacing + + # Calculate final height + total_content_height = y + padding + final_h = min(total_content_height, max_height) + fade_zone = 60 # pixels at top that fade out + + # Create final canvas + canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + canvas_draw = ImageDraw.Draw(canvas) + + # Draw background + canvas_draw.rounded_rectangle( + [0, 0, width - 1, final_h - 1], + radius=radius, + fill=bg + (255,), + outline=(55, 62, 74) + ) + + # If content overflows, show from bottom (newest messages visible) + if total_content_height > max_height: + # Crop from bottom of temp + crop_y = y + padding - final_h + if crop_y < 0: + crop_y = 0 + crop = temp.crop((0, crop_y, width, crop_y + final_h)) + + # Apply fade gradient at top if enabled + if fade_old and crop_y > 0: + # Create gradient mask for fading old content at top + gradient = Image.new('L', (width, final_h), 255) + gradient_draw = ImageDraw.Draw(gradient) + + for gy in range(fade_zone): + alpha = int(255 * (gy / fade_zone)) + gradient_draw.line([(0, gy), (width, gy)], fill=alpha) + + # Apply gradient to crop alpha + crop_rgba = crop.split() + if len(crop_rgba) == 4: + r, g, b, a = crop_rgba + # Multiply alpha by gradient + from PIL import ImageChops + new_alpha = ImageChops.multiply(a, gradient) + crop.putalpha(new_alpha) + + canvas.paste(crop, (0, 0), crop) + else: + # Content fits, just paste + crop = temp.crop((0, 0, width, final_h)) + canvas.paste(crop, (0, 0), crop) + + self._chat_canvases[chat_name] = canvas + + # Update layout manager with actual rendered height + layout_name = f"chat_{chat_name}" + self._layout_manager.update_window_height(layout_name, final_h) + + # Blit to window + hwnd = self._chat_hwnds.get(chat_name) + if hwnd and chat_name in self._chat_window_dcs: + window_dc, mem_dc = self._chat_window_dcs[chat_name] + + # Get position from layout manager if in auto mode + layout_mode = props.get('layout_mode', 'auto') + if layout_mode == 'auto': + pos = self._layout_manager.get_position(layout_name) + if pos: + x, y_pos = pos + else: + x = int(props.get('x', 20)) + y_pos = int(props.get('y', 20)) + else: + x = int(props.get('x', 20)) + y_pos = int(props.get('y', 20)) + + user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) + + # Blit + self._blit_to_window_chat(hwnd, canvas, window_dc, mem_dc, chat_name) + + # Set opacity + user32.SetLayeredWindowAttributes( + hwnd, 0x00FF00FF, int(chat['opacity']), LWA_ALPHA | LWA_COLORKEY + ) + + self._chat_window_dirty[chat_name] = False + + def _blit_to_window_chat(self, hwnd, canvas, window_dc, mem_dc, chat_name: str): + """Blit a chat canvas to its window using DIB.""" + if not canvas or not hwnd: + return + + w, h = canvas.size + + # Create DIB for this blit + bmi = BITMAPINFO() + bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + bmi.bmiHeader.biWidth = w + bmi.bmiHeader.biHeight = -h # Top-down + bmi.bmiHeader.biPlanes = 1 + bmi.bmiHeader.biBitCount = 32 + bmi.bmiHeader.biCompression = BI_RGB + + dib_bits = ctypes.c_void_p() + dib_bitmap = gdi32.CreateDIBSection( + mem_dc, ctypes.byref(bmi), DIB_RGB_COLORS, + ctypes.byref(dib_bits), None, 0 + ) + + if not dib_bitmap or not dib_bits: + return + + old_bitmap = gdi32.SelectObject(mem_dc, dib_bitmap) + + try: + # Copy pixel data + raw = canvas.tobytes("raw", "BGRA") + ctypes.memmove(dib_bits, raw, len(raw)) + + # Blit to window + gdi32.BitBlt(window_dc, 0, 0, w, h, mem_dc, 0, 0, SRCCOPY) + + finally: + gdi32.SelectObject(mem_dc, old_bitmap) + gdi32.DeleteObject(dib_bitmap) + + +def run_overlay_in_subprocess(command_queue, error_queue=None): + """Entry point for running the overlay in a subprocess. + + Args: + command_queue: A multiprocessing.Queue for receiving commands. + error_queue: Optional queue for reporting errors back to the parent process. + """ + try: + overlay = HeadsUpOverlay(command_queue=command_queue, error_queue=error_queue) + overlay.run() + except Exception as e: + if error_queue: + import traceback + error_queue.put(f"Subprocess crashed: {type(e).__name__}: {e}\n{traceback.format_exc()}") + raise + + +if __name__ == "__main__": + # Allow running standalone for testing + overlay = HeadsUpOverlay() + overlay.run() + diff --git a/hud_server/platform/__init__.py b/hud_server/platform/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/hud_server/platform/__init__.py @@ -0,0 +1 @@ + diff --git a/hud_server/platform/win32.py b/hud_server/platform/win32.py new file mode 100644 index 000000000..7417f181b --- /dev/null +++ b/hud_server/platform/win32.py @@ -0,0 +1,132 @@ +import ctypes +from ctypes import wintypes + +# Windows API Constants +GWL_EXSTYLE = -20 +WS_POPUP = 0x80000000 +WS_EX_LAYERED = 0x80000 +WS_EX_TRANSPARENT = 0x20 +WS_EX_TOPMOST = 0x00000008 +WS_EX_TOOLWINDOW = 0x00000080 +WS_EX_NOACTIVATE = 0x08000000 +LWA_ALPHA = 0x00000002 +LWA_COLORKEY = 0x00000001 +SWP_NOSIZE = 0x0001 +SWP_NOMOVE = 0x0002 +SWP_SHOWWINDOW = 0x0040 +SWP_NOACTIVATE = 0x0010 +SWP_ASYNCWINDOWPOS = 0x4000 +SRCCOPY = 0x00CC0020 +DIB_RGB_COLORS = 0 +BI_RGB = 0 + +# Function pointers +# Use fresh WinDLL instances to isolate argtypes from other modules sharing windll +user32 = ctypes.WinDLL("user32.dll", use_last_error=True) +gdi32 = ctypes.WinDLL("gdi32.dll", use_last_error=True) +kernel32 = ctypes.WinDLL("kernel32.dll", use_last_error=True) + +SW_SHOWNOACTIVATE = 4 +HWND_TOPMOST = wintypes.HWND(-1) + +# Use platform-appropriate types for WPARAM and LPARAM (64-bit on x64) +if ctypes.sizeof(ctypes.c_void_p) == 8: + WPARAM = ctypes.c_uint64 + LPARAM = ctypes.c_int64 + LRESULT = ctypes.c_int64 +else: + WPARAM = ctypes.c_uint + LPARAM = ctypes.c_long + LRESULT = ctypes.c_long + +WNDPROC = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, ctypes.c_uint, WPARAM, LPARAM) + +class WNDCLASSEXW(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), ("style", ctypes.c_uint), ("lpfnWndProc", WNDPROC), + ("cbClsExtra", ctypes.c_int), ("cbWndExtra", ctypes.c_int), ("hInstance", wintypes.HINSTANCE), + ("hIcon", wintypes.HICON), ("hCursor", wintypes.HICON), ("hbrBackground", wintypes.HBRUSH), + ("lpszMenuName", wintypes.LPCWSTR), ("lpszClassName", wintypes.LPCWSTR), ("hIconSm", wintypes.HICON), + ] + +class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [ + ('biSize', wintypes.DWORD), ('biWidth', wintypes.LONG), ('biHeight', wintypes.LONG), + ('biPlanes', wintypes.WORD), ('biBitCount', wintypes.WORD), ('biCompression', wintypes.DWORD), + ('biSizeImage', wintypes.DWORD), ('biXPelsPerMeter', wintypes.LONG), ('biYPelsPerMeter', wintypes.LONG), + ('biClrUsed', wintypes.DWORD), ('biClrImportant', wintypes.DWORD), + ] + +class RGBQUAD(ctypes.Structure): + _fields_ = [ + ('rgbBlue', ctypes.c_byte), + ('rgbGreen', ctypes.c_byte), + ('rgbRed', ctypes.c_byte), + ('rgbReserved', ctypes.c_byte) + ] + +class BITMAPINFO(ctypes.Structure): + _fields_ = [('bmiHeader', BITMAPINFOHEADER), ('bmiColors', wintypes.DWORD * 3)] + +# Setup Function Prototypes +user32.DefWindowProcW.argtypes = [wintypes.HWND, ctypes.c_uint, WPARAM, LPARAM] +user32.DefWindowProcW.restype = LRESULT +user32.SetLayeredWindowAttributes.argtypes = [wintypes.HWND, wintypes.COLORREF, wintypes.BYTE, wintypes.DWORD] +user32.SetLayeredWindowAttributes.restype = wintypes.BOOL +user32.UpdateLayeredWindow.argtypes = [ + wintypes.HWND, wintypes.HDC, ctypes.POINTER(wintypes.POINT), + ctypes.POINTER(wintypes.SIZE), wintypes.HDC, ctypes.POINTER(wintypes.POINT), + wintypes.COLORREF, ctypes.POINTER(RGBQUAD), wintypes.DWORD +] + +# Basic Win32 message structures for a non-blocking pump +class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + +class MSG(ctypes.Structure): + _fields_ = [ + ("hwnd", wintypes.HWND), ("message", ctypes.c_uint), ("wParam", WPARAM), ("lParam", LPARAM), + ("time", wintypes.DWORD), ("pt", POINT) + ] + +# WinAPI signatures we need for message pumping +# Use c_void_p for MSG pointers to avoid strict type checking issues with byref() +user32.PeekMessageW.argtypes = [ctypes.c_void_p, wintypes.HWND, wintypes.UINT, wintypes.UINT, wintypes.UINT] +user32.PeekMessageW.restype = wintypes.BOOL +user32.TranslateMessage.argtypes = [ctypes.c_void_p] +user32.TranslateMessage.restype = wintypes.BOOL +user32.DispatchMessageW.argtypes = [ctypes.c_void_p] +user32.DispatchMessageW.restype = LRESULT + +PM_REMOVE = 0x0001 + +def _wnd_proc(hwnd, msg, wparam, lparam): + """Window procedure callback - must handle all message types safely.""" + try: + return user32.DefWindowProcW(hwnd, msg, WPARAM(wparam), LPARAM(lparam)) + except: + return 0 + +_wnd_proc_callback = WNDPROC(_wnd_proc) +_class_registered = False +_class_name = "WingmanHeadsUpOverlay" + +def _ensure_window_class(): + global _class_registered + if _class_registered: + return True + hInstance = kernel32.GetModuleHandleW(None) + wc = WNDCLASSEXW() + wc.cbSize = ctypes.sizeof(WNDCLASSEXW) + wc.lpfnWndProc = _wnd_proc_callback + wc.hInstance = hInstance + wc.lpszClassName = _class_name + if user32.RegisterClassExW(ctypes.byref(wc)): + _class_registered = True + return True + return False + +# Common helpers +def force_on_top(hwnd): + user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) diff --git a/hud_server/rendering/__init__.py b/hud_server/rendering/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/hud_server/rendering/__init__.py @@ -0,0 +1 @@ + diff --git a/hud_server/rendering/markdown.py b/hud_server/rendering/markdown.py new file mode 100644 index 000000000..3c84eeffe --- /dev/null +++ b/hud_server/rendering/markdown.py @@ -0,0 +1,2330 @@ +""" +HeadsUp Overlay - PIL-based implementation with sophisticated Markdown rendering + +This implementation uses ONLY: +- PIL (Pillow) for rendering (text, shapes, images) +- Win32 API for window management +""" + +import os +import re +from typing import Tuple, Dict, List +import io +import urllib.request +import urllib.error + +# PIL for rendering +try: + from PIL import Image, ImageDraw, ImageFont, ImageChops + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + Image = None + ImageDraw = None + ImageFont = None + ImageChops = None + + +class MarkdownRenderer: + """Full-featured Markdown renderer with typewriter support.""" + + def __init__(self, fonts: Dict, colors: Dict, color_emojis: bool = True): + self.fonts = fonts + self.colors = colors + self.color_emojis = color_emojis # Enable colored emoji rendering + self.line_height = 26 # Good line height for readability + self.letter_spacing = 0 # No letter spacing + self.char_count = 0 # For typewriter tracking + self._text_size_cache = {} + self._image_cache = {} # Cache for loaded images + self._image_load_failures = set() # Track failed URLs to avoid retrying + + def set_colors(self, text: Tuple, accent: Tuple, bg: Tuple): + self.colors = {'text': text, 'accent': accent, 'bg': bg} + + def _load_image(self, url: str, max_width: int) -> Image.Image: + """Load an image from URL or file path, resize to fit max_width while keeping aspect ratio. + + Returns None if loading fails. + """ + # Check cache first + cache_key = (url, max_width) + if cache_key in self._image_cache: + return self._image_cache[cache_key] + + # Check if we already failed to load this URL + if url in self._image_load_failures: + return None + + try: + img = None + + # Check if it's a local file path + if os.path.isfile(url): + img = Image.open(url) + elif url.startswith('file://'): + # Handle file:// URLs + file_path = url[7:] # Remove 'file://' + if os.path.isfile(file_path): + img = Image.open(file_path) + elif url.startswith(('http://', 'https://')): + # Download from URL with timeout + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=5) as response: + img_data = response.read() + img = Image.open(io.BytesIO(img_data)) + + if img is None: + self._image_load_failures.add(url) + return None + + # Convert to RGBA if necessary + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Resize to fit max_width while keeping aspect ratio (only shrink, never enlarge) + orig_width, orig_height = img.size + if orig_width > max_width: + ratio = max_width / orig_width + new_height = int(orig_height * ratio) + img = img.resize((max_width, new_height), Image.Resampling.LANCZOS) + + # Cache the result (limit cache size) + if len(self._image_cache) > 20: + # Remove oldest entry + oldest_key = next(iter(self._image_cache)) + del self._image_cache[oldest_key] + + self._image_cache[cache_key] = img + return img + + except Exception as e: + # Mark as failed to avoid retrying + self._image_load_failures.add(url) + return None + + def _get_text_size(self, text: str, font) -> Tuple[int, int]: + """Get text size with caching.""" + # Use id(font) because font objects are not hashable but are persistent in this app + key = (text, id(font)) + if key in self._text_size_cache: + return self._text_size_cache[key] + + try: + bbox = font.getbbox(text) + size = (int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1])) + except: + size = (len(text) * 8, 16) + + # Limit cache size to prevent memory leaks (simple eviction) + if len(self._text_size_cache) > 2000: + self._text_size_cache.clear() + + self._text_size_cache[key] = size + return size + + def _draw_text_with_spacing(self, draw, pos: Tuple[int, int], text: str, fill, font, embedded_color=False): + """Draw text - direct draw since no letter spacing. + + Args: + embedded_color: If True and color_emojis is enabled, renders emojis in full color. + """ + if embedded_color and self.color_emojis: + draw.text(pos, text, fill=fill, font=font, embedded_color=True) + else: + draw.text(pos, text, fill=fill, font=font) + + def _is_emoji_codepoint(self, codepoint: int) -> bool: + """Check if a Unicode codepoint is an emoji base character.""" + return ( + (0x1F600 <= codepoint <= 0x1F64F) or # Emoticons + (0x1F300 <= codepoint <= 0x1F5FF) or # Misc Symbols and Pictographs + (0x1F680 <= codepoint <= 0x1F6FF) or # Transport and Map + (0x1F7E0 <= codepoint <= 0x1F7EB) or # Colored circles (🟠🟡🟢🟣🟤🔵) + (0x1F900 <= codepoint <= 0x1F9FF) or # Supplemental Symbols and Pictographs + (0x1FA70 <= codepoint <= 0x1FAFF) or # Symbols and Pictographs Extended-A + (0x2600 <= codepoint <= 0x26FF) or # Misc Symbols + (0x2700 <= codepoint <= 0x27BF) or # Dingbats + (0x2300 <= codepoint <= 0x23FF) or # Misc Technical + (0x2B50 <= codepoint <= 0x2B55) or # Stars and circles + (0x203C <= codepoint <= 0x3299) or # Various symbols + (0x1F004 == codepoint) or # Mahjong + (0x1F0CF == codepoint) or # Joker + (0x1F170 <= codepoint <= 0x1F251) or # Enclosed Ideographic Supplement + (0x1F1E0 <= codepoint <= 0x1F1FF) or # Regional indicator symbols (flags) + (0x1F910 <= codepoint <= 0x1F9FF) or # Extended emoji + (0x231A <= codepoint <= 0x231B) or # Watch, hourglass + (0x23E9 <= codepoint <= 0x23F3) or # Media controls + (0x23F8 <= codepoint <= 0x23FA) or # More media + (0x25AA <= codepoint <= 0x25AB) or # Squares + (0x25B6 == codepoint) or # Play button + (0x25C0 == codepoint) or # Reverse button + (0x25FB <= codepoint <= 0x25FE) or # Squares + (0x2614 <= codepoint <= 0x2615) or # Umbrella, coffee + (0x2648 <= codepoint <= 0x2653) or # Zodiac + (0x267F == codepoint) or # Wheelchair + (0x2693 == codepoint) or # Anchor + (0x26A1 == codepoint) or # High voltage + (0x26AA <= codepoint <= 0x26AB) or # Circles + (0x26BD <= codepoint <= 0x26BE) or # Sports + (0x26C4 <= codepoint <= 0x26C5) or # Weather + (0x26CE == codepoint) or # Ophiuchus + (0x26D4 == codepoint) or # No entry + (0x26EA == codepoint) or # Church + (0x26F2 <= codepoint <= 0x26F3) or # Fountain, golf + (0x26F5 == codepoint) or # Sailboat + (0x26FA == codepoint) or # Tent + (0x26FD == codepoint) or # Fuel pump + (0x2702 == codepoint) or # Scissors + (0x2705 == codepoint) or # Check mark + (0x2708 <= codepoint <= 0x270D) or # Airplane to writing hand + (0x270F == codepoint) or # Pencil + (0x2712 == codepoint) or # Black nib + (0x2714 == codepoint) or # Check mark + (0x2716 == codepoint) or # X mark + (0x271D == codepoint) or # Latin cross + (0x2721 == codepoint) or # Star of David + (0x2728 == codepoint) or # Sparkles + (0x2733 <= codepoint <= 0x2734) or # Eight spoked asterisk + (0x2744 == codepoint) or # Snowflake + (0x2747 == codepoint) or # Sparkle + (0x274C == codepoint) or # Cross mark + (0x274E == codepoint) or # Cross mark + (0x2753 <= codepoint <= 0x2755) or # Question marks + (0x2757 == codepoint) or # Exclamation mark + (0x2763 <= codepoint <= 0x2764) or # Heart exclamation, red heart + (0x2795 <= codepoint <= 0x2797) or # Plus, minus, divide + (0x27A1 == codepoint) or # Right arrow + (0x27B0 == codepoint) or # Curly loop + (0x27BF == codepoint) or # Double curly loop + (0x2934 <= codepoint <= 0x2935) or # Arrows + (0x2B05 <= codepoint <= 0x2B07) or # Arrows + (0x2B1B <= codepoint <= 0x2B1C) or # Squares + (0x3030 == codepoint) or # Wavy dash + (0x303D == codepoint) or # Part alternation mark + (0x1F004 == codepoint) or # Mahjong red dragon + (0x1F0CF == codepoint) or # Playing card black joker + (0x1F18E == codepoint) or # AB button + (0x1F191 <= codepoint <= 0x1F19A) or # CL button to VS button + (0x1F201 <= codepoint <= 0x1F202) or # Japanese buttons + (0x1F21A == codepoint) or # Japanese button + (0x1F22F == codepoint) or # Japanese button + (0x1F232 <= codepoint <= 0x1F23A) or # Japanese buttons + (0x1F250 <= codepoint <= 0x1F251) or # Japanese buttons + (0x00A9 == codepoint) or # Copyright + (0x00AE == codepoint) or # Registered + (0x2122 == codepoint) # Trademark + ) + + def _get_emoji_length(self, text: str, pos: int) -> int: + """Get the length of an emoji sequence starting at pos. + + Returns 0 if the character at pos is not an emoji. + Handles multi-character sequences like emoji + variation selector, + ZWJ sequences (family, skin tones), and flag sequences. + """ + if pos >= len(text): + return 0 + + codepoint = ord(text[pos]) + + # Check if this is an emoji base character + if not self._is_emoji_codepoint(codepoint): + return 0 + + # Start with the base emoji + length = 1 + + # Check for multi-character sequences + while pos + length < len(text): + next_char = text[pos + length] + next_cp = ord(next_char) + + # Variation selector (makes emoji colored or text-style) + if next_cp == 0xFE0F or next_cp == 0xFE0E: + length += 1 + continue + + # Zero-width joiner (for combined emojis like family, skin tones) + if next_cp == 0x200D: + length += 1 + # The next character after ZWJ should be another emoji + if pos + length < len(text): + following_cp = ord(text[pos + length]) + if self._is_emoji_codepoint(following_cp): + length += 1 + continue + break + + # Skin tone modifiers (Fitzpatrick scale) + if 0x1F3FB <= next_cp <= 0x1F3FF: + length += 1 + continue + + # Regional indicator (for flags - need two) + if 0x1F1E0 <= next_cp <= 0x1F1FF and 0x1F1E0 <= codepoint <= 0x1F1FF: + # This is part of a flag sequence + if pos + length < len(text): + following_cp = ord(text[pos + length]) + if 0x1F1E0 <= following_cp <= 0x1F1FF: + length += 1 + continue + break + + # Combining enclosing keycap (for keycap emojis like 1️⃣) + if next_cp == 0x20E3: + length += 1 + continue + + # No more emoji modifiers + break + + return length + + def _wrap_text(self, text: str, font, max_width: int) -> List[str]: + words = text.split(' ') + lines, current = [], [] + for word in words: + test = ' '.join(current + [word]) + w, _ = self._get_text_size(test, font) + if w <= max_width: + current.append(word) + else: + if current: + lines.append(' '.join(current)) + current = [word] + if current: + lines.append(' '.join(current)) + return lines or [''] + + # ========================================================================= + # INLINE TOKENIZER - supports all inline markdown + # ========================================================================= + + def tokenize_inline(self, text: str) -> List[Dict]: + """Parse inline markdown into tokens. + + Each token includes: + - 'type': The token type (text, bold, italic, code, link, etc.) + - 'text': The visible text content (without markdown syntax) + - 'start': The start position in the original text + - 'end': The end position in the original text (exclusive) + - 'content_start': Start of the actual content (after opening syntax) + - 'content_end': End of the actual content (before closing syntax) + + This allows the typewriter effect to correctly track position in the original text. + """ + tokens = [] + i = 0 + + while i < len(text): + start_pos = i + + # Image ![alt](url) + if text[i:i+2] == '![': + m = re.match(r'!\[([^]]*)]\(([^)]+)\)', text[i:]) + if m: + full_len = len(m.group(0)) + # Content is "alt" which starts at i+2, ends before ] + tokens.append({ + 'type': 'image', + 'alt': m.group(1), + 'url': m.group(2), + 'start': start_pos, + 'end': start_pos + full_len, + 'content_start': start_pos + 2, # After ![ + 'content_end': start_pos + 2 + len(m.group(1)) # End of alt text + }) + i += full_len + continue + + # Link [text](url) - text can contain any character except ] + if text[i] == '[' and (i == 0 or text[i-1] != '!'): + m = re.match(r'\[([^]]+)]\(([^)]+)\)', text[i:]) + if m: + full_len = len(m.group(0)) + link_title = m.group(1) + link_url = m.group(2) + tokens.append({ + 'type': 'link', + 'text': link_title, + 'url': link_url, + 'start': start_pos, + 'end': start_pos + full_len, + 'content_start': start_pos + 1, # After [ + 'content_end': start_pos + 1 + len(link_title) # End of link title + }) + i += full_len + continue + + # Bold Italic ***text*** + if text[i:i+3] == '***': + end = text.find('***', i + 3) + if end != -1: + content = text[i+3:end] + tokens.append({ + 'type': 'bold_italic', + 'text': content, + 'start': start_pos, + 'end': end + 3, + 'content_start': start_pos + 3, # After *** + 'content_end': end # Before closing *** + }) + i = end + 3 + continue + + # Bold **text** + if text[i:i+2] == '**': + end = text.find('**', i + 2) + if end != -1: + content = text[i+2:end] + tokens.append({ + 'type': 'bold', + 'text': content, + 'start': start_pos, + 'end': end + 2, + 'content_start': start_pos + 2, # After ** + 'content_end': end # Before closing ** + }) + i = end + 2 + continue + + # Italic *text* + if text[i] == '*' and (i+1 < len(text) and text[i+1] != '*'): + end = i + 1 + while end < len(text) and not (text[end] == '*' and (end+1 >= len(text) or text[end+1] != '*')): + end += 1 + if end < len(text): + content = text[i+1:end] + tokens.append({ + 'type': 'italic', + 'text': content, + 'start': start_pos, + 'end': end + 1, + 'content_start': start_pos + 1, # After * + 'content_end': end # Before closing * + }) + i = end + 1 + continue + + # Inline code `text` + if text[i] == '`' and text[i:i+3] != '```': + end = text.find('`', i + 1) + if end != -1: + content = text[i+1:end] + tokens.append({ + 'type': 'code', + 'text': content, + 'start': start_pos, + 'end': end + 1, + 'content_start': start_pos + 1, # After ` + 'content_end': end # Before closing ` + }) + i = end + 1 + continue + + # Strikethrough ~~text~~ + if text[i:i+2] == '~~': + end = text.find('~~', i + 2) + if end != -1: + content = text[i+2:end] + tokens.append({ + 'type': 'strike', + 'text': content, + 'start': start_pos, + 'end': end + 2, + 'content_start': start_pos + 2, # After ~~ + 'content_end': end # Before closing ~~ + }) + i = end + 2 + continue + + # Bold with underscores __text__ + if text[i:i+2] == '__': + end = text.find('__', i + 2) + if end != -1: + content = text[i+2:end] + tokens.append({ + 'type': 'bold', + 'text': content, + 'start': start_pos, + 'end': end + 2, + 'content_start': start_pos + 2, # After __ + 'content_end': end # Before closing __ + }) + i = end + 2 + continue + + # Italic with underscores _text_ + if text[i] == '_' and (i+1 < len(text) and text[i+1] != '_'): + end = i + 1 + while end < len(text) and not (text[end] == '_' and (end+1 >= len(text) or text[end+1] != '_')): + end += 1 + if end < len(text): + content = text[i+1:end] + tokens.append({ + 'type': 'italic', + 'text': content, + 'start': start_pos, + 'end': end + 1, + 'content_start': start_pos + 1, # After _ + 'content_end': end # Before closing _ + }) + i = end + 1 + continue + + # Inline math \( text \) + if text[i:i+2] == '\\(': + end = text.find('\\)', i + 2) + if end != -1: + content = text[i+2:end] + tokens.append({ + 'type': 'math', + 'text': content, + 'start': start_pos, + 'end': end + 2, + 'content_start': start_pos + 2, # After \( + 'content_end': end # Before closing \) + }) + i = end + 2 + continue + + # Task list checkbox [ ] or [x] + if text[i:i+3] in ['[ ]', '[x]', '[X]']: + checked = text[i+1].lower() == 'x' + tokens.append({ + 'type': 'checkbox', + 'checked': checked, + 'start': start_pos, + 'end': start_pos + 3, + 'content_start': start_pos, + 'content_end': start_pos + 3 + }) + i += 3 + continue + + # Footnote reference [^1] or [^name] + if text[i:i+2] == '[^': + m = re.match(r'\[\^([^\]]+)\]', text[i:]) + if m and not text[i:].startswith('[^') or not re.match(r'\[\^[^\]]+\]:', text[i:]): + # It's a reference, not a definition + m = re.match(r'\[\^([^\]]+)\]', text[i:]) + if m: + full_len = len(m.group(0)) + fn_id = m.group(1) + tokens.append({ + 'type': 'footnote_ref', + 'id': fn_id, + 'start': start_pos, + 'end': start_pos + full_len, + 'content_start': start_pos + 2, # After [^ + 'content_end': start_pos + 2 + len(fn_id) # Before ] + }) + i += full_len + continue + + # Emoji detection - check for multi-character emoji sequences first + # Many emojis include variation selectors (U+FE0F) or ZWJ sequences + emoji_len = self._get_emoji_length(text, i) + if emoji_len > 0: + emoji_text = text[i:i+emoji_len] + tokens.append({ + 'type': 'emoji', + 'text': emoji_text, + 'start': start_pos, + 'end': start_pos + emoji_len, + 'content_start': start_pos, + 'content_end': start_pos + emoji_len + }) + i += emoji_len + continue + + # Regular text - accumulate consecutive characters + if tokens and tokens[-1].get('type') == 'text': + tokens[-1]['text'] += text[i] + tokens[-1]['end'] = i + 1 + tokens[-1]['content_end'] = i + 1 + else: + tokens.append({ + 'type': 'text', + 'text': text[i], + 'start': start_pos, + 'end': start_pos + 1, + 'content_start': start_pos, + 'content_end': start_pos + 1 + }) + i += 1 + + return tokens + + # ========================================================================= + # BLOCK PARSER - supports all block-level markdown + # ========================================================================= + + def parse_blocks(self, text: str) -> List[Dict]: + """Parse markdown into block elements. + + Each block includes 'start' and 'end' positions in the original text + for typewriter effect support. + """ + blocks = [] + lines = text.split('\n') + i = 0 + + # Calculate line start positions for mapping + line_starts = [0] + for line in lines[:-1]: # Don't need position after last line + line_starts.append(line_starts[-1] + len(line) + 1) # +1 for \n + + while i < len(lines): + line = lines[i] + stripped = line.strip() + block_start = line_starts[i] if i < len(line_starts) else len(text) + + # Empty line + if not stripped: + block_end = block_start + len(line) + (1 if i < len(lines) - 1 else 0) + blocks.append({'type': 'empty', 'start': block_start, 'end': block_end}) + i += 1 + continue + + # Code block ``` + if stripped.startswith('```'): + lang = stripped[3:].strip() + code_lines = [] + start_i = i + i += 1 + while i < len(lines) and not lines[i].strip().startswith('```'): + code_lines.append(lines[i]) + i += 1 + # Include closing ``` in end position + block_end = line_starts[i] + len(lines[i]) + 1 if i < len(lines) else len(text) + blocks.append({ + 'type': 'code_block', + 'code': '\n'.join(code_lines), + 'lang': lang, + 'start': block_start, + 'end': block_end + }) + i += 1 + continue + + # Headers # + m = re.match(r'^(#{1,6})\s+(.+)$', stripped) + if m: + block_end = block_start + len(line) + (1 if i < len(lines) - 1 else 0) + # Content starts after "# " (header marker + space) + content_start = block_start + len(m.group(1)) + 1 # +1 for space after # + blocks.append({ + 'type': 'header', + 'level': len(m.group(1)), + 'text': m.group(2), + 'start': block_start, + 'end': block_end, + 'content_start': content_start, + 'content_end': block_end - (1 if i < len(lines) - 1 else 0) + }) + i += 1 + continue + + # Blockquote > (can start with > or have spaces before it) + if stripped.startswith('>') or line.lstrip().startswith('>'): + quote_lines = [] + start_i = i + while i < len(lines): + current_line = lines[i].strip() + if current_line.startswith('>'): + # Remove the > and optional space + content = re.sub(r'^>\s?', '', current_line) + quote_lines.append(content) + i += 1 + elif current_line == '' and quote_lines: + # Empty line ends blockquote + break + else: + break + if quote_lines: + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'blockquote', + 'text': ' '.join(quote_lines), + 'start': block_start, + 'end': block_end + }) + continue + + # Horizontal rule --- + if re.match(r'^[-*_]{3,}$', stripped): + block_end = block_start + len(line) + (1 if i < len(lines) - 1 else 0) + blocks.append({ + 'type': 'hr', + 'start': block_start, + 'end': block_end + }) + i += 1 + continue + + # Table | + if stripped.startswith('|') and '|' in stripped[1:]: + table_lines = [] + start_i = i + while i < len(lines) and '|' in lines[i]: + table_lines.append(lines[i].strip()) + i += 1 + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'table', + 'lines': table_lines, + 'start': block_start, + 'end': block_end + }) + continue + + # Unordered list - * + + m = re.match(r'^(\s*)([-*+])\s+(.*)$', line) + if m: + items = [] + start_i = i + while i < len(lines): + item_m = re.match(r'^(\s*)([-*+])\s+(.*)$', lines[i]) + if item_m: + items.append({'indent': len(item_m.group(1)) // 2, 'text': item_m.group(3)}) + i += 1 + elif lines[i].strip() == '': + i += 1 + break + elif re.match(r'^\s+\S', lines[i]): + # Check for OL start (nested list of different type) + if re.match(r'^\s*(?:\d+\.)+\d*\s+', lines[i]): + break + if items: + items[-1]['text'] += ' ' + lines[i].strip() + i += 1 + else: + break + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'ul', + 'items': items, + 'start': block_start, + 'end': block_end + }) + continue + + # Ordered list 1. + m = re.match(r'^(\s*)((?:\d+\.)+\d*)\s+(.*)$', line) + if m: + items = [] + start_i = i + while i < len(lines): + item_m = re.match(r'^(\s*)((?:\d+\.)+\d*)\s+(.*)$', lines[i]) + if item_m: + items.append({'indent': len(item_m.group(1)) // 2, 'num': item_m.group(2), 'text': item_m.group(3)}) + i += 1 + elif lines[i].strip() == '': + i += 1 + break + elif re.match(r'^\s+\S', lines[i]): + # Check for UL start (nested list of different type) + if re.match(r'^\s*[-*+]\s+', lines[i]): + break + if items: + items[-1]['text'] += ' ' + lines[i].strip() + i += 1 + else: + break + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'ol', + 'items': items, + 'start': block_start, + 'end': block_end + }) + continue + + # Footnote definition [^1]: text or [^name]: text + fn_match = re.match(r'^\[\^([^\]]+)\]:\s*(.*)$', stripped) + if fn_match: + fn_id = fn_match.group(1) + fn_text = fn_match.group(2) + # Collect continuation lines (indented) + i += 1 + while i < len(lines) and (lines[i].startswith(' ') or lines[i].startswith('\t') or lines[i].strip() == ''): + if lines[i].strip(): + fn_text += ' ' + lines[i].strip() + i += 1 + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'footnote_def', + 'id': fn_id, + 'text': fn_text, + 'start': block_start, + 'end': block_end + }) + continue + + # Paragraph (default) + para = [] + start_i = i + while i < len(lines) and lines[i].strip() and not self._is_block_start(lines[i]): + para.append(lines[i].strip()) + i += 1 + if para: + block_end = line_starts[i-1] + len(lines[i-1]) + 1 if i > 0 and i-1 < len(lines) else len(text) + blocks.append({ + 'type': 'paragraph', + 'text': ' '.join(para), + 'start': block_start, + 'end': block_end + }) + + return blocks + + def _is_block_start(self, line: str) -> bool: + s = line.strip() + if s.startswith('#') or s.startswith('```') or s.startswith('>'): + return True + if re.match(r'^[-*_]{3,}$', s) or (s.startswith('|') and '|' in s[1:]): + return True + if re.match(r'^(\s*)([-*+])\s+', line) or re.match(r'^(\s*)((?:\d+\.)+\d*)\s+', line): + return True + # Footnote definition + if re.match(r'^\[\^[^\]]+\]:', s): + return True + return False + + # ========================================================================= + # RENDERING - Modern, sleek design + # ========================================================================= + + def render(self, draw, canvas, text: str, x: int, y: int, width: int, max_chars: int = None, pre_parsed_blocks: List[Dict] = None) -> int: + """Render markdown with optional character limit for typewriter. + + max_chars represents the position in the ORIGINAL text up to which + characters should be shown. This correctly handles markdown syntax + characters that are not displayed but still count towards the position. + """ + # Store remaining chars as position in original text for typewriter effect + self.remaining_chars = max_chars if max_chars is not None else float('inf') + + if pre_parsed_blocks: + blocks = pre_parsed_blocks + else: + blocks = self.parse_blocks(text) + + current_y = y + + for block in blocks: + block_start = block.get('start', 0) + block_end = block.get('end', block_start) + + # Skip blocks that haven't been reached yet by typewriter + if self.remaining_chars != float('inf') and block_start >= self.remaining_chars: + break + + t = block['type'] + + if t == 'empty': + current_y += self.line_height // 2 + elif t == 'header': + current_y = self._render_header(draw, block, x, current_y, width) + elif t == 'paragraph': + current_y = self._render_paragraph(draw, block, x, current_y, width) + elif t == 'code_block': + current_y = self._render_code_block(draw, canvas, block, x, current_y, width) + elif t == 'blockquote': + current_y = self._render_blockquote(draw, canvas, block, x, current_y, width) + elif t == 'hr': + current_y = self._render_hr(draw, x, current_y, width) + elif t == 'table': + current_y = self._render_table(draw, block, x, current_y, width) + elif t == 'ul': + current_y = self._render_ul(draw, canvas, block, x, current_y, width) + elif t == 'ol': + current_y = self._render_ol(draw, canvas, block, x, current_y, width) + elif t == 'footnote_def': + current_y = self._render_footnote_def(draw, block, x, current_y, width) + + return current_y + + def _render_header(self, draw, block: Dict, x: int, y: int, width: int) -> int: + level = block['level'] + text = block['text'] + content_start = block.get('content_start', block.get('start', 0)) + + # Header styling for H1-H6 + header_styles = { + 1: {'font': 'h1', 'color': self.colors['accent'], 'spacing': 8}, + 2: {'font': 'h2', 'color': self.colors['accent'], 'spacing': 6}, + 3: {'font': 'h3', 'color': self.colors['accent'], 'spacing': 4}, + 4: {'font': 'h4', 'color': self.colors['text'], 'spacing': 4}, + 5: {'font': 'h5', 'color': self.colors['text'], 'spacing': 4}, + 6: {'font': 'h6', 'color': (160, 168, 180), 'spacing': 4}, + } + + # Get style for this level (default to H6 style for levels > 6) + style = header_styles.get(level, header_styles[6]) + font = self.fonts.get(style['font'], self.fonts.get('bold', self.fonts['normal'])) + color = style['color'] + spacing = style['spacing'] + + # Render header text with inline formatting + tokens = self.tokenize_inline(text) + + # Adjust token positions to be absolute (relative to original text) + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + if typewriter_pos != float('inf'): + for token in tokens: + token['start'] = token.get('start', 0) + content_start + token['end'] = token.get('end', 0) + content_start + token['content_start'] = token.get('content_start', token['start']) + content_start + token['content_end'] = token.get('content_end', token['end']) + content_start + + final_y = self._render_tokens(draw, tokens, x, y, width, override_color=color, override_font=font, header_level=level) + + return final_y + spacing + + def _render_paragraph(self, draw, block: Dict, x: int, y: int, width: int) -> int: + """Render a paragraph block with typewriter support.""" + text = block.get('text', '') if isinstance(block, dict) else block + block_start = block.get('start', 0) if isinstance(block, dict) else 0 + + # Handle hard line breaks (two spaces at end of line or explicit \n) + lines = text.split(' \n') if ' \n' in text else [text] + current_y = y + + # Calculate relative position within this block for typewriter + # remaining_chars is the absolute position in original text + # We need to convert it to relative position within this paragraph's content + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + + for line in lines: + if line.strip(): + tokens = self.tokenize_inline(line) + + # Adjust token positions to be relative to remaining_chars + # by offsetting them based on block_start + if typewriter_pos != float('inf'): + # Create adjusted tokens with positions relative to absolute typewriter position + for token in tokens: + token['start'] = token.get('start', 0) + block_start + token['end'] = token.get('end', 0) + block_start + token['content_start'] = token.get('content_start', token['start']) + block_start + token['content_end'] = token.get('content_end', token['end']) + block_start + + current_y = self._render_tokens(draw, tokens, x, current_y, width) + + return current_y + + def _render_code_block(self, draw, canvas, block: Dict, x: int, y: int, width: int) -> int: + code = block['code'] + lang = block.get('lang', '') + block_start = block.get('start', 0) + font = self.fonts.get('code', self.fonts['normal']) + + line_h = 18 + padding = 12 + header_h = 24 if lang else 0 + code_width = width - padding * 2 # Available width for code text + + # Wrap long lines to fit within code block + original_lines = code.split('\n') + wrapped_lines = [] + for line in original_lines: + if line: + # Wrap the line if it's too long + wrapped = self._wrap_code_line(line, font, code_width) + wrapped_lines.extend(wrapped) + else: + wrapped_lines.append('') + + # Calculate typewriter position relative to code content + # Code content starts after opening ``` line (includes lang if present) + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + # Estimate where actual code content starts (after ```lang\n) + code_content_start = block_start + 3 + len(lang) + 1 # ``` + lang + \n + + # First pass: count visible lines + chars_shown = 0 + visible_lines = 0 + for line in wrapped_lines: + line_start_in_code = chars_shown + if typewriter_pos != float('inf'): + relative_pos = typewriter_pos - code_content_start + if line_start_in_code >= relative_pos: + break + visible_lines += 1 + chars_shown += len(line) + 1 + + # If no lines visible yet, check if we should show the header at least + show_header = lang and (typewriter_pos == float('inf') or typewriter_pos > block_start + 3) + + if visible_lines == 0 and not show_header: + return y # Nothing to show yet + + # Calculate height based on visible content + visible_content_h = max(1, visible_lines) * line_h + padding * 2 + total_h = header_h + visible_content_h + + # Modern dark background with subtle gradient effect + bg_color = (22, 27, 34, 250) + border_color = (48, 54, 61) + + # Main background (fill only) + draw.rounded_rectangle([x, y, x + width, y + total_h], radius=10, fill=bg_color) + + # Language header bar + code_y = y + padding + if show_header: + header_bg = (30, 36, 44) + # Header background + draw.rounded_rectangle([x, y, x + width, y + header_h], radius=10, fill=header_bg) + draw.rectangle([x, y + 10, x + width, y + header_h], fill=header_bg) + + # Language tag (text only) + lang_text = lang.upper() + # Draw text directly in accent color + draw.text((x + 12, y + 4), lang_text, fill=self.colors['accent'], font=font) + + code_y = y + header_h + 8 + + # Draw border on top + draw.rounded_rectangle([x, y, x + width, y + total_h], radius=10, fill=None, outline=border_color, width=1) + + # Second pass: draw visible code lines + chars_shown = 0 + for line in wrapped_lines: + # Calculate visibility based on typewriter position + line_start_in_code = chars_shown + line_end_in_code = chars_shown + len(line) + + # Check if this line should be visible + if typewriter_pos != float('inf'): + relative_pos = typewriter_pos - code_content_start + if line_start_in_code >= relative_pos: + break # Haven't reached this line yet + + # Simple keyword highlighting + if any(kw in line for kw in ['def ', 'class ', 'import ', 'from ', 'return ', 'if ', 'else:', 'for ', 'while ']): + color = (255, 123, 114) # Red for keywords + elif line.strip().startswith('#') or line.strip().startswith('//'): + color = (139, 148, 158) # Gray for comments + elif '"' in line or "'" in line: + color = (165, 214, 255) # Light blue for strings + else: + color = self.colors['accent'] + + display_line = line + if typewriter_pos != float('inf'): + relative_pos = typewriter_pos - code_content_start + visible_in_line = relative_pos - line_start_in_code + if visible_in_line < len(line): + display_line = line[:max(0, int(visible_in_line))] + + # Render line with emoji support + self._render_code_line_with_emoji(draw, display_line, x + padding, code_y, color, font) + code_y += line_h + chars_shown += len(line) + 1 # +1 for newline + + return y + total_h + 12 + + def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, font, emoji_y_offset: int = 5): + """Render text with inline emoji support for titles and labels. + + Args: + draw: ImageDraw object + text: Text to render (may contain emojis) + x: X position + y: Y position + color: Text color (RGBA tuple) + font: Font to use for text + emoji_y_offset: Vertical offset for emojis (default 5 for bold titles) + """ + if not text: + return + + current_x = x + i = 0 + emoji_font = self.fonts.get('emoji', font) + + while i < len(text): + # Check for emoji at current position + emoji_len = self._get_emoji_length(text, i) + if emoji_len > 0: + # Render emoji with emoji font and color support + emoji_text = text[i:i+emoji_len] + if self.color_emojis: + draw.text((current_x, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font, embedded_color=True) + else: + draw.text((current_x, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font) + emoji_w, _ = self._get_text_size(emoji_text, emoji_font) + # Reduce emoji width - more aggressive for variation selector emojis + has_variation_selector = '\ufe0f' in emoji_text + if has_variation_selector: + current_x += int(emoji_w * 0.55) + else: + current_x += int(emoji_w * 0.85) + i += emoji_len + else: + # Find the next emoji or end of text + text_start = i + while i < len(text) and self._get_emoji_length(text, i) == 0: + i += 1 + # Render text segment + text_segment = text[text_start:i] + if text_segment: + draw.text((current_x, y), text_segment, fill=color, font=font) + text_w, _ = self._get_text_size(text_segment, font) + current_x += text_w + + def _render_code_line_with_emoji(self, draw, line: str, x: int, y: int, color: Tuple, font, emoji_y_offset: int = 0, emoji_x_offset: int = 0): + """Render a code line with emoji support. + + Args: + emoji_y_offset: Vertical offset for emojis (default 0 for code, use 7 for normal text) + emoji_x_offset: Horizontal offset for emojis (default 0, use negative to move left) + """ + if not line: + return + + current_x = x + i = 0 + emoji_font = self.fonts.get('emoji', font) + + while i < len(line): + # Check for emoji at current position + emoji_len = self._get_emoji_length(line, i) + if emoji_len > 0: + # Render emoji with emoji font and color support + emoji_text = line[i:i+emoji_len] + if self.color_emojis: + draw.text((current_x + emoji_x_offset, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font, embedded_color=True) + else: + draw.text((current_x + emoji_x_offset, y + emoji_y_offset), emoji_text, fill=color, font=emoji_font) + emoji_w, _ = self._get_text_size(emoji_text, emoji_font) + # Reduce emoji width - more aggressive for variation selector emojis + has_variation_selector = '\ufe0f' in emoji_text + if has_variation_selector: + current_x += int(emoji_w * 0.55) + else: + current_x += int(emoji_w * 0.85) + i += emoji_len + else: + # Find the next emoji or end of line + text_start = i + while i < len(line) and self._get_emoji_length(line, i) == 0: + i += 1 + # Render text segment + text_segment = line[text_start:i] + if text_segment: + draw.text((current_x, y), text_segment, fill=color, font=font) + text_w, _ = self._get_text_size(text_segment, font) + current_x += text_w + + def _wrap_code_line(self, line: str, font, max_width: int) -> List[str]: + """Wrap a code line to fit within max_width, breaking at character boundaries.""" + if not line: + return [''] + + w, _ = self._get_text_size(line, font) + if w <= max_width: + return [line] + + # Need to wrap - break at character boundaries + wrapped = [] + current = '' + for char in line: + test = current + char + tw, _ = self._get_text_size(test, font) + if tw > max_width and current: + wrapped.append(current) + current = ' ' + char # Indent continuation lines + else: + current = test + + if current: + wrapped.append(current) + + return wrapped if wrapped else [''] + + def _wrap_inline_code(self, text: str, font, max_width: int) -> List[str]: + """Wrap inline code text to fit within max_width, breaking at character boundaries.""" + if not text: + return [''] + + w, _ = self._get_text_size(text, font) + if w <= max_width: + return [text] + + # Need to wrap - break at character boundaries (no indent for inline code) + wrapped = [] + current = '' + for char in text: + test = current + char + tw, _ = self._get_text_size(test, font) + if tw > max_width and current: + wrapped.append(current) + current = char # No indent for inline code continuation + else: + current = test + + if current: + wrapped.append(current) + + return wrapped if wrapped else [''] + + def _render_blockquote(self, draw, canvas, block: Dict, x: int, y: int, width: int) -> int: + """Render a blockquote with typewriter support and full markdown/emoji rendering.""" + text = block.get('text', '') if isinstance(block, dict) else block + block_start = block.get('start', 0) if isinstance(block, dict) else 0 + + # Add small top margin before blockquote + y += 4 + + border_w = 4 + content_x = x + border_w + 12 + content_w = width - border_w - 16 + + # Calculate typewriter position relative to blockquote content + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + # Content starts after "> " (2 chars) + content_start = block_start + 2 + + # Tokenize the blockquote content for proper markdown/emoji rendering + tokens = self.tokenize_inline(text) + + # Adjust token positions to be absolute + for token in tokens: + token['start'] = token.get('start', 0) + content_start + token['end'] = token.get('end', 0) + content_start + token['content_start'] = token.get('content_start', token['start']) + token['content_end'] = token.get('content_end', token['end']) + + # Calculate visible characters for typewriter + max_chars = None + if typewriter_pos != float('inf'): + max_chars = max(0, typewriter_pos - content_start) + if max_chars <= 0: + return y - 4 # Nothing visible yet + + # First, estimate the height by doing a dry run + # We'll use a simple line-based estimate + font = self.fonts.get('italic', self.fonts['normal']) + lines = self._wrap_text(text, font, content_w) + line_h = self.line_height + + # Calculate how much text is visible + if max_chars is not None: + chars_shown = 0 + visible_lines = 0 + for line in lines: + if chars_shown >= max_chars: + break + visible_lines += 1 + chars_shown += len(line) + 1 + visible_h = max(1, visible_lines) * line_h + 12 + else: + visible_h = len(lines) * line_h + 12 + + # Draw accent border on left + accent = self.colors['accent'] + draw.rectangle([x, y, x + border_w, y + visible_h], fill=accent) + + # Subtle background + bg = (40, 46, 56, 180) + draw.rounded_rectangle([x + border_w, y, x + width, y + visible_h], radius=6, fill=bg) + + # Render the blockquote content with full markdown support + quote_color = (180, 186, 196) + text_y = y + 6 + + # Use _render_tokens for proper markdown/emoji rendering + final_y = self._render_tokens( + draw, tokens, content_x, text_y, content_w, + override_color=quote_color, + override_font=font, + max_chars=max_chars + ) + + return y + visible_h + 10 + + def _render_hr(self, draw, x: int, y: int, width: int) -> int: + hr_y = y + 12 + # Simple horizontal line with accent color + accent = self.colors['accent'] + + # Draw the main line + line_start_x = x + line_end_x = x + width + draw.line([(line_start_x, hr_y), (line_end_x, hr_y)], fill=(60, 66, 78), width=1) + + # Decorative dots at the ends and center + dot_positions = [line_start_x, x + width // 2, line_end_x] + for dot_x in dot_positions: + draw.ellipse([dot_x - 2, hr_y - 2, dot_x + 2, hr_y + 2], fill=accent) + + return y + 24 + + def _render_footnote_def(self, draw, block: Dict, x: int, y: int, width: int) -> int: + """Render a footnote definition block.""" + fn_id = block.get('id', '?') + fn_text = block.get('text', '') + block_start = block.get('start', 0) + + font = self.fonts.get('normal', self.fonts['normal']) + small_font = self.fonts.get('code', font) + + # Footnote styling - smaller, with left border accent + border_w = 3 + padding = 8 + + # Get typewriter position + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + + # Calculate content start (after "[^id]: ") + content_start = block_start + len(f'[^{fn_id}]: ') + + # Skip if typewriter hasn't reached this block yet + if typewriter_pos != float('inf') and block_start >= typewriter_pos: + return y + + # Draw accent border on left + accent = self.colors['accent'] + + # Calculate text area + text_x = x + border_w + padding + text_width = width - border_w - padding * 2 + + # Prepare footnote label + label = f'[{fn_id}]' + label_w, label_h = self._get_text_size(label, small_font) + + # Wrap footnote text + lines = self._wrap_text(fn_text, font, text_width - label_w - 6) + line_h = self.line_height - 4 # Slightly smaller line height for footnotes + + # Calculate total height + total_h = max(len(lines) * line_h, label_h) + padding + + # Draw background and border + bg_color = (35, 40, 50, 200) + draw.rounded_rectangle([x, y, x + width, y + total_h], radius=4, fill=bg_color) + draw.rectangle([x, y, x + border_w, y + total_h], fill=accent) + + # Draw footnote label (moved down by 7px) + label_y = y + padding // 2 + 7 + draw.text((text_x, label_y), label, fill=accent, font=small_font) + + # Draw footnote text (also moved down to align) + text_start_x = text_x + label_w + 6 + text_y = y + padding // 2 + 7 + + fn_color = (170, 176, 186) # Slightly dimmed text for footnotes + + for i, line in enumerate(lines): + # Calculate visible portion based on typewriter + display_line = line + if typewriter_pos != float('inf'): + chars_before = sum(len(lines[j]) + 1 for j in range(i)) + relative_pos = typewriter_pos - content_start + if chars_before >= relative_pos: + break + visible_in_line = relative_pos - chars_before + if visible_in_line < len(line): + display_line = line[:max(0, int(visible_in_line))] + + if i == 0: + draw.text((text_start_x, text_y), display_line, fill=fn_color, font=font) + else: + draw.text((text_x, text_y), display_line, fill=fn_color, font=font) + text_y += line_h + + return y + total_h + 6 + + def _render_table(self, draw, block: Dict, x: int, y: int, width: int) -> int: + """Render a table with typewriter support.""" + lines = block.get('lines', []) if isinstance(block, dict) else block + block_start = block.get('start', 0) if isinstance(block, dict) else 0 + + if len(lines) < 2: + return y + + font = self.fonts['normal'] + bold_font = self.fonts.get('bold', font) + line_h = self.line_height - 4 + + # Parse rows (skip separator lines) + rows = [] + row_line_indices = [] # Track which original line each row came from + for li, line in enumerate(lines): + cells = [c.strip() for c in line.split('|')[1:-1]] + if cells and not all(re.match(r'^[-:]+$', c.strip()) for c in cells if c.strip()): + rows.append(cells) + row_line_indices.append(li) + + if not rows: + return y + + num_cols = max(len(r) for r in rows) if rows else 0 + if num_cols == 0: + return y + + # Calculate column widths - table uses full width like code blocks + cell_padding = 6 + table_w = width # Match code block width + col_width = max(40, table_w // num_cols) + col_widths = [col_width] * num_cols + # Adjust last column to fill remaining space + col_widths[-1] = table_w - sum(col_widths[:-1]) + + # Calculate row heights (need to pre-calculate for proper backgrounds) + row_heights = [] + for ri, row in enumerate(rows): + current_font = bold_font if ri == 0 else font + max_lines = 1 + for ci in range(num_cols): + cell_text = row[ci].strip() if ci < len(row) else '' + cell_w = col_widths[ci] - cell_padding * 2 + num_lines = self._count_wrapped_lines_breaking(cell_text, current_font, cell_w) + max_lines = max(max_lines, num_lines) + row_heights.append(max_lines * (line_h + 6) + 10) + + current_y = y + + # Calculate typewriter position and row positions + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + + # Calculate cumulative position of each row in the original text + row_positions = [] + pos = block_start + for li, line in enumerate(lines): + row_positions.append(pos) + pos += len(line) + 1 # +1 for newline + + # Track visible rows for proper border drawing + visible_rows = 0 + last_visible_y = y + + # Draw table rows + for ri, row in enumerate(rows): + # Get this row's position in original text + orig_line_idx = row_line_indices[ri] + row_start_pos = row_positions[orig_line_idx] if orig_line_idx < len(row_positions) else block_start + + # Skip this row if typewriter hasn't reached it yet + if typewriter_pos != float('inf') and row_start_pos >= typewriter_pos: + break + + visible_rows += 1 + row_h = row_heights[ri] + is_header = (ri == 0) + current_font = bold_font if is_header else font + text_color = self.colors['accent'] if is_header else self.colors['text'] + + # Row background + bg = (45, 52, 64) if is_header else ((32, 38, 48) if ri % 2 == 1 else (38, 44, 54)) + + # Draw row background + if ri == 0: + draw.rounded_rectangle([x, current_y, x + table_w, current_y + row_h], radius=8, fill=bg) + draw.rectangle([x, current_y + row_h - 8, x + table_w, current_y + row_h], fill=bg) + else: + # Check if this is the last visible row + next_row_visible = False + if ri + 1 < len(rows): + next_orig_idx = row_line_indices[ri + 1] + next_row_pos = row_positions[next_orig_idx] if next_orig_idx < len(row_positions) else float('inf') + next_row_visible = (typewriter_pos == float('inf') or next_row_pos < typewriter_pos) + + if not next_row_visible or ri == len(rows) - 1: + # This is the last visible row - use rounded bottom + draw.rounded_rectangle([x, current_y, x + table_w, current_y + row_h], radius=8, fill=bg) + draw.rectangle([x, current_y, x + table_w, current_y + 8], fill=bg) + else: + draw.rectangle([x, current_y, x + table_w, current_y + row_h], fill=bg) + + # Draw cells with typewriter support + cell_x = x + for ci in range(num_cols): + cell_text = row[ci].strip() if ci < len(row) else '' + cell_w = col_widths[ci] + content_w = cell_w - cell_padding * 2 + + # Calculate cell position in original text for typewriter + # Approximate: row_start + position within row + cell_start_approx = row_start_pos + sum(len(row[j]) + 1 for j in range(ci) if j < len(row)) + + # Render cell with typewriter effect + self._render_cell_content_with_pos( + draw, cell_text, cell_x + cell_padding, current_y + 5, + content_w, line_h + 6, current_font, text_color, + cell_start_approx, typewriter_pos + ) + + # Column separator + if ci < num_cols - 1: + sep_x = cell_x + cell_w + draw.line([(sep_x, current_y + 4), (sep_x, current_y + row_h - 4)], + fill=(55, 62, 74), width=1) + + cell_x += cell_w + + last_visible_y = current_y + row_h + current_y += row_h + + # Outer border (only around visible portion) + if visible_rows > 0: + draw.rounded_rectangle([x, y, x + table_w, last_visible_y], radius=8, outline=(55, 62, 74), width=1) + + return last_visible_y + 10 if visible_rows > 0 else y + + def _render_cell_content_with_pos(self, draw, text: str, x: int, y: int, max_width: int, + line_h: int, base_font, base_color: Tuple, + cell_start_pos: int, typewriter_pos: float): + """Render cell content with position-based typewriter effect.""" + if not text: + return + + # Skip if typewriter hasn't reached this cell + if typewriter_pos != float('inf') and cell_start_pos >= typewriter_pos: + return + + tokens = self.tokenize_inline(text) + render_x = x + render_y = y + + # Adjust token positions to absolute + for token in tokens: + token['start'] = token.get('start', 0) + cell_start_pos + token['end'] = token.get('end', 0) + cell_start_pos + token['content_start'] = token.get('content_start', token['start']) + cell_start_pos + token['content_end'] = token.get('content_end', token['end']) + cell_start_pos + + for token in tokens: + token_start = token.get('start', 0) + content_start = token.get('content_start', token_start) + content_end = token.get('content_end', token.get('end', token_start)) + + # Skip tokens not yet reached + if typewriter_pos != float('inf') and token_start >= typewriter_pos: + break + + ttype = token['type'] + + # Get font and color + if ttype == 'bold': + tfont = self.fonts.get('bold', base_font) + tcolor = base_color + elif ttype == 'italic': + tfont = self.fonts.get('italic', base_font) + tcolor = base_color + elif ttype == 'bold_italic': + tfont = self.fonts.get('bold_italic', base_font) + tcolor = base_color + elif ttype == 'code': + tfont = self.fonts.get('code', base_font) + tcolor = self.colors['accent'] + elif ttype == 'strike': + tfont = base_font + tcolor = (110, 118, 129) + elif ttype in ('link', 'image'): + tfont = base_font + tcolor = self.colors['accent'] + elif ttype == 'emoji': + tfont = self.fonts.get('emoji', self.fonts['normal']) + tcolor = base_color + else: + tfont = base_font + tcolor = base_color + + is_code = (ttype == 'code') + is_strike = (ttype == 'strike') + + # Get display text + if ttype == 'link': + display_text = token.get('text', '') + elif ttype == 'image': + display_text = token.get('alt', 'img') + elif ttype == 'checkbox': + display_text = '\u2611' if token.get('checked') else '\u2610' # ☑ ☐ + else: + display_text = token.get('text', '') + + # Calculate visible portion + visible_chars = len(display_text) + if typewriter_pos != float('inf'): + if typewriter_pos <= content_start: + visible_chars = 0 + elif typewriter_pos >= content_end: + visible_chars = len(display_text) + else: + content_len = content_end - content_start + if content_len > 0: + pos_in_content = typewriter_pos - content_start + visible_chars = int(pos_in_content * len(display_text) / content_len) + + if visible_chars <= 0: + continue + + visible_text = display_text[:visible_chars] + + # Render word by word for proper wrapping + words = visible_text.split(' ') + space_w, _ = self._get_text_size(' ', tfont) + + for i, word in enumerate(words): + if not word and i > 0: + render_x += space_w + continue + + # Handle space before word + if i > 0: + if render_x + space_w > x + max_width and render_x > x: + render_y += line_h + 4 + render_x = x + else: + render_x += space_w + + word_w, word_h = self._get_text_size(word, tfont) + + # Check if word fits on current line + if render_x + word_w > x + max_width and render_x > x: + render_y += line_h + 4 + render_x = x + + # Draw code background + if is_code and word.strip(): + draw.rounded_rectangle( + [render_x - 1, render_y - 1, render_x + word_w + 1, render_y + word_h + 1], + radius=2, fill=(40, 46, 56, 200) + ) + + # Draw word (use embedded_color for colored emoji rendering) + # Emojis need vertical offset to align with text baseline + is_emoji = (ttype == 'emoji') + emoji_y_offset = 7 if is_emoji else 0 + if is_emoji and self.color_emojis: + draw.text((render_x, render_y + emoji_y_offset), word, fill=tcolor, font=tfont, embedded_color=True) + else: + draw.text((render_x, render_y), word, fill=tcolor, font=tfont) + + # Strikethrough + if is_strike: + sy = render_y + word_h // 2 + draw.line([(render_x, sy), (render_x + word_w, sy)], fill=tcolor, width=1) + + render_x += word_w + + def _count_wrapped_lines_breaking(self, text: str, font, max_width: int) -> int: + """Count lines needed when breaking mid-word is allowed but word-wrap is preferred.""" + if not text or max_width <= 0: + return 1 + + # Strip markdown for sizing + plain = re.sub(r'\*{1,3}|`|~~|\[.*?]\(.*?\)|!\[.*?]\(.*?\)', '', text) + + lines = 1 + current_width = 0 + space_width, _ = self._get_text_size(' ', font) + + words = plain.split(' ') + + for i, word in enumerate(words): + word_w, _ = self._get_text_size(word, font) + + # Handle space before word + if i > 0: + if current_width + space_width > max_width and current_width > 0: + lines += 1 + current_width = 0 + else: + current_width += space_width + + # Handle word + if current_width + word_w > max_width and current_width > 0: + lines += 1 + current_width = 0 + + if word_w > max_width: + # Word is too long, must break it + for char in word: + char_w, _ = self._get_text_size(char, font) + if current_width + char_w > max_width: + lines += 1 + current_width = char_w + else: + current_width += char_w + else: + current_width += word_w + + return max(1, lines) + + def _calculate_wrapped_lines(self, text: str, font, max_width: int) -> int: + """Calculate how many lines the text will need when wrapped to max_width.""" + if not text or max_width <= 0: + return 1 + + # Strip markdown syntax for width calculation + plain_text = re.sub(r'\*{1,3}|`|~~|\[.*?]\(.*?\)|!\[.*?]\(.*?\)', '', text) + + words = plain_text.split() + if not words: + return 1 + + lines = 1 + current_width = 0 + space_width, _ = self._get_text_size(' ', font) + + for word in words: + word_width, _ = self._get_text_size(word, font) + + if current_width == 0: + current_width = word_width + elif current_width + space_width + word_width <= max_width: + current_width += space_width + word_width + else: + lines += 1 + current_width = word_width + + return max(1, lines) + + def _render_ul(self, draw, canvas, block: Dict, x: int, y: int, width: int) -> int: + """Render unordered list with typewriter support.""" + items = block.get('items', []) if isinstance(block, dict) else block + block_start = block.get('start', 0) if isinstance(block, dict) else 0 + + current_y = y + bullets = ['\u2022', '\u25E6', '\u25AA', '\u2023'] # • ◦ ▪ ‣ + + # Track position within block for typewriter + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + item_offset = 0 # Track cumulative offset within block + + for item in items: + indent = item.get('indent', 0) + text = item['text'] + + # Calculate item start position in original text + # Format: "- text\n" or " - text\n" for indented + item_start = block_start + item_offset + item_marker_end = item_start + 2 + (indent * 2) # "- " or " - " etc. + + # Skip this item entirely if typewriter hasn't reached it yet + if typewriter_pos != float('inf') and item_start >= typewriter_pos: + break + + indent_px = indent * 20 + bullet = bullets[min(indent, len(bullets) - 1)] + bullet_x = x + indent_px + text_x = bullet_x + 16 + + # Only draw bullet if typewriter has reached at least the marker + if typewriter_pos == float('inf') or typewriter_pos > item_start: + bullet_font = self.fonts.get('bold', self.fonts['normal']) + draw.text((bullet_x, current_y), bullet, fill=self.colors['accent'], font=bullet_font) + + # Text with inline formatting + tokens = self.tokenize_inline(text) + + # Adjust token positions to be absolute (relative to item_marker_end) + if typewriter_pos != float('inf'): + for token in tokens: + token['start'] = token.get('start', 0) + item_marker_end + token['end'] = token.get('end', 0) + item_marker_end + token['content_start'] = token.get('content_start', token['start']) + item_marker_end + token['content_end'] = token.get('content_end', token['end']) + item_marker_end + + # Update offset for next item: "- " (2) + text + "\n" (1) + item_offset += 2 + (indent * 2) + len(text) + 1 + + current_y = self._render_tokens(draw, tokens, text_x, current_y, width - (text_x - x)) + + return current_y + 2 + + def _render_ol(self, draw, canvas, block: Dict, x: int, y: int, width: int) -> int: + """Render ordered list with typewriter support.""" + items = block.get('items', []) if isinstance(block, dict) else block + block_start = block.get('start', 0) if isinstance(block, dict) else 0 + + current_y = y + + # Track position within block for typewriter + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + item_offset = 0 + + for item in items: + indent = item.get('indent', 0) + num = item.get('num', '1') + text = item['text'] + + # Styled number + num_text = num + if not num_text.endswith('.'): + num_text += '.' + + # Calculate item start position in original text + # Format: "1. text\n" or " 1. text\n" for indented + item_start = block_start + item_offset + item_marker_end = item_start + len(num_text) + 1 + (indent * 3) # "1. " + indent + + # Skip this item entirely if typewriter hasn't reached it yet + if typewriter_pos != float('inf') and item_start >= typewriter_pos: + break + + indent_px = indent * 20 + num_x = x + indent_px + + num_font = self.fonts.get('bold', self.fonts['normal']) + nw, nh = self._get_text_size(num_text, num_font) + + # Only draw number if typewriter has reached at least the marker + if typewriter_pos == float('inf') or typewriter_pos > item_start: + draw.text((num_x, current_y), num_text, fill=self.colors['accent'], font=num_font) + + text_x = num_x + nw + 8 + + # Text with inline formatting + tokens = self.tokenize_inline(text) + + # Adjust token positions to be absolute + if typewriter_pos != float('inf'): + for token in tokens: + token['start'] = token.get('start', 0) + item_marker_end + token['end'] = token.get('end', 0) + item_marker_end + token['content_start'] = token.get('content_start', token['start']) + item_marker_end + token['content_end'] = token.get('content_end', token['end']) + item_marker_end + + # Update offset for next item: "1. " + text + "\n" + item_offset += len(num_text) + 1 + (indent * 3) + len(text) + 1 + + current_y = self._render_tokens(draw, tokens, text_x, current_y, width - (text_x - x)) + + return current_y + 2 + + def _render_tokens(self, draw, tokens: List[Dict], x: int, y: int, width: int, + override_color: Tuple = None, override_font = None, max_chars: int = None, + header_level: int = None) -> int: + """Render inline tokens with word wrapping and styling. + + The typewriter effect uses 'remaining_chars' as a position in the ORIGINAL text. + Each token has 'start', 'end', 'content_start', and 'content_end' positions that + map to the original text, allowing correct character-by-character reveal even + with markdown syntax characters. + + Args: + max_chars: If provided, limits the number of visible characters (overrides remaining_chars) + header_level: If provided (1-6), uses appropriately sized emoji font for headers + """ + current_x = x + current_y = y + + # Get actual line height from font metrics + base_font = override_font if override_font else self.fonts['normal'] + _, base_h = self._get_text_size('Ay', base_font) # Use 'Ay' to get proper ascender/descender height + line_h = base_h + + # Get the typewriter limit (position in original text) + # Use max_chars if provided, otherwise use remaining_chars + if max_chars is not None: + typewriter_pos = max_chars + else: + typewriter_pos = getattr(self, 'remaining_chars', float('inf')) + + for token in tokens: + # Get token positions in original text + token_start = token.get('start', 0) + token_end = token.get('end', token_start + len(token.get('text', ''))) + content_start = token.get('content_start', token_start) + content_end = token.get('content_end', token_end) + + # Skip tokens that haven't been reached yet by typewriter + if typewriter_pos != float('inf') and token_start >= typewriter_pos: + break + + ttype = token['type'] + + # Determine font - emoji always uses emoji font regardless of override + # Use header-sized emoji font when rendering in headers + if ttype == 'emoji': + if header_level and header_level in range(1, 7): + emoji_font_key = f'emoji_h{header_level}' + font = self.fonts.get(emoji_font_key, self.fonts.get('emoji', self.fonts['normal'])) + else: + font = self.fonts.get('emoji', self.fonts['normal']) + elif override_font: + font = override_font + elif ttype == 'bold': + font = self.fonts.get('bold', self.fonts['normal']) + elif ttype == 'italic': + font = self.fonts.get('italic', self.fonts['normal']) + elif ttype == 'bold_italic': + font = self.fonts.get('bold_italic', self.fonts['normal']) + elif ttype == 'code': + font = self.fonts.get('code', self.fonts['normal']) + else: + font = self.fonts['normal'] + + # Determine color + if override_color: + color = override_color + elif ttype == 'code': + color = self.colors['accent'] + elif ttype in ('link', 'image'): + color = self.colors['accent'] + elif ttype == 'strike': + color = (140, 148, 160) + elif ttype == 'math': + color = (255, 203, 107) + else: + color = self.colors['text'] + + # Get display text - links show only the title, not the URL + if ttype == 'link': + text = token['text'] # Just the link title, no emoji or URL + elif ttype == 'image': + # Try to load and render the actual image + image_url = token.get('url', '') + alt_text = token.get('alt', 'image') + max_img_width = width - 10 # Leave some margin + + # Try to load the image + loaded_img = self._load_image(image_url, max_img_width) if image_url else None + + if loaded_img is not None: + # Successfully loaded image - render it + img_width, img_height = loaded_img.size + + # Check if image fits on current line, if not move to next line + if current_x > x and current_x + img_width > x + width: + current_y += line_h + 10 + current_x = x + + # We need access to the canvas to paste the image + # The 'draw' object has a reference to the image via draw._image (internal) + # or we can use the canvas passed to render() + try: + canvas = draw._image + # Paste the image onto the canvas + canvas.paste(loaded_img, (int(current_x), int(current_y)), loaded_img) + + # Update position + current_y += img_height + 8 + current_x = x + line_h = base_h # Reset line height + + # Optionally show alt text below image if present + if alt_text and alt_text != 'image': + alt_font = self.fonts.get('italic', self.fonts['normal']) + alt_color = (140, 148, 160) # Gray for caption + draw.text((current_x, current_y), alt_text, fill=alt_color, font=alt_font) + _, alt_h = self._get_text_size(alt_text, alt_font) + current_y += alt_h + 6 + + continue # Skip normal text rendering + except Exception: + pass # Fall back to icon rendering + + # Fallback: render icon with alt text (image couldn't be loaded) + icon_size = int(base_h * 0.9) + icon_y = current_y + int((base_h - icon_size) / 2) + 3 + icon_color = self.colors['accent'] + + # Draw image frame (rounded rectangle) + draw.rounded_rectangle( + [current_x, icon_y, current_x + icon_size, icon_y + icon_size], + radius=2, fill=None, outline=icon_color, width=1 + ) + + # Draw mountain/landscape symbol inside (simplified image icon) + # Bottom triangle (mountain) + m_left = current_x + icon_size * 0.15 + m_right = current_x + icon_size * 0.85 + m_bottom = icon_y + icon_size * 0.8 + m_peak = icon_y + icon_size * 0.35 + m_mid = current_x + icon_size * 0.5 + draw.polygon( + [(m_left, m_bottom), (m_mid, m_peak), (m_right, m_bottom)], + fill=icon_color + ) + + # Small sun/circle in top right + sun_r = icon_size * 0.12 + sun_cx = current_x + icon_size * 0.72 + sun_cy = icon_y + icon_size * 0.28 + draw.ellipse( + [sun_cx - sun_r, sun_cy - sun_r, sun_cx + sun_r, sun_cy + sun_r], + fill=icon_color + ) + + current_x += icon_size + 4 + + # Now render the alt text after the icon + if alt_text: + text = alt_text + color = self.colors['accent'] + else: + continue # No alt text, just show icon + elif ttype == 'checkbox': + # Render custom checkbox graphics instead of font characters + checkbox_size = int(base_h * 0.95) # 95% of font height + checkbox_y = current_y + int((base_h - checkbox_size) / 2) + 4 # Move down + + if token['checked']: + # Draw rounded rectangle outline with checkmark (no fill) + check_color = self.colors['accent'] # Use configured accent color + draw.rounded_rectangle( + [current_x, checkbox_y, current_x + checkbox_size, checkbox_y + checkbox_size], + radius=3, fill=None, outline=check_color, width=2 + ) + # Draw checkmark (two lines forming a check) + cx, cy = current_x, checkbox_y + # Checkmark points - slightly larger and better positioned + p1 = (cx + checkbox_size * 0.18, cy + checkbox_size * 0.5) + p2 = (cx + checkbox_size * 0.4, cy + checkbox_size * 0.78) + p3 = (cx + checkbox_size * 0.85, cy + checkbox_size * 0.22) + draw.line([p1, p2], fill=check_color, width=2) + draw.line([p2, p3], fill=check_color, width=2) + else: + # Draw empty rounded rectangle + box_color = (140, 148, 160) # Gray + draw.rounded_rectangle( + [current_x, checkbox_y, current_x + checkbox_size, checkbox_y + checkbox_size], + radius=3, fill=None, outline=box_color, width=1 + ) + + current_x += checkbox_size + 6 # Move past checkbox with spacing + continue # Skip normal text rendering for checkbox + elif ttype == 'footnote_ref': + # Render footnote reference as superscript number/text in brackets + fn_id = token.get('id', '?') + fn_text = f'[{fn_id}]' + + # Use smaller font size for superscript effect + fn_font = self.fonts.get('code', self.fonts['normal']) + fn_color = self.colors['accent'] + + # Get text size + fn_w, fn_h = self._get_text_size(fn_text, fn_font) + + # Draw slightly lower (moved down) + fn_y = current_y + 5 + + # Draw the footnote reference + draw.text((current_x, fn_y), fn_text, fill=fn_color, font=fn_font) + + current_x += fn_w + 2 + continue # Skip normal text rendering + elif ttype == 'math': + text = f"\u222B {token['text']}" # ∫ integral symbol + else: + text = token.get('text', '') + + if not text: + continue + + # Calculate how much of this token's content should be shown + # based on typewriter position in original text + visible_chars = len(text) # Default: show all + fading_char_info = None # (char, alpha) for the character being typed + + if typewriter_pos != float('inf'): + if typewriter_pos <= content_start: + # Typewriter hasn't reached the content yet (might be in opening syntax) + visible_chars = 0 + elif typewriter_pos >= content_end: + # Entire content is visible + visible_chars = len(text) + else: + # Partial content visible + # Map typewriter position to content position + content_len = content_end - content_start + text_len = len(text) + + if content_len > 0: + # Position within content + pos_in_content = typewriter_pos - content_start + # Scale to text length (handles special tokens like image/checkbox) + visible_chars = int(pos_in_content * text_len / content_len) + + # Calculate fading character + fraction = (pos_in_content * text_len / content_len) - visible_chars + if fraction > 0 and visible_chars < text_len: + fading_char_info = (text[visible_chars], int(fraction * 255)) + else: + visible_chars = 0 + + # Skip if nothing to show + if visible_chars <= 0 and fading_char_info is None: + continue + + # For inline code, render as single unit (don't split on spaces) + if ttype == 'code': + # Add spacing before inline code if not at start of line + if current_x > x: + current_x += 4 # Extra space before code block + + w, h = self._get_text_size(text, font) + pad_x = 4 + pad_y = 3 + # Align code baseline with surrounding text (move down 7 pixels) + code_y_offset = 7 + available_width = x + width - current_x - pad_x * 2 + + # Check if code fits on current line + if w <= available_width or current_x == x: + # Code fits or we're at start of line - render normally + if w > available_width and current_x > x: + # Move to next line first + current_y += line_h + 10 + current_x = x + available_width = width - pad_x * 2 + + # If still too wide, we need to wrap the code itself + if w > available_width: + # Wrap inline code at character boundaries + code_lines = self._wrap_inline_code(text, font, available_width) + chars_consumed = 0 + for ci, code_line in enumerate(code_lines): + # Calculate how much of this line to show + line_visible = min(len(code_line), max(0, visible_chars - chars_consumed)) + if line_visible <= 0: + break + + display_line = code_line[:line_visible] + chars_consumed += len(code_line) + + cw, ch = self._get_text_size(display_line, font) + + # Draw code background pill - vertically centered with text + bg_top = current_y + code_y_offset - pad_y + bg_bottom = current_y + ch + code_y_offset + pad_y + draw.rounded_rectangle( + [current_x - pad_x, bg_top, + current_x + cw + pad_x, bg_bottom], + radius=4, fill=(40, 46, 56, 240), outline=(60, 68, 80) + ) + # Draw code text with emoji support (emojis shifted left for inline code) + self._render_code_line_with_emoji(draw, display_line, current_x, current_y + code_y_offset, color, font, emoji_x_offset=-4) + + if ci < len(code_lines) - 1 and line_visible >= len(code_line): + # Move to next line for continuation + current_y += line_h + 2 + current_x = x + else: + current_x += cw + pad_x * 2 + 4 + + line_h = max(line_h, h + pad_y * 2) + continue + + # Normal single-line code rendering + display_text = text[:visible_chars] if visible_chars < len(text) else text + + # Recalculate width for partial text + display_w = w + if len(display_text) < len(text): + display_w, _ = self._get_text_size(display_text, font) + + # Draw code background pill - vertically centered with text + bg_top = current_y + code_y_offset - pad_y + bg_bottom = current_y + h + code_y_offset + pad_y + draw.rounded_rectangle( + [current_x - pad_x, bg_top, + current_x + display_w + pad_x, bg_bottom], + radius=4, fill=(40, 46, 56, 240), outline=(60, 68, 80) + ) + self._render_code_line_with_emoji(draw, display_text, current_x, current_y + code_y_offset, color, font, emoji_x_offset=-4) + + # Draw fading character for code + if fading_char_info: + fade_char, fade_alpha = fading_char_info + fade_color = color[:3] + (fade_alpha,) if len(color) >= 3 else color + fcw, _ = self._get_text_size(fade_char, font) + draw.text((current_x + display_w, current_y + code_y_offset), fade_char, fill=fade_color, font=font) + display_w += fcw + + current_x += display_w + pad_x * 2 + 4 + line_h = max(line_h, h + pad_y * 2) + else: + # Move to next line and try again + current_y += line_h + 10 + current_x = x + available_width = width - pad_x * 2 + + if w > available_width: + # Still need to wrap + code_lines = self._wrap_inline_code(text, font, available_width) + chars_consumed = 0 + for ci, code_line in enumerate(code_lines): + line_visible = min(len(code_line), max(0, visible_chars - chars_consumed)) + if line_visible <= 0: + break + + display_line = code_line[:line_visible] + chars_consumed += len(code_line) + + cw, ch = self._get_text_size(display_line, font) + bg_top = current_y + code_y_offset - pad_y + bg_bottom = current_y + ch + code_y_offset + pad_y + draw.rounded_rectangle( + [current_x - pad_x, bg_top, + current_x + cw + pad_x, bg_bottom], + radius=4, fill=(40, 46, 56, 240), outline=(60, 68, 80) + ) + self._render_code_line_with_emoji(draw, display_line, current_x, current_y + code_y_offset, color, font, emoji_x_offset=-4) + if ci < len(code_lines) - 1 and line_visible >= len(code_line): + current_y += line_h + 2 + current_x = x + else: + current_x += cw + pad_x * 2 + 4 + else: + display_text = text[:visible_chars] if visible_chars < len(text) else text + display_w = w + if len(display_text) < len(text): + display_w, _ = self._get_text_size(display_text, font) + + bg_top = current_y + code_y_offset - pad_y + bg_bottom = current_y + h + code_y_offset + pad_y + draw.rounded_rectangle( + [current_x - pad_x, bg_top, + current_x + display_w + pad_x, bg_bottom], + radius=4, fill=(40, 46, 56, 240), outline=(60, 68, 80) + ) + self._render_code_line_with_emoji(draw, display_text, current_x, current_y + code_y_offset, color, font, emoji_x_offset=-4) + + # Draw fading character + if fading_char_info: + fade_char, fade_alpha = fading_char_info + fade_color = color[:3] + (fade_alpha,) if len(color) >= 3 else color + fcw, _ = self._get_text_size(fade_char, font) + draw.text((current_x + display_w, current_y + code_y_offset), fade_char, fill=fade_color, font=font) + display_w += fcw + + current_x += display_w + pad_x * 2 + 4 + + line_h = max(line_h, h + pad_y * 2) + + continue + + # Word wrap and render for other token types + # We need to track position within the visible portion of text + visible_text = text[:visible_chars] if visible_chars < len(text) else text + words = visible_text.split(' ') if ' ' in visible_text else [visible_text] + + # Check if we need to add a fading character after the last word + need_fading = fading_char_info is not None + + # For strikethrough, calculate consistent line height based on font + # Use base_h which is computed from 'Ay' for consistent baseline + # Position at 70% for lower placement + strike_y_offset = int(base_h * 0.70) + + # Track start position for continuous strikethrough + strike_start_x = current_x if ttype == 'strike' else None + + for wi, word in enumerate(words): + if not word and wi > 0: + # Just a space - still advance position (strikethrough will cover it) + space_w, _ = self._get_text_size(' ', font) + current_x += space_w + continue + + # Add space before word if needed + if wi > 0 and current_x > x: + space_w, _ = self._get_text_size(' ', font) + current_x += space_w + + # Use emoji font for width calculation if this is an emoji + width_font = self.fonts.get('emoji', font) if ttype == 'emoji' else font + w, h = self._get_text_size(word, width_font) + # Reduce emoji width to avoid extra spacing + # Emojis with variation selectors (U+FE0F) need more reduction + if ttype == 'emoji': + has_variation_selector = '\ufe0f' in word + if has_variation_selector: + w = int(w * 0.55) # Aggressive reduction for variation selector emojis + else: + w = int(w * 0.85) + line_h = max(line_h, h) + + # Wrap to next line if needed + if current_x + w > x + width and current_x > x: + # Draw strikethrough for current line before wrapping + if ttype == 'strike' and strike_start_x is not None and current_x > strike_start_x: + sy = current_y + strike_y_offset + strike_color = (160, 168, 180) + draw.line([(strike_start_x, sy), (current_x, sy)], fill=strike_color, width=1) + + current_y += line_h + 10 # Line spacing + current_x = x + line_h = h + + # Reset strike start for new line + strike_start_x = current_x if ttype == 'strike' else None + + # Draw the word (emojis need vertical offset to align with text baseline) + # In headlines, emojis also need a left offset to avoid collision with following text + emoji_y_offset = 7 if ttype == 'emoji' else 0 + emoji_x_offset = -4 if (ttype == 'emoji' and header_level) else 0 + self._draw_text_with_spacing( + draw, (current_x + emoji_x_offset, current_y + emoji_y_offset), word, fill=color, font=font, + embedded_color=(ttype == 'emoji') + ) + current_x += w + + # Draw continuous strikethrough line at the end (covers all words and spaces) + if ttype == 'strike' and strike_start_x is not None and current_x > strike_start_x: + sy = current_y + strike_y_offset + strike_color = (160, 168, 180) + draw.line([(strike_start_x, sy), (current_x, sy)], fill=strike_color, width=1) + + # Draw fading character after all visible words if needed + if need_fading and fading_char_info: + fade_char, fade_alpha = fading_char_info + + # Handle space before fading char if the visible text ended with space + if visible_text and visible_text[-1] == ' ': + # The fading char starts a new word after a space + pass # Space already added in loop + elif visible_chars > 0 and visible_chars < len(text) and text[visible_chars - 1] != ' ' and fade_char != ' ': + # Fading char is part of current word, no extra space needed + pass + elif fade_char == ' ': + # The fading char is itself a space + space_w, _ = self._get_text_size(' ', font) + # Render fading space (essentially invisible but we track position) + current_x += int(space_w * fade_alpha / 255) + # Draw fading strikethrough over the space + if ttype == 'strike': + sy = current_y + strike_y_offset + strike_color = (160, 168, 180, fade_alpha) + draw.line([(current_x - int(space_w * fade_alpha / 255), sy), (current_x, sy)], fill=strike_color, width=1) + continue + + fade_color = color[:3] + (fade_alpha,) if len(color) >= 3 else color + fcw, fch = self._get_text_size(fade_char, font) + + # Check if we need to wrap before fading char + if current_x + fcw > x + width and current_x > x: + current_y += line_h + 10 + current_x = x + line_h = fch + + self._draw_text_with_spacing(draw, (current_x, current_y), fade_char, fill=fade_color, font=font) + + # Strikethrough for fading char - extends from current position + if ttype == 'strike': + sy = current_y + strike_y_offset + strike_color = (160, 168, 180, fade_alpha) + draw.line([(current_x, sy), (current_x + fcw, sy)], fill=strike_color, width=1) + + + current_x += fcw + + # Return Y position after the last line of text (not adding full line_h again) + return current_y + line_h + 10 + + +# ============================================================================= +# HEADS UP OVERLAY CLASS +# ============================================================================= + diff --git a/hud_server/server.py b/hud_server/server.py new file mode 100644 index 000000000..246b1a149 --- /dev/null +++ b/hud_server/server.py @@ -0,0 +1,692 @@ +""" +HUD Server - FastAPI-based HTTP server for HUD overlay control. + +This server provides a REST API to control HUD overlays from any client. +It runs in its own thread with its own event loop. +""" + +import asyncio +import threading +import queue +from typing import Optional, Any +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +import uvicorn +from uvicorn import Server, Config + +from fastapi.exceptions import RequestValidationError +from starlette.requests import Request +from starlette.responses import JSONResponse + +from api.enums import LogType +from services.printr import Printr +from hud_server.hud_manager import HudManager +from hud_server.models import ( + CreateGroupRequest, + UpdateGroupRequest, + MessageRequest, + AppendMessageRequest, + LoaderRequest, + ItemRequest, + UpdateItemRequest, + ProgressRequest, + TimerRequest, + ChatMessageRequest, + CreateChatWindowRequest, + StateRestoreRequest, + HealthResponse, + GroupStateResponse, + OperationResponse, +) + +# Try to import overlay support (bundled with hud_server) +OVERLAY_AVAILABLE = False +HeadsUpOverlay = None +PIL_AVAILABLE = False + +try: + from hud_server.overlay.overlay import HeadsUpOverlay as _HeadsUpOverlay, PIL_AVAILABLE as _PIL_AVAILABLE + OVERLAY_AVAILABLE = _PIL_AVAILABLE and _HeadsUpOverlay is not None + HeadsUpOverlay = _HeadsUpOverlay + PIL_AVAILABLE = _PIL_AVAILABLE +except ImportError: + pass + +printr = Printr() + + +class HudServer: + """ + HTTP-based HUD Server running in its own thread. + + Provides REST API endpoints for controlling HUD overlays. + Starts fresh on each launch - clients can use state/restore endpoints + to persist and restore their own state. + """ + + VERSION = "1.0.0" + + def __init__(self): + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._server: Optional[Server] = None + self._running = False + self._host = "127.0.0.1" + self._port = 7862 + self._framerate = 60 + self._layout_margin = 20 + self._layout_spacing = 15 + + # HUD state manager + self.manager = HudManager() + + # Overlay support (optional) + self._overlay = None + self._overlay_thread: Optional[threading.Thread] = None + self._command_queue: Optional[queue.Queue] = None + self._error_queue: Optional[queue.Queue] = None + + # Create FastAPI app + self.app = self._create_app() + + def _create_app(self) -> FastAPI: + """Create and configure the FastAPI application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + # Startup + self._start_overlay() + yield + # Shutdown + self._stop_overlay() + + app = FastAPI( + title="HUD Server", + description="HTTP API for controlling HUD overlays", + version=self.VERSION, + lifespan=lifespan + ) + + # Enable CORS for browser-based clients (OBS Browser Source, web overlays) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Register error handlers for logging invalid requests + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Log validation errors for invalid request data.""" + path = request.url.path + method = request.method + errors = exc.errors() + error_details = [f"{e.get('loc', ['?'])}: {e.get('msg', 'unknown error')}" for e in errors] + printr.print( + f"[HUD Server] Invalid request data on {method} {path}: {'; '.join(error_details)}", + color=LogType.WARNING, + server_only=True + ) + return JSONResponse( + status_code=422, + content={"status": "error", "message": "Validation error", "detail": errors} + ) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """Log HTTP exceptions (404, etc.).""" + path = request.url.path + method = request.method + printr.print( + f"[HUD Server] {exc.status_code} on {method} {path}: {exc.detail}", + color=LogType.WARNING, + server_only=True + ) + return JSONResponse( + status_code=exc.status_code, + content={"status": "error", "message": exc.detail} + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + """Log unexpected exceptions.""" + path = request.url.path + method = request.method + printr.print( + f"[HUD Server] Unexpected error on {method} {path}: {type(exc).__name__}: {exc}", + color=LogType.ERROR, + server_only=True + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "Internal server error", "detail": str(exc)} + ) + + # Register routes + self._register_routes(app) + + return app + + def _register_routes(self, app: FastAPI): + """Register all API routes.""" + + # ─────────────────────────────── Health ─────────────────────────────── # + + @app.get("/health", response_model=HealthResponse, tags=["health"]) + async def health_check(): + """Check server health and get list of active groups.""" + return HealthResponse( + status="healthy", + groups=self.manager.get_groups(), + version=self.VERSION + ) + + @app.get("/", response_model=HealthResponse, tags=["health"]) + async def root(): + """Root endpoint - same as health check.""" + return await health_check() + + # ─────────────────────────────── Groups ─────────────────────────────── # + + @app.post("/groups", response_model=OperationResponse, tags=["groups"]) + async def create_group(request: CreateGroupRequest): + """Create or update a HUD group.""" + self.manager.create_group(request.group_name, request.props) + return OperationResponse(status="ok", message=f"Group '{request.group_name}' created") + + @app.put("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) + async def update_group(group_name: str, request: UpdateGroupRequest): + """Update properties of an existing group.""" + if not self.manager.update_group(group_name, request.props): + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok", message=f"Group '{group_name}' updated") + + @app.patch("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) + async def patch_group(group_name: str, request: UpdateGroupRequest): + """Update properties of an existing group (PATCH).""" + printr.print( + f"[HUD Server] PATCH /groups/{group_name}: props keys={list(request.props.keys()) if request.props else []}", + color=LogType.INFO, + server_only=True + ) + if request.props and 'width' in request.props: + printr.print( + f"[HUD Server] PATCH /groups/{group_name}: width={request.props['width']}", + color=LogType.INFO, + server_only=True + ) + result = self.manager.update_group(group_name, request.props) + printr.print( + f"[HUD Server] PATCH /groups/{group_name}: manager.update_group returned {result}", + color=LogType.INFO, + server_only=True + ) + if not result: + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok", message=f"Group '{group_name}' updated") + + @app.delete("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) + async def delete_group(group_name: str): + """Delete a HUD group.""" + if not self.manager.delete_group(group_name): + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok", message=f"Group '{group_name}' deleted") + + @app.get("/groups", tags=["groups"]) + async def list_groups(): + """Get list of all group names.""" + return {"groups": self.manager.get_groups()} + + # ─────────────────────────────── State ─────────────────────────────── # + + @app.get("/state/{group_name}", response_model=GroupStateResponse, tags=["state"]) + async def get_state(group_name: str): + """Get the current state of a group for persistence.""" + state = self.manager.get_group_state(group_name) + if state is None: + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return GroupStateResponse(group_name=group_name, state=state) + + @app.post("/state/restore", response_model=OperationResponse, tags=["state"]) + async def restore_state(request: StateRestoreRequest): + """Restore a group's state from a previous snapshot.""" + self.manager.restore_group_state(request.group_name, request.state) + return OperationResponse(status="ok", message=f"State restored for '{request.group_name}'") + + # ─────────────────────────────── Messages ─────────────────────────────── # + + @app.post("/message", response_model=OperationResponse, tags=["messages"]) + async def show_message(request: MessageRequest): + """Show a message in a HUD group.""" + printr.print( + f"[HUD Server] show_message called for group '{request.group_name}'", + color=LogType.INFO, + server_only=True + ) + self.manager.show_message( + group_name=request.group_name, + title=request.title, + content=request.content, + color=request.color, + tools=request.tools, + props=request.props, + duration=request.duration + ) + return OperationResponse(status="ok") + + @app.post("/message/append", response_model=OperationResponse, tags=["messages"]) + async def append_message(request: AppendMessageRequest): + """Append content to the current message (for streaming).""" + if not self.manager.append_message(request.group_name, request.content): + raise HTTPException(status_code=404, detail=f"Group '{request.group_name}' not found") + return OperationResponse(status="ok") + + @app.post("/message/hide/{group_name}", response_model=OperationResponse, tags=["messages"]) + async def hide_message(group_name: str): + """Hide the current message in a group.""" + if not self.manager.hide_message(group_name): + available_groups = self.manager.get_groups() + printr.print( + f"[HUD Server] hide_message failed: group '{group_name}' not found. " + f"Available groups: {available_groups}", + color=LogType.WARNING, + server_only=True + ) + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok") + + # ─────────────────────────────── Loader ─────────────────────────────── # + + @app.post("/loader", response_model=OperationResponse, tags=["loader"]) + async def set_loader(request: LoaderRequest): + """Show or hide the loader animation.""" + self.manager.set_loader(request.group_name, request.show, request.color) + return OperationResponse(status="ok") + + # ─────────────────────────────── Items ─────────────────────────────── # + + @app.post("/items", response_model=OperationResponse, tags=["items"]) + async def add_item(request: ItemRequest): + """Add a persistent item to a group.""" + self.manager.add_item( + group_name=request.group_name, + title=request.title, + description=request.description, + color=request.color, + duration=request.duration + ) + return OperationResponse(status="ok") + + @app.put("/items", response_model=OperationResponse, tags=["items"]) + async def update_item(request: UpdateItemRequest): + """Update an existing item.""" + if not self.manager.update_item( + group_name=request.group_name, + title=request.title, + description=request.description, + color=request.color, + duration=request.duration + ): + raise HTTPException(status_code=404, detail="Item not found") + return OperationResponse(status="ok") + + @app.delete("/items/{group_name}/{title}", response_model=OperationResponse, tags=["items"]) + async def remove_item(group_name: str, title: str): + """Remove an item from a group.""" + if not self.manager.remove_item(group_name, title): + raise HTTPException(status_code=404, detail="Item not found") + return OperationResponse(status="ok") + + @app.delete("/items/{group_name}", response_model=OperationResponse, tags=["items"]) + async def clear_items(group_name: str): + """Clear all items from a group.""" + if not self.manager.clear_items(group_name): + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok") + + # ─────────────────────────────── Progress ─────────────────────────────── # + + @app.post("/progress", response_model=OperationResponse, tags=["progress"]) + async def show_progress(request: ProgressRequest): + """Show or update a progress bar.""" + self.manager.show_progress( + group_name=request.group_name, + title=request.title, + current=request.current, + maximum=request.maximum, + description=request.description, + color=request.color, + auto_close=request.auto_close + ) + return OperationResponse(status="ok") + + @app.post("/timer", response_model=OperationResponse, tags=["progress"]) + async def show_timer(request: TimerRequest): + """Show a timer-based progress bar.""" + self.manager.show_timer( + group_name=request.group_name, + title=request.title, + duration=request.duration, + description=request.description, + color=request.color, + auto_close=request.auto_close, + initial_progress=request.initial_progress + ) + return OperationResponse(status="ok") + + # ─────────────────────────────── Chat Window ─────────────────────────────── # + + @app.post("/chat/window", response_model=OperationResponse, tags=["chat"]) + async def create_chat_window(request: CreateChatWindowRequest): + """Create a new chat window.""" + props = { + "x": request.x, + "y": request.y, + "width": request.width, + "max_height": request.max_height, + "auto_hide": request.auto_hide, + "auto_hide_delay": request.auto_hide_delay, + "max_messages": request.max_messages, + "sender_colors": request.sender_colors or {}, + "fade_old_messages": request.fade_old_messages, + "is_chat_window": True, + } + if request.props: + props.update(request.props) + + self.manager.create_chat_window(request.name, props) + return OperationResponse(status="ok", message=f"Chat window '{request.name}' created") + + @app.delete("/chat/window/{name}", response_model=OperationResponse, tags=["chat"]) + async def delete_chat_window(name: str): + """Delete a chat window.""" + if not self.manager.delete_group(name): + raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + return OperationResponse(status="ok") + + @app.post("/chat/message", response_model=OperationResponse, tags=["chat"]) + async def send_chat_message(request: ChatMessageRequest): + """Send a message to a chat window.""" + if not self.manager.send_chat_message( + window_name=request.window_name, + sender=request.sender, + text=request.text, + color=request.color + ): + raise HTTPException(status_code=404, detail=f"Chat window '{request.window_name}' not found") + return OperationResponse(status="ok") + + @app.delete("/chat/messages/{window_name}", response_model=OperationResponse, tags=["chat"]) + async def clear_chat_messages(window_name: str): + """Clear all messages from a chat window.""" + if not self.manager.clear_chat_window(window_name): + raise HTTPException(status_code=404, detail=f"Chat window '{window_name}' not found") + return OperationResponse(status="ok") + + @app.post("/chat/show/{name}", response_model=OperationResponse, tags=["chat"]) + async def show_chat_window(name: str): + """Show a hidden chat window.""" + if not self.manager.show_chat_window(name): + raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + return OperationResponse(status="ok") + + @app.post("/chat/hide/{name}", response_model=OperationResponse, tags=["chat"]) + async def hide_chat_window(name: str): + """Hide a chat window.""" + if not self.manager.hide_chat_window(name): + raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + return OperationResponse(status="ok") + + # ─────────────────────────────── Legacy Compatibility ─────────────────────────────── # + # These endpoints provide compatibility with the old WebSocket-based commands + + @app.post("/legacy/draw", response_model=OperationResponse, tags=["legacy"]) + async def legacy_draw(cmd: dict[str, Any]): + """Legacy draw command (WebSocket compatibility).""" + group = cmd.get("group", "default") + self.manager.show_message( + group_name=group, + title=cmd.get("title", ""), + content=cmd.get("message", ""), + color=cmd.get("color"), + tools=cmd.get("tools"), + props=cmd.get("props"), + duration=cmd.get("duration") + ) + return OperationResponse(status="ok") + + @app.post("/legacy/hide", response_model=OperationResponse, tags=["legacy"]) + async def legacy_hide(cmd: dict[str, Any]): + """Legacy hide command (WebSocket compatibility).""" + group = cmd.get("group", "default") + self.manager.hide_message(group) + return OperationResponse(status="ok") + + @app.post("/legacy/loading", response_model=OperationResponse, tags=["legacy"]) + async def legacy_loading(cmd: dict[str, Any]): + """Legacy loading command (WebSocket compatibility).""" + group = cmd.get("group", "default") + self.manager.set_loader(group, cmd.get("state", True), cmd.get("color")) + return OperationResponse(status="ok") + + # ─────────────────────────────── Overlay Support ─────────────────────────────── # + + def _start_overlay(self): + """Start the overlay renderer in a background thread (if available).""" + if not OVERLAY_AVAILABLE or HeadsUpOverlay is None: + return + + if self._overlay_thread and self._overlay_thread.is_alive(): + return + + try: + self._command_queue = queue.Queue() + self._error_queue = queue.Queue() + + self._overlay = HeadsUpOverlay( + command_queue=self._command_queue, + error_queue=self._error_queue, + framerate=self._framerate, + layout_margin=self._layout_margin, + layout_spacing=self._layout_spacing, + ) + + # Register callback to send commands to overlay + self.manager.register_command_callback(self._send_to_overlay) + + self._overlay_thread = threading.Thread( + target=self._overlay.run, + daemon=True, + name="HUDOverlayThread" + ) + self._overlay_thread.start() + + except Exception: + pass # Overlay is optional + + def _stop_overlay(self): + """Stop the overlay renderer.""" + if self._command_queue: + try: + self._command_queue.put({"type": "quit"}) + except Exception: + pass + + if self._overlay_thread: + self._overlay_thread.join(timeout=2.0) + self._overlay_thread = None + + self._overlay = None + self._command_queue = None + self.manager.unregister_command_callback(self._send_to_overlay) + + def _send_to_overlay(self, command: dict[str, Any]): + """Send a command to the overlay renderer.""" + cmd_type = command.get('type', 'unknown') + group = command.get('group', 'unknown') + printr.print( + f"[HUD Server] _send_to_overlay: type='{cmd_type}', group='{group}'", + color=LogType.INFO, + server_only=True + ) + if cmd_type == 'update_group': + props = command.get('props', {}) + printr.print( + f"[HUD Server] _send_to_overlay: update_group props keys={list(props.keys())}", + color=LogType.INFO, + server_only=True + ) + if 'width' in props: + printr.print( + f"[HUD Server] _send_to_overlay: width={props['width']}", + color=LogType.INFO, + server_only=True + ) + if self._command_queue: + try: + self._command_queue.put(command) + except Exception as e: + printr.print( + f"[HUD Server] _send_to_overlay: FAILED to queue: {e}", + color=LogType.ERROR, + server_only=True + ) + else: + printr.print( + f"[HUD Server] _send_to_overlay: NO command queue!", + color=LogType.WARNING, + server_only=True + ) + + # ─────────────────────────────── Server Lifecycle ─────────────────────────────── # + + def start(self, host: str = "127.0.0.1", port: int = 7862, framerate: int = 60, + layout_margin: int = 20, layout_spacing: int = 15) -> bool: + """ + Start the HUD server in a background thread. + + Args: + host: Interface to listen on ('127.0.0.1' for local, '0.0.0.0' for LAN) + port: Port to listen on + framerate: HUD overlay rendering framerate (min 1) + layout_margin: Margin from screen edges in pixels + layout_spacing: Spacing between stacked windows in pixels + + Returns: + True if server started successfully + """ + if self._running: + return True + + self._host = host + self._port = port + self._framerate = max(1, framerate) + self._layout_margin = layout_margin + self._layout_spacing = layout_spacing + + self._thread = threading.Thread( + target=self._run_server, + daemon=True, + name="HUDServerThread" + ) + self._thread.start() + + # Wait briefly for server to start + import time + for _ in range(50): # 5 seconds max + time.sleep(0.1) + if self._running: + printr.print( + f"HUD Server started on http://{self._host}:{self._port}", + color=LogType.INFO, + server_only=True + ) + return True + + return False + + def _run_server(self): + """Run the server in its own thread with its own event loop.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + config = Config( + app=self.app, + host=self._host, + port=self._port, + log_level="warning", + access_log=False, + ) + self._server = Server(config) + + self._running = True + + try: + self._loop.run_until_complete(self._server.serve()) + except Exception: + pass + finally: + self._running = False + + async def stop(self): + """Stop the HUD server.""" + if not self._running: + return + + self._running = False + + # Stop overlay first + self._stop_overlay() + + # Signal server to stop + if self._server: + self._server.should_exit = True + + # Wait for thread to finish + if self._thread: + self._thread.join(timeout=5.0) + self._thread = None + + self._server = None + self._loop = None + + printr.print( + "HUD Server stopped", + color=LogType.INFO, + server_only=True + ) + + @property + def is_running(self) -> bool: + """Check if the server is currently running.""" + return self._running + + @property + def base_url(self) -> str: + """Get the base URL for the server.""" + return f"http://{self._host}:{self._port}" + + +# Standalone execution support +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="HUD Server") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind to") + parser.add_argument("--port", type=int, default=7862, help="Port to bind to") + args = parser.parse_args() + + print(f"Starting HUD Server on http://{args.host}:{args.port}") + print("API docs available at /docs") + + uvicorn.run( + "hud_server.server:HudServer().app", + host=args.host, + port=args.port, + reload=False + ) + diff --git a/hud_server/tests/README.md b/hud_server/tests/README.md new file mode 100644 index 000000000..440f4814f --- /dev/null +++ b/hud_server/tests/README.md @@ -0,0 +1,55 @@ +# HUD Server Tests + +This directory contains test suites for the HUD Server component. + +## Running Tests + +All tests should be executed from the project root directory (`wingman-ai/`) using the module syntax. + +### Quick Integration Test + +To run a quick connectivity and basic functionality check: + +```bash +python -m hud_server.tests.run_tests +``` + +### Running Specific Test Suites + +You can run specific functional test suites using command line arguments: + +```bash +# Run all test suites +python -m hud_server.tests.run_tests --all + +# Run message overlay tests +python -m hud_server.tests.run_tests --messages + +# Run progress bar tests +python -m hud_server.tests.run_tests --progress + +# Run persistent info display tests +python -m hud_server.tests.run_tests --persistent + +# Run chat window tests +python -m hud_server.tests.run_tests --chat + +# Run layout manager unit tests (no server needed) +python -m hud_server.tests.run_tests --layout + +# Run visual layout tests with actual HUD windows +python -m hud_server.tests.run_tests --layout-visual +``` + +## detailed Test Files + +- `run_tests.py`: Main entry point and test runner utility. +- `test_runner.py`: Contains `TestContext` manager for handling test sessions. +- `test_messages.py`: Tests for transient overlay messages (titles, content). +- `test_progress.py`: Tests for progress bar creation, updates, and removal. +- `test_persistent.py`: Tests for persistent info boxes (key-value pairs). +- `test_chat.py`: Tests for chat window visibility and content updates. +- `test_session.py`: Tests for session management (connection/disconnection). +- `test_multiuser.py`: Tests for handling multiple client connections. +- `test_layout.py`: Unit tests for the layout manager (automatic stacking and collision prevention). +- `test_layout_visual.py`: Visual integration tests that display actual HUD windows to verify layout. diff --git a/hud_server/tests/__init__.py b/hud_server/tests/__init__.py new file mode 100644 index 000000000..5dbf0a334 --- /dev/null +++ b/hud_server/tests/__init__.py @@ -0,0 +1,2 @@ +# HUD Server Tests + diff --git a/hud_server/tests/debug_layout.py b/hud_server/tests/debug_layout.py new file mode 100644 index 000000000..46de689c6 --- /dev/null +++ b/hud_server/tests/debug_layout.py @@ -0,0 +1,86 @@ +""" +Debug test to trace layout manager behavior with show/hide cycles. +""" +import sys +import asyncio + +sys.path.insert(0, ".") + +from hud_server.tests.test_runner import TestContext + + +async def debug_layout_test(session): + """Debug test showing layout manager state during show/hide.""" + print("\n" + "=" * 70) + print("DEBUG: Layout Manager Show/Hide Trace") + print("=" * 70) + + client = session._client + + # Create three groups with different priorities + groups_config = [ + ("debug_red", 30, "#ff0000", "RED - Priority 30"), + ("debug_green", 20, "#00ff00", "GREEN - Priority 20"), + ("debug_blue", 10, "#0000ff", "BLUE - Priority 10"), + ] + + print("\n1. Creating groups...") + for name, priority, color, label in groups_config: + await client.create_group(name, props={ + "anchor": "top_left", + "priority": priority, + "layout_mode": "auto", + "margin": 20, + "spacing": 15, + "width": 400, + "accent_color": color, + }) + print(f" Created: {name} (priority={priority})") + + await asyncio.sleep(0.5) + + print("\n2. Showing all three messages...") + for name, priority, color, label in groups_config: + await client.show_message(name, title=label, content=f"Priority: {priority}", duration=60.0) + print(f" Shown: {name}") + await asyncio.sleep(0.2) + + print("\n Expected stack (top to bottom): RED, GREEN, BLUE") + print(" Waiting 3 seconds - verify visually...") + await asyncio.sleep(3) + + print("\n3. HIDING GREEN (middle)...") + await client.hide_message("debug_green") + print(" Expected stack: RED, BLUE (GREEN hidden)") + print(" BLUE should move UP to where GREEN was") + print(" Waiting 3 seconds - verify visually...") + await asyncio.sleep(3) + + print("\n4. SHOWING GREEN again...") + await client.show_message("debug_green", title="GREEN - BACK!", content="I should be in the MIDDLE!", duration=60.0) + print(" Expected stack: RED, GREEN, BLUE") + print(" GREEN should appear BETWEEN RED and BLUE") + print(" BLUE should move DOWN") + print(" Waiting 5 seconds - verify visually...") + await asyncio.sleep(5) + + print("\n5. Cleanup...") + for name, _, _, _ in groups_config: + await client.hide_message(name) + + await asyncio.sleep(1) + print("\n[DONE] Check the console output above and visual behavior") + + +async def main(): + print("Debug Layout Test - Tracing show/hide behavior\n") + + async with TestContext(session_ids=[1]) as ctx: + session = ctx.sessions[0] + await debug_layout_test(session) + + print("\nTest complete.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hud_server/tests/run_tests.py b/hud_server/tests/run_tests.py new file mode 100644 index 000000000..5e4cc89c2 --- /dev/null +++ b/hud_server/tests/run_tests.py @@ -0,0 +1,151 @@ +""" +Test script for HUD Server - Quick integration test and test suite runner. + +Usage: + python -m hud_server.tests.run_tests # Run quick integration test + python -m hud_server.tests.run_tests --all # Run all test suites + python -m hud_server.tests.run_tests --messages # Run message tests + python -m hud_server.tests.run_tests --progress # Run progress tests + python -m hud_server.tests.run_tests --persistent # Run persistent info tests + python -m hud_server.tests.run_tests --chat # Run chat tests + python -m hud_server.tests.run_tests --unicode # Run Unicode/emoji stress tests + python -m hud_server.tests.run_tests --layout # Run layout manager unit tests (no server needed) + python -m hud_server.tests.run_tests --layout-visual # Run visual layout tests with actual HUD windows +""" +import sys +import asyncio + +try: + sys.stdout.reconfigure(line_buffering=True) +except AttributeError: + pass # stdout may be redirected and not support reconfigure + + +def quick_test(): + """Quick integration test.""" + print("=" * 60) + print("HUD Server Quick Integration Test") + print("=" * 60) + + print("\nImporting HudServer...") + from hud_server import HudServer + + print("Creating server instance...") + server = HudServer() + + print("Starting server...") + started = server.start() + print(f"Server started: {started}") + print(f"Server running: {server.is_running}") + print(f"Base URL: {server.base_url}") + + print("\nTesting health endpoint...") + import httpx + try: + response = httpx.get('http://127.0.0.1:7862/health', timeout=5.0) + print(f"Health response: {response.json()}") + except Exception as e: + print(f"Health check failed: {e}") + + print("\nTesting message endpoint...") + try: + response = httpx.post('http://127.0.0.1:7862/message', json={ + 'group_name': 'test', + 'title': 'Test', + 'content': 'Hello World' + }, timeout=5.0) + print(f"Message response: {response.json()}") + except Exception as e: + print(f"Message failed: {e}") + + print("\nChecking groups...") + try: + response = httpx.get('http://127.0.0.1:7862/groups', timeout=5.0) + print(f"Groups: {response.json()}") + except Exception as e: + print(f"Groups failed: {e}") + + print("\nStopping server...") + asyncio.run(server.stop()) + print("Server stopped") + + print("\n" + "=" * 60) + print("Quick test complete!") + print("=" * 60) + + +async def run_test_suite(test_name: str): + """Run a specific test suite.""" + from hud_server.tests.test_runner import TestContext + + print(f"\n{'=' * 60}") + print(f"Running {test_name} tests...") + print(f"{'=' * 60}\n") + + async with TestContext(session_ids=[1]) as ctx: + session = ctx.sessions[0] + + if test_name == "messages": + from hud_server.tests.test_messages import run_all_message_tests + await run_all_message_tests(session) + elif test_name == "progress": + from hud_server.tests.test_progress import run_all_progress_tests + await run_all_progress_tests(session) + elif test_name == "persistent": + from hud_server.tests.test_persistent import run_all_persistent_tests + await run_all_persistent_tests(session) + elif test_name == "chat": + from hud_server.tests.test_chat import run_all_chat_tests + await run_all_chat_tests(session) + elif test_name == "unicode": + from hud_server.tests.test_unicode_stress import run_all_unicode_stress_tests + await run_all_unicode_stress_tests(session) + elif test_name == "all": + from hud_server.tests.test_messages import run_all_message_tests + from hud_server.tests.test_progress import run_all_progress_tests + from hud_server.tests.test_persistent import run_all_persistent_tests + from hud_server.tests.test_chat import run_all_chat_tests + from hud_server.tests.test_unicode_stress import run_all_unicode_stress_tests + + await run_all_message_tests(session) + await asyncio.sleep(2) + await run_all_progress_tests(session) + await asyncio.sleep(2) + await run_all_persistent_tests(session) + await asyncio.sleep(2) + await run_all_chat_tests(session) + await asyncio.sleep(2) + await run_all_unicode_stress_tests(session) + + print(f"\n{'=' * 60}") + print(f"{test_name.capitalize()} tests complete!") + print(f"{'=' * 60}") + + +def main(): + if len(sys.argv) > 1: + arg = sys.argv[1].lower().replace("--", "").replace("-", "_") + if arg == "layout": + # Layout unit tests don't need a server + from hud_server.tests.test_layout import run_all_tests + success = run_all_tests() + sys.exit(0 if success else 1) + elif arg == "layout_visual": + # Visual layout tests need the full server + from hud_server.tests.test_layout_visual import main as layout_visual_main + asyncio.run(layout_visual_main()) + elif arg in ["messages", "progress", "persistent", "chat", "unicode", "all"]: + asyncio.run(run_test_suite(arg)) + elif arg == "help": + print(__doc__) + else: + print(f"Unknown argument: {arg}") + print(__doc__) + else: + quick_test() + + +if __name__ == "__main__": + main() + + diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py new file mode 100644 index 000000000..a4644adbc --- /dev/null +++ b/hud_server/tests/test_chat.py @@ -0,0 +1,402 @@ +""" +Test Chat - Chat window tests with natural conversation simulation. + +Tests the chat window HUD type with: +- Natural conversation flow with realistic timing +- Full markdown support in messages +- Multiple participants with custom colors +- Auto-hide and manual show/hide +- Message overflow with fade effect +""" + +import asyncio +from hud_server.tests.test_session import TestSession + +# Emoji constants using Unicode escape sequences (avoids file encoding issues) +EMOJI_ROCKET = "\U0001F680" # 🚀 +EMOJI_WARNING = "\u26A0\uFE0F" # ⚠️ +EMOJI_SPARKLES = "\u2728" # ✨ +ARROW_RIGHT = "\u2192" # → + + +# ============================================================================= +# Conversation Data - Natural AI Assistant Scenario +# ============================================================================= + +CONVERSATION_WINGMAN = [ + # (sender, message, delay_after) + ("System", "Voice connection established", 0.5), + ("User", "Hey, what's my current status?", 1.5), + ("Wingman", """Your ship status looks **good**: + +- Hull: `100%` +- Shields: *92%* (charging) +- Fuel: **67%** + +You're currently in safe space near *Hurston*. +""", 2.5), + ("User", "Where's the nearest refuel station?", 1.5), + ("Wingman", """I found **3 stations** nearby: + +1. **Everus Harbor** - 45km + - Full service, `moderate` traffic +2. **HDMS-Oparei** - 120km + - Fuel only, *low* traffic +3. **Lorville Gates** - 200km + - Full service, ~~closed~~ **open** + +> Recommend: *Everus Harbor* for fastest refuel +""", 3.0), + ("User", "Set course for Everus Harbor", 1.2), + ("Wingman", f"""Course set! {EMOJI_ROCKET} + +| Parameter | Value | +|-----------|-------| +| Distance | 45km | +| ETA | 2m 30s | +| Speed | 300m/s | + +*Autopilot engaged* +""", 2.0), + ("System", f"{EMOJI_WARNING} Quantum Travel spooling...", 1.5), + ("Wingman", "QT drive ready. Jump in **3... 2... 1...**", 2.0), + ("System", "Arrived at destination", 1.0), + ("User", "Thanks! Request landing", 1.5), + ("Wingman", """Landing request sent to **Everus Harbor** ATC. + +``` +Clearance: GRANTED +Pad: H-07 +Bay: Hangar 2 +``` + +Follow the guide markers. Safe landing! {EMOJI_SPARKLES} +""", 2.5), +] + +CONVERSATION_CODING = [ + ("User", "Help me write a Python function", 1.5), + ("Assistant", """Sure! What should the function do? + +I can help with: +- Data processing +- API calls +- File operations +- **Algorithms** +""", 2.0), + ("User", "Calculate fibonacci numbers", 1.2), + ("Assistant", """Here's an efficient implementation: + +```python +def fibonacci(n: int) -> int: + if n <= 1: + return n + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b +``` + +This uses **O(n)** time and **O(1)** space. + +Usage: +- `fibonacci(10)` returns `55` +- `fibonacci(20)` returns `6765` +""", 3.0), + ("User", "Can you add memoization?", 1.5), + ("Assistant", """Here's the memoized version: + +```python +from functools import lru_cache + +@lru_cache(maxsize=None) +def fib_memo(n: int) -> int: + if n <= 1: + return n + return fib_memo(n-1) + fib_memo(n-2) +``` + +> **Note:** The `@lru_cache` decorator automatically handles caching. + +| Approach | Time | Space | +|----------|------|-------| +| Iterative | O(n) | O(1) | +| Memoized | O(n) | O(n) | +| Recursive | O(2^n) | O(n) | +""", 2.5), + ("User", "Perfect, thanks!", 1.0), + ("Assistant", "You're welcome! Let me know if you need anything else.", 1.5), +] + +CONVERSATION_GAME = [ + ("Player", "What's in my inventory?", 1.2), + ("Game", """## Inventory + +### Weapons +- **Sword of Dawn** (+25 ATK) +- *Wooden Bow* (+10 ATK) + +### Items +| Item | Qty | Effect | +|------|-----|--------| +| Health Potion | 5 | +50 HP | +| Mana Crystal | 3 | +30 MP | +| ~~Old Key~~ | 0 | *Used* | + +### Gold +`1,234` coins +""", 2.5), + ("Player", "Use health potion", 1.0), + ("Game", """**Health Potion** used! + +- HP: ~~45/100~~ -> **95/100** +- Potions remaining: `4` + +> *You feel rejuvenated!* +""", 2.0), + ("System", "Enemy approaching!", 0.8), + ("Game", """**Battle Started!** + +*Goblin Warrior* appears! +- Level: 5 +- HP: `80/80` +- Weakness: *Fire* + +Your turn! Choose: +1. [x] Attack +2. [ ] Defend +3. [ ] Magic +4. [ ] Flee +""", 2.0), +] + + +# ============================================================================= +# Tests +# ============================================================================= + +async def test_chat_basic(session: TestSession): + """Basic chat window test.""" + print(f"[{session.name}] Testing basic chat...") + + chat_name = f"chat_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=50, # High priority - appears first + layout_mode="auto", + width=session.config["hud_width"], + max_height=300, + auto_hide=False, + bg_color=session.config["bg_color"], + text_color=session.config["text_color"], + accent_color=session.config["accent_color"], + opacity=session.config["opacity"], + ) + await asyncio.sleep(0.5) + + await session.send_chat_message(chat_name, "User", "Hello!") + await asyncio.sleep(1) + await session.send_chat_message(chat_name, session.name, "Hi there! How can I help?") + await asyncio.sleep(1) + await session.send_chat_message(chat_name, "User", "Just testing the chat window") + await asyncio.sleep(1) + await session.send_chat_message(chat_name, session.name, "Looks like it's working!") + await asyncio.sleep(2) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Basic chat test complete") + + +async def test_chat_markdown(session: TestSession): + """Test markdown rendering in chat messages.""" + print(f"[{session.name}] Testing chat markdown...") + + chat_name = f"md_chat_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=40, # Second priority + layout_mode="auto", + width=450, + max_height=400, + auto_hide=False, + sender_colors={ + "User": session.config["user_color"], + session.name: session.config["accent_color"], + "System": "#888888", + }, + ) + await asyncio.sleep(0.5) + + # Test various markdown features + await session.send_chat_message(chat_name, "User", "Show me markdown features") + await asyncio.sleep(1.5) + + await session.send_chat_message(chat_name, session.name, + "**Bold**, *italic*, `code`, ~~strike~~") + await asyncio.sleep(1.5) + + await session.send_chat_message(chat_name, session.name, """Here's a list: +- First item +- Second item + - Nested item +- Third item""") + await asyncio.sleep(2) + + await session.send_chat_message(chat_name, session.name, """Code block: +```python +print("Hello!") +```""") + await asyncio.sleep(2) + + await session.send_chat_message(chat_name, session.name, """| Col1 | Col2 | +|------|------| +| A | B | +| C | D |""") + await asyncio.sleep(2) + + await session.send_chat_message(chat_name, "System", "> This is a quote block") + await asyncio.sleep(2) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Chat markdown test complete") + + +async def test_chat_conversation(session: TestSession, conversation: list = None): + """Test natural conversation flow.""" + if conversation is None: + conversation = CONVERSATION_WINGMAN + + print(f"[{session.name}] Testing conversation flow...") + + chat_name = f"conv_{session.session_id}" + + # Determine unique senders for colors + senders = list(set(msg[0] for msg in conversation)) + colors = ["#4cd964", "#00aaff", "#ff9500", "#9b59b6", "#888888"] + sender_colors = {s: colors[i % len(colors)] for i, s in enumerate(senders)} + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=30, # Third priority + layout_mode="auto", + width=450, + max_height=400, + auto_hide=True, + auto_hide_delay=10.0, + fade_old_messages=True, + sender_colors=sender_colors, + bg_color=session.config["bg_color"], + opacity=0.92, + ) + await asyncio.sleep(0.5) + + for sender, message, delay in conversation: + await session.send_chat_message(chat_name, sender, message) + await asyncio.sleep(delay) + + # Let it sit visible for a moment + await asyncio.sleep(3) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Conversation test complete") + + +async def test_chat_auto_hide(session: TestSession): + """Test auto-hide functionality.""" + print(f"[{session.name}] Testing chat auto-hide...") + + chat_name = f"autohide_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=20, # Fourth priority + layout_mode="auto", + width=350, + max_height=250, + auto_hide=True, + auto_hide_delay=3.0, # Short delay for testing + ) + await asyncio.sleep(0.5) + + await session.send_chat_message(chat_name, "Test", "This will auto-hide in 3 seconds...") + await asyncio.sleep(1) + await session.send_chat_message(chat_name, "Test", "Timer resets with each message") + await asyncio.sleep(4) # Wait for auto-hide + + # Should be hidden now, send new message to show again + await session.send_chat_message(chat_name, "Test", "I'm back!") + await asyncio.sleep(2) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Auto-hide test complete") + + +async def test_chat_overflow(session: TestSession): + """Test message overflow and fade effect.""" + print(f"[{session.name}] Testing chat overflow...") + + chat_name = f"overflow_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=10, # Fifth priority + layout_mode="auto", + width=400, + max_height=200, # Small height to trigger overflow + fade_old_messages=True, + ) + await asyncio.sleep(0.5) + + # Send many messages to overflow + for i in range(15): + await session.send_chat_message(chat_name, f"User{i%3}", f"Message #{i+1}: Testing overflow behavior") + await asyncio.sleep(0.4) + + await asyncio.sleep(2) + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Overflow test complete") + + +async def test_chat_wingman(session: TestSession): + """Run the Wingman conversation scenario.""" + await test_chat_conversation(session, CONVERSATION_WINGMAN) + + +async def test_chat_coding(session: TestSession): + """Run the coding assistant conversation scenario.""" + await test_chat_conversation(session, CONVERSATION_CODING) + + +async def test_chat_game(session: TestSession): + """Run the game UI conversation scenario.""" + await test_chat_conversation(session, CONVERSATION_GAME) + + +# ============================================================================= +# Run All Tests +# ============================================================================= + +async def run_all_chat_tests(session: TestSession): + """Run all chat tests.""" + await test_chat_basic(session) + await asyncio.sleep(1) + await test_chat_markdown(session) + await asyncio.sleep(1) + await test_chat_auto_hide(session) + await asyncio.sleep(1) + await test_chat_overflow(session) + await asyncio.sleep(1) + await test_chat_wingman(session) + + +if __name__ == "__main__": + from hud_server.tests.test_runner import run_interactive_test + run_interactive_test(run_all_chat_tests) + diff --git a/hud_server/tests/test_layout.py b/hud_server/tests/test_layout.py new file mode 100644 index 000000000..f3404d228 --- /dev/null +++ b/hud_server/tests/test_layout.py @@ -0,0 +1,335 @@ +""" +Test script for the Layout Manager. + +Usage: + python -m hud_server.tests.test_layout +""" +import sys +sys.path.insert(0, ".") + +from hud_server.layout import LayoutManager, Anchor, LayoutMode + + +def test_basic_stacking(): + """Test basic vertical stacking at top-left anchor.""" + print("=" * 60) + print("Test: Basic Vertical Stacking") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Register three windows at top-left + layout.register_window("message_ATC", anchor=Anchor.TOP_LEFT, priority=20, height=100) + layout.register_window("message_Computer", anchor=Anchor.TOP_LEFT, priority=15, height=150) + layout.register_window("persistent_ATC", anchor=Anchor.TOP_LEFT, priority=5, height=80) + + positions = layout.compute_positions() + + print("Positions:") + for name, pos in sorted(positions.items(), key=lambda x: x[1][1]): + print(f" {name}: x={pos[0]}, y={pos[1]}") + + # Verify ordering: higher priority windows are closer to anchor (lower y) + assert positions["message_ATC"][1] < positions["message_Computer"][1], "message_ATC should be above message_Computer" + assert positions["message_Computer"][1] < positions["persistent_ATC"][1], "message_Computer should be above persistent_ATC" + + # Verify no overlap + for name1 in positions: + for name2 in positions: + if name1 >= name2: + continue + assert not layout.check_collision(name1, name2), f"{name1} and {name2} should not overlap" + + print("✓ Basic stacking test passed!\n") + + +def test_multiple_anchors(): + """Test windows at different anchors.""" + print("=" * 60) + print("Test: Multiple Anchors") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Register windows at different corners + layout.register_window("left_top", anchor=Anchor.TOP_LEFT, width=400, height=100) + layout.register_window("right_top", anchor=Anchor.TOP_RIGHT, width=400, height=100) + layout.register_window("left_bottom", anchor=Anchor.BOTTOM_LEFT, width=400, height=100) + layout.register_window("right_bottom", anchor=Anchor.BOTTOM_RIGHT, width=400, height=100) + + positions = layout.compute_positions() + + print("Positions:") + for name, pos in positions.items(): + print(f" {name}: x={pos[0]}, y={pos[1]}") + + # Verify corners + assert positions["left_top"][0] == 20, "left_top should be at left margin" + assert positions["left_top"][1] == 20, "left_top should be at top margin" + + assert positions["right_top"][0] == 1920 - 400 - 20, "right_top should be at right edge" + assert positions["right_top"][1] == 20, "right_top should be at top margin" + + assert positions["left_bottom"][0] == 20, "left_bottom should be at left margin" + assert positions["left_bottom"][1] == 1080 - 100 - 20, "left_bottom should be at bottom edge" + + assert positions["right_bottom"][0] == 1920 - 400 - 20, "right_bottom should be at right edge" + assert positions["right_bottom"][1] == 1080 - 100 - 20, "right_bottom should be at bottom edge" + + print("✓ Multiple anchors test passed!\n") + + +def test_visibility(): + """Test visibility affecting layout.""" + print("=" * 60) + print("Test: Visibility") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + layout.register_window("win1", anchor=Anchor.TOP_LEFT, priority=10, height=100) + layout.register_window("win2", anchor=Anchor.TOP_LEFT, priority=5, height=100) + layout.register_window("win3", anchor=Anchor.TOP_LEFT, priority=1, height=100) + + # All visible + positions = layout.compute_positions() + print("All visible:") + for name, pos in sorted(positions.items(), key=lambda x: x[1][1]): + print(f" {name}: y={pos[1]}") + + original_win3_y = positions["win3"][1] + + # Hide win2 + layout.set_window_visible("win2", False) + positions = layout.compute_positions(force=True) + print("\nWith win2 hidden:") + for name, pos in sorted(positions.items(), key=lambda x: x[1][1]): + print(f" {name}: y={pos[1]}") + + # win3 should move up (lower y value) + assert positions["win3"][1] < original_win3_y, "win3 should move up when win2 is hidden" + assert "win2" not in positions, "win2 should not be in positions when hidden" + + print("✓ Visibility test passed!\n") + + +def test_height_update(): + """Test dynamic height changes.""" + print("=" * 60) + print("Test: Dynamic Height Updates") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + layout.register_window("win1", anchor=Anchor.TOP_LEFT, priority=10, height=100) + layout.register_window("win2", anchor=Anchor.TOP_LEFT, priority=5, height=100) + + positions = layout.compute_positions() + original_win2_y = positions["win2"][1] + print(f"Original: win1 y={positions['win1'][1]}, win2 y={positions['win2'][1]}") + + # Increase win1 height + layout.update_window_height("win1", 200) + positions = layout.compute_positions(force=True) + new_win2_y = positions["win2"][1] + print(f"After win1 grows: win1 y={positions['win1'][1]}, win2 y={positions['win2'][1]}") + + assert new_win2_y > original_win2_y, "win2 should move down when win1 grows" + assert new_win2_y == original_win2_y + 100, f"win2 should move down by 100px (got {new_win2_y - original_win2_y})" + + print("✓ Height update test passed!\n") + + +def test_manual_mode(): + """Test manual positioning mode.""" + print("=" * 60) + print("Test: Manual Mode") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # One auto, one manual + layout.register_window("auto_win", anchor=Anchor.TOP_LEFT, mode=LayoutMode.AUTO, priority=10, height=100) + layout.register_window("manual_win", anchor=Anchor.TOP_LEFT, mode=LayoutMode.MANUAL, priority=5, height=100, manual_x=500, manual_y=500) + + positions = layout.compute_positions() + print("Positions:") + for name, pos in positions.items(): + print(f" {name}: x={pos[0]}, y={pos[1]}") + + assert positions["auto_win"] == (20, 20), "auto_win should be at auto position" + assert positions["manual_win"] == (500, 500), "manual_win should be at manual position" + + print("✓ Manual mode test passed!\n") + + +def test_collision_detection(): + """Test collision detection between windows.""" + print("=" * 60) + print("Test: Collision Detection") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Create overlapping windows with manual positioning + layout.register_window("win1", mode=LayoutMode.MANUAL, width=200, height=200, manual_x=100, manual_y=100) + layout.register_window("win2", mode=LayoutMode.MANUAL, width=200, height=200, manual_x=150, manual_y=150) + layout.register_window("win3", mode=LayoutMode.MANUAL, width=200, height=200, manual_x=500, manual_y=500) + + positions = layout.compute_positions() + + print("Checking collisions:") + col_1_2 = layout.check_collision("win1", "win2") + col_1_3 = layout.check_collision("win1", "win3") + col_2_3 = layout.check_collision("win2", "win3") + + print(f" win1 vs win2: {col_1_2}") + print(f" win1 vs win3: {col_1_3}") + print(f" win2 vs win3: {col_2_3}") + + assert col_1_2, "win1 and win2 should collide" + assert not col_1_3, "win1 and win3 should not collide" + assert not col_2_3, "win2 and win3 should not collide" + + collisions = layout.find_collisions() + print(f" All collisions: {collisions}") + assert len(collisions) == 1, "Should find exactly 1 collision" + assert ("win1", "win2") in collisions, "Should detect win1-win2 collision" + + print("✓ Collision detection test passed!\n") + + +def test_all_nine_anchors(): + """Test all 9 anchor positions.""" + print("=" * 60) + print("Test: All 9 Anchor Positions") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Register windows at all 9 anchors + anchors = [ + (Anchor.TOP_LEFT, "tl"), + (Anchor.TOP_CENTER, "tc"), + (Anchor.TOP_RIGHT, "tr"), + (Anchor.LEFT_CENTER, "lc"), + (Anchor.CENTER, "c"), + (Anchor.RIGHT_CENTER, "rc"), + (Anchor.BOTTOM_LEFT, "bl"), + (Anchor.BOTTOM_CENTER, "bc"), + (Anchor.BOTTOM_RIGHT, "br"), + ] + + for anchor, name in anchors: + layout.register_window(name, anchor=anchor, width=200, height=100) + + positions = layout.compute_positions() + + print("Positions for all 9 anchors:") + for name, pos in sorted(positions.items()): + print(f" {name}: x={pos[0]}, y={pos[1]}") + + # Verify key positions + # Top row + assert positions["tl"][0] == 20, "top_left x should be margin" + assert positions["tl"][1] == 20, "top_left y should be margin" + + assert positions["tc"][0] == (1920 - 200) // 2, "top_center x should be centered" + assert positions["tc"][1] == 20, "top_center y should be margin" + + assert positions["tr"][0] == 1920 - 200 - 20, "top_right x should be right edge" + assert positions["tr"][1] == 20, "top_right y should be margin" + + # Middle row (left/right center are vertically centered) + assert positions["lc"][0] == 20, "left_center x should be margin" + assert positions["rc"][0] == 1920 - 200 - 20, "right_center x should be right edge" + + # Center + assert positions["c"][0] == (1920 - 200) // 2, "center x should be centered" + assert positions["c"][1] == (1080 - 100) // 2, "center y should be centered" + + # Bottom row + assert positions["bl"][0] == 20, "bottom_left x should be margin" + assert positions["bl"][1] == 1080 - 100 - 20, "bottom_left y should be bottom" + + assert positions["bc"][0] == (1920 - 200) // 2, "bottom_center x should be centered" + assert positions["bc"][1] == 1080 - 100 - 20, "bottom_center y should be bottom" + + assert positions["br"][0] == 1920 - 200 - 20, "bottom_right x should be right" + assert positions["br"][1] == 1080 - 100 - 20, "bottom_right y should be bottom" + + print("✓ All 9 anchors test passed!\n") + + +def test_center_edge_stacking(): + """Test stacking at center-edge anchors (left_center, right_center).""" + print("=" * 60) + print("Test: Center-Edge Stacking") + print("=" * 60) + + layout = LayoutManager(screen_width=1920, screen_height=1080) + + # Stack 3 windows at left_center + layout.register_window("lc1", anchor=Anchor.LEFT_CENTER, priority=30, width=200, height=100) + layout.register_window("lc2", anchor=Anchor.LEFT_CENTER, priority=20, width=200, height=100) + layout.register_window("lc3", anchor=Anchor.LEFT_CENTER, priority=10, width=200, height=100) + + positions = layout.compute_positions() + + print("Left-center stack positions:") + for name in ["lc1", "lc2", "lc3"]: + print(f" {name}: x={positions[name][0]}, y={positions[name][1]}") + + # The stack should be vertically centered + # Total height = 3 * 100 + 2 * 10 = 320 + # Starting y = (1080 - 320) / 2 = 380 + expected_start_y = (1080 - 320) // 2 + + assert positions["lc1"][1] == expected_start_y, f"lc1 should start at y={expected_start_y}" + assert positions["lc2"][1] == expected_start_y + 110, f"lc2 should be at y={expected_start_y + 110}" + assert positions["lc3"][1] == expected_start_y + 220, f"lc3 should be at y={expected_start_y + 220}" + + # All should be at left margin + for name in ["lc1", "lc2", "lc3"]: + assert positions[name][0] == 20, f"{name} should be at left margin" + + print("✓ Center-edge stacking test passed!\n") + + +def run_all_tests(): + """Run all layout manager tests.""" + print("\n" + "=" * 60) + print("LAYOUT MANAGER TEST SUITE") + print("=" * 60 + "\n") + + try: + test_basic_stacking() + test_multiple_anchors() + test_all_nine_anchors() + test_center_edge_stacking() + test_visibility() + test_height_update() + test_manual_mode() + test_collision_detection() + + print("=" * 60) + print("ALL TESTS PASSED! ✓") + print("=" * 60) + return True + + except AssertionError as e: + print(f"\n✗ TEST FAILED: {e}") + import traceback + traceback.print_exc() + return False + + except Exception as e: + print(f"\n✗ UNEXPECTED ERROR: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/hud_server/tests/test_layout_visual.py b/hud_server/tests/test_layout_visual.py new file mode 100644 index 000000000..77a40dded --- /dev/null +++ b/hud_server/tests/test_layout_visual.py @@ -0,0 +1,714 @@ +""" +Visual Layout Integration Test - Tests layout manager with actual HUD content. + +This test creates multiple HUD groups with different anchors and priorities, +displays content in them, and verifies the layout system stacks them correctly. + +Usage: + python -m hud_server.tests.test_layout_visual + +Requirements: + - HUD Server must be running (will be auto-started) + - Windows only (overlay uses Win32 API) +""" +import sys +import asyncio + +sys.path.insert(0, ".") + +from hud_server.tests.test_runner import TestContext + + +# ============================================================================= +# ANCHOR CONFIGURATION - All 9 anchor points +# ============================================================================= + +ANCHOR_CONFIG = { + "top_left": { + "label": "TOP LEFT", + "color": "#ff5555", + "emoji_fallback": "[TL]", + }, + "top_center": { + "label": "TOP CENTER", + "color": "#ffaa00", + "emoji_fallback": "[TC]", + }, + "top_right": { + "label": "TOP RIGHT", + "color": "#55ff55", + "emoji_fallback": "[TR]", + }, + "right_center": { + "label": "RIGHT CENTER", + "color": "#55ffff", + "emoji_fallback": "[RC]", + }, + "bottom_right": { + "label": "BOTTOM RIGHT", + "color": "#5555ff", + "emoji_fallback": "[BR]", + }, + "bottom_center": { + "label": "BOTTOM CENTER", + "color": "#ff55ff", + "emoji_fallback": "[BC]", + }, + "bottom_left": { + "label": "BOTTOM LEFT", + "color": "#ffff55", + "emoji_fallback": "[BL]", + }, + "left_center": { + "label": "LEFT CENTER", + "color": "#ff8855", + "emoji_fallback": "[LC]", + }, + "center": { + "label": "CENTER", + "color": "#ffffff", + "emoji_fallback": "[C]", + }, +} + + +async def cleanup_groups(client, group_names): + """Helper to clean up groups.""" + for name in group_names: + try: + await client.hide_message(name) + except: + pass + await asyncio.sleep(0.5) + + +async def test_all_nine_anchors(session): + """Test all 9 anchor positions simultaneously.""" + print("\n" + "=" * 70) + print("TEST 1: All 9 Anchor Positions") + print("=" * 70) + print("Creating windows at all 9 anchor points...") + + client = session._client + groups = [] + + for anchor, config in ANCHOR_CONFIG.items(): + group_name = f"anchor_{anchor}" + groups.append(group_name) + + await client.create_group(group_name, props={ + "anchor": anchor, + "priority": 10, + "layout_mode": "auto", + "margin": 25, + "spacing": 10, + "width": 280, + "accent_color": config["color"], + }) + + await client.show_message( + group_name, + title=f"{config['emoji_fallback']} {config['label']}", + content=f"Anchor: **{anchor}**\n\nThis window is positioned at the {config['label'].lower()} of the screen.", + duration=30.0 + ) + await asyncio.sleep(0.15) + + print("\nAll 9 windows displayed!") + print("Visual verification:") + print(" - TOP ROW: Left, Center, Right") + print(" - MIDDLE ROW: Left edge, Center (if visible), Right edge") + print(" - BOTTOM ROW: Left, Center, Right") + + await asyncio.sleep(6) + await cleanup_groups(client, groups) + print("[OK] Test 1 complete\n") + + +async def test_priority_stacking(session): + """Test priority-based stacking at each anchor.""" + print("\n" + "=" * 70) + print("TEST 2: Priority-Based Stacking") + print("=" * 70) + + client = session._client + groups = [] + + # Test stacking at TOP_LEFT with 3 priority levels + priorities = [ + ("stack_high", 30, "#ff3333", "HIGH Priority (30)"), + ("stack_med", 20, "#33ff33", "MEDIUM Priority (20)"), + ("stack_low", 10, "#3333ff", "LOW Priority (10)"), + ] + + print("Creating 3 windows at TOP_LEFT with different priorities...") + + for name, priority, color, label in priorities: + groups.append(name) + await client.create_group(name, props={ + "anchor": "top_left", + "priority": priority, + "layout_mode": "auto", + "margin": 20, + "spacing": 12, + "width": 380, + "accent_color": color, + }) + + await client.show_message( + name, + title=label, + content=f"Priority value: **{priority}**\n\nHigher priority = closer to anchor point (top).", + duration=20.0 + ) + await asyncio.sleep(0.2) + + print("\nExpected order (top to bottom):") + print(" 1. RED - High (30)") + print(" 2. GREEN - Medium (20)") + print(" 3. BLUE - Low (10)") + + await asyncio.sleep(5) + + # Now add windows to TOP_RIGHT to show parallel stacking + print("\nAdding 2 windows to TOP_RIGHT...") + + for name, priority, color in [("right_a", 25, "#ff9900"), ("right_b", 15, "#9900ff")]: + groups.append(name) + await client.create_group(name, props={ + "anchor": "top_right", + "priority": priority, + "layout_mode": "auto", + "margin": 20, + "spacing": 12, + "width": 320, + "accent_color": color, + }) + + await client.show_message( + name, + title=f"Right Side (P:{priority})", + content=f"Independent stack on right side.\nPriority: {priority}", + duration=15.0 + ) + await asyncio.sleep(0.2) + + print("Both sides now have independent stacks!") + + await asyncio.sleep(5) + await cleanup_groups(client, groups) + print("[OK] Test 2 complete\n") + + +async def test_dynamic_height_changes(session): + """Test that windows reflow when heights change dynamically.""" + print("\n" + "=" * 70) + print("TEST 3: Dynamic Height Changes & Reflow") + print("=" * 70) + + client = session._client + groups = ["dyn_top", "dyn_bottom"] + + # Create two stacked windows + await client.create_group("dyn_top", props={ + "anchor": "top_left", + "priority": 20, + "layout_mode": "auto", + "margin": 20, + "spacing": 15, + "width": 420, + "accent_color": "#ff6600", + }) + + await client.create_group("dyn_bottom", props={ + "anchor": "top_left", + "priority": 10, + "layout_mode": "auto", + "margin": 20, + "spacing": 15, + "width": 420, + "accent_color": "#0066ff", + }) + + # Phase 1: Short top window + print("Phase 1: Top window is SHORT") + await client.show_message( + "dyn_top", + title="Top Window - SHORT", + content="This is a short message.", + duration=30.0 + ) + await asyncio.sleep(0.3) + + await client.show_message( + "dyn_bottom", + title="Bottom Window", + content="Watch me move as the top window changes height!", + duration=30.0 + ) + await asyncio.sleep(3) + + # Phase 2: Tall top window + print("Phase 2: Top window GROWS - bottom should move DOWN") + await client.show_message( + "dyn_top", + title="Top Window - TALL", + content="""This window has grown significantly! + +## Content Section + +Here's a list of items: +- First important item +- Second important item +- Third important item +- Fourth important item + +### Additional Details + +The bottom window should have automatically +repositioned itself below this content. + +``` +No manual adjustment needed! +Layout manager handles it. +``` +""", + duration=25.0 + ) + await asyncio.sleep(4) + + # Phase 3: Short again + print("Phase 3: Top window SHRINKS - bottom should move UP") + await client.show_message( + "dyn_top", + title="Top Window - SHORT again", + content="Shrunk back down.", + duration=20.0 + ) + await asyncio.sleep(3) + + # Phase 4: Medium height + print("Phase 4: Top window MEDIUM height") + await client.show_message( + "dyn_top", + title="Top Window - MEDIUM", + content="Now at a medium height.\n\nWith a bit more content.\n\nJust enough to demonstrate.", + duration=15.0 + ) + await asyncio.sleep(3) + + await cleanup_groups(client, groups) + print("[OK] Test 3 complete\n") + + +async def test_visibility_reflow(session): + """Test that hiding windows causes others to reflow.""" + print("\n" + "=" * 70) + print("TEST 4: Visibility Changes & Reflow") + print("=" * 70) + + client = session._client + groups = ["vis_1", "vis_2", "vis_3"] + + colors = ["#ff0000", "#00ff00", "#0000ff"] + labels = ["First (Red)", "Second (Green)", "Third (Blue)"] + + for i, (name, color, label) in enumerate(zip(groups, colors, labels)): + await client.create_group(name, props={ + "anchor": "top_left", + "priority": 30 - (i * 10), + "layout_mode": "auto", + "margin": 20, + "spacing": 12, + "width": 380, + "accent_color": color, + }) + + # Show all three + print("Phase 1: All 3 windows visible") + for name, label in zip(groups, labels): + await client.show_message(name, title=label, content=f"Window: {label}", duration=30.0) + await asyncio.sleep(0.2) + await asyncio.sleep(3) + + # Hide middle (green) + print("Phase 2: HIDING middle (Green) - Blue should move UP") + await client.hide_message("vis_2") + await asyncio.sleep(3) + + # Show middle again + print("Phase 3: SHOWING middle (Green) - Blue should move DOWN") + await client.show_message("vis_2", title="Second (Green) - BACK!", content="I'm back in the stack!", duration=20.0) + await asyncio.sleep(3) + + # Hide first (red) + print("Phase 4: HIDING first (Red) - Both should move UP") + await client.hide_message("vis_1") + await asyncio.sleep(3) + + # Hide all except blue + print("Phase 5: Only Blue remains") + await client.hide_message("vis_2") + await asyncio.sleep(2) + + await cleanup_groups(client, groups) + print("[OK] Test 4 complete\n") + + +async def test_opposite_anchors(session): + """Test opposite corners simultaneously.""" + print("\n" + "=" * 70) + print("TEST 5: Opposite Corners (Diagonal)") + print("=" * 70) + + client = session._client + groups = [] + + pairs = [ + ("diag_tl", "top_left", "#ff0000", "TOP-LEFT Corner"), + ("diag_br", "bottom_right", "#00ff00", "BOTTOM-RIGHT Corner"), + ("diag_tr", "top_right", "#0000ff", "TOP-RIGHT Corner"), + ("diag_bl", "bottom_left", "#ffff00", "BOTTOM-LEFT Corner"), + ] + + print("Creating windows at all 4 corners...") + + for name, anchor, color, label in pairs: + groups.append(name) + await client.create_group(name, props={ + "anchor": anchor, + "priority": 10, + "layout_mode": "auto", + "margin": 20, + "width": 320, + "accent_color": color, + }) + + await client.show_message( + name, + title=label, + content=f"Anchor: **{anchor}**\n\nDiagonal positioning test.", + duration=15.0 + ) + await asyncio.sleep(0.15) + + print("All 4 corners populated - verify no overlaps!") + + await asyncio.sleep(5) + await cleanup_groups(client, groups) + print("[OK] Test 5 complete\n") + + +async def test_center_anchors(session): + """Test center and edge-center anchors.""" + print("\n" + "=" * 70) + print("TEST 6: Center and Edge-Center Anchors") + print("=" * 70) + + client = session._client + groups = [] + + # First show center + groups.append("center_main") + await client.create_group("center_main", props={ + "anchor": "center", + "priority": 10, + "layout_mode": "auto", + "width": 350, + "accent_color": "#ffffff", + }) + + await client.show_message( + "center_main", + title="CENTER", + content="This window is in the absolute center of the screen.", + duration=20.0 + ) + + print("Center window displayed") + await asyncio.sleep(2) + + # Add edge centers + edge_centers = [ + ("edge_top", "top_center", "#ff9900", "TOP CENTER EDGE"), + ("edge_bottom", "bottom_center", "#9900ff", "BOTTOM CENTER EDGE"), + ("edge_left", "left_center", "#00ff99", "LEFT CENTER EDGE"), + ("edge_right", "right_center", "#ff0099", "RIGHT CENTER EDGE"), + ] + + print("Adding edge-center windows...") + + for name, anchor, color, label in edge_centers: + groups.append(name) + await client.create_group(name, props={ + "anchor": anchor, + "priority": 10, + "layout_mode": "auto", + "margin": 20, + "width": 260, + "accent_color": color, + }) + + await client.show_message( + name, + title=label, + content=f"Positioned at the {anchor.replace('_', ' ')}.", + duration=15.0 + ) + await asyncio.sleep(0.2) + + print("All edge-center windows displayed!") + print("Should form a cross pattern around the center.") + + await asyncio.sleep(5) + await cleanup_groups(client, groups) + print("[OK] Test 6 complete\n") + + +async def test_stacking_at_edge_centers(session): + """Test that edge-center anchors also support stacking.""" + print("\n" + "=" * 70) + print("TEST 7: Stacking at Edge-Center Anchors") + print("=" * 70) + + client = session._client + groups = [] + + # Stack 3 windows at left_center + print("Stacking 3 windows at LEFT_CENTER...") + + for i, (priority, color) in enumerate([(30, "#ff5555"), (20, "#55ff55"), (10, "#5555ff")]): + name = f"left_stack_{i}" + groups.append(name) + + await client.create_group(name, props={ + "anchor": "left_center", + "priority": priority, + "layout_mode": "auto", + "margin": 20, + "spacing": 10, + "width": 280, + "accent_color": color, + }) + + await client.show_message( + name, + title=f"Left Stack (P:{priority})", + content=f"Priority: {priority}\nVertically centered stack.", + duration=20.0 + ) + await asyncio.sleep(0.2) + + # Stack 2 windows at right_center + print("Stacking 2 windows at RIGHT_CENTER...") + + for i, (priority, color) in enumerate([(25, "#ff9900"), (15, "#9900ff")]): + name = f"right_stack_{i}" + groups.append(name) + + await client.create_group(name, props={ + "anchor": "right_center", + "priority": priority, + "layout_mode": "auto", + "margin": 20, + "spacing": 10, + "width": 280, + "accent_color": color, + }) + + await client.show_message( + name, + title=f"Right Stack (P:{priority})", + content=f"Priority: {priority}\nMirrored stack on right.", + duration=20.0 + ) + await asyncio.sleep(0.2) + + print("Both side stacks visible - should be vertically centered!") + + await asyncio.sleep(5) + await cleanup_groups(client, groups) + print("[OK] Test 7 complete\n") + + +async def test_mixed_content_with_progress(session): + """Test layout with mixed content types including progress bars.""" + print("\n" + "=" * 70) + print("TEST 8: Mixed Content Types (Messages + Progress)") + print("=" * 70) + + client = session._client + groups = ["msg_group", "progress_group"] + + # Message window at top + await client.create_group("msg_group", props={ + "anchor": "top_left", + "priority": 20, + "layout_mode": "auto", + "margin": 20, + "spacing": 15, + "width": 400, + "accent_color": "#00aaff", + }) + + await client.show_message( + "msg_group", + title="System Status", + content="Active operations are displayed below.\n\nProgress bars update in real-time.", + duration=30.0 + ) + + # Progress window below + await client.create_group("progress_group", props={ + "anchor": "top_left", + "priority": 10, + "layout_mode": "auto", + "margin": 20, + "spacing": 15, + "width": 380, + "accent_color": "#ffaa00", + }) + + # Add progress bar + await client.show_progress( + "progress_group", + title="Download Progress", + current=0, + maximum=100, + description="Starting download..." + ) + + print("Message and progress bar displayed") + print("Animating progress...") + + # Animate progress + for i in range(0, 101, 5): + await client.show_progress( + "progress_group", + title="Download Progress", + current=i, + maximum=100, + description=f"Downloading... {i}%" + ) + await asyncio.sleep(0.15) + + print("Progress complete!") + await asyncio.sleep(2) + + await cleanup_groups(client, groups) + await client.remove_item("progress_group", "Download Progress") + print("[OK] Test 8 complete\n") + + +async def test_rapid_show_hide(session): + """Stress test with rapid show/hide cycles.""" + print("\n" + "=" * 70) + print("TEST 9: Rapid Show/Hide Stress Test") + print("=" * 70) + + client = session._client + groups = ["rapid_1", "rapid_2", "rapid_3"] + + for i, name in enumerate(groups): + await client.create_group(name, props={ + "anchor": "top_left", + "priority": 30 - (i * 10), + "layout_mode": "auto", + "margin": 20, + "spacing": 10, + "width": 350, + "accent_color": ["#ff0000", "#00ff00", "#0000ff"][i], + }) + + print("Performing 5 rapid show/hide cycles...") + + for cycle in range(5): + print(f" Cycle {cycle + 1}/5") + + # Show all + for name in groups: + await client.show_message(name, title=f"Window {name}", content=f"Cycle {cycle + 1}", duration=10.0) + await asyncio.sleep(0.05) + + await asyncio.sleep(0.5) + + # Hide middle + await client.hide_message("rapid_2") + await asyncio.sleep(0.3) + + # Show middle + await client.show_message("rapid_2", title="Window rapid_2", content=f"Back! Cycle {cycle + 1}", duration=10.0) + await asyncio.sleep(0.3) + + # Hide first + await client.hide_message("rapid_1") + await asyncio.sleep(0.3) + + # Show first + await client.show_message("rapid_1", title="Window rapid_1", content=f"Back! Cycle {cycle + 1}", duration=10.0) + await asyncio.sleep(0.2) + + print("Stress test complete - checking final state...") + await asyncio.sleep(2) + + await cleanup_groups(client, groups) + print("[OK] Test 9 complete\n") + + +async def run_all_layout_visual_tests(session): + """Run all visual layout tests.""" + print("\n" + "=" * 70) + print(" SOPHISTICATED VISUAL LAYOUT INTEGRATION TEST SUITE") + print(" Testing all 9 anchor points and complex scenarios") + print("=" * 70) + print("\nThis test will display HUD windows on your screen.") + print("Watch for correct positioning and stacking behavior.\n") + print("Press Ctrl+C to abort at any time.\n") + + await asyncio.sleep(2) + + try: + await test_all_nine_anchors(session) + await test_priority_stacking(session) + await test_dynamic_height_changes(session) + await test_visibility_reflow(session) + await test_opposite_anchors(session) + await test_center_anchors(session) + await test_stacking_at_edge_centers(session) + await test_mixed_content_with_progress(session) + await test_rapid_show_hide(session) + + print("\n" + "=" * 70) + print(" ALL 9 VISUAL LAYOUT TESTS COMPLETE!") + print("=" * 70) + print("\nSummary:") + print(" [OK] Test 1: All 9 anchor positions") + print(" [OK] Test 2: Priority-based stacking") + print(" [OK] Test 3: Dynamic height changes") + print(" [OK] Test 4: Visibility changes & reflow") + print(" [OK] Test 5: Opposite corners (diagonal)") + print(" [OK] Test 6: Center and edge-center anchors") + print(" [OK] Test 7: Stacking at edge-centers") + print(" [OK] Test 8: Mixed content types") + print(" [OK] Test 9: Rapid show/hide stress test") + print("\nIf windows positioned correctly without overlapping,") + print("the layout manager is working properly!") + + except KeyboardInterrupt: + print("\n\nTest aborted by user.") + + +async def main(): + """Main entry point.""" + print("Starting Sophisticated Visual Layout Integration Tests...") + print("The HUD overlay will appear on your screen.\n") + + async with TestContext(session_ids=[1]) as ctx: + session = ctx.sessions[0] + await run_all_layout_visual_tests(session) + + print("\nTests complete. Server stopped.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hud_server/tests/test_messages.py b/hud_server/tests/test_messages.py new file mode 100644 index 000000000..fe1f49aca --- /dev/null +++ b/hud_server/tests/test_messages.py @@ -0,0 +1,204 @@ +""" +Test Messages - Basic message display and markdown rendering tests. +""" + +import asyncio +import random +from hud_server.tests.test_session import TestSession + + +# ============================================================================= +# Test Data +# ============================================================================= + +SHORT_MESSAGES = [ + "Hello, I'm ready to assist.", + "Command executed successfully.", + "Processing your request...", + "Target acquired.", + "Systems nominal.", +] + +MARKDOWN_SAMPLES = [ + """**Bold text** and *italic text* mixed together. +Also `inline code` and ~~strikethrough~~ for variety.""", + + """## Mission Briefing + +### Objective +Retrieve the artifact from **Alpha Station**. + +### Intel +1. Station has 3 docking bays +2. Security level: *Medium* +3. Expected resistance: `Minimal`""", + + """Here's a checklist: +- [x] Primary systems online +- [x] Navigation calibrated +- [ ] Cargo secured +- [ ] Jump coordinates verified""", + + """Configuration: +``` +target = "Alpha Centauri" +fuel = 87.5 +stealth = True +``` +Ready for departure.""", + + """| Parameter | Value | Status | +|-----------|-------|--------| +| Power | 95% | OK | +| Shields | 78% | WARN | +| Hull | 100% | OK | + +> **Note:** Shields recharging""", +] + +LONG_MESSAGE = """## Comprehensive Status Report + +### Navigation Systems +All navigation systems are **fully operational**. Current heading: `045.7 deg` + +### Communication Array +Minor interference detected on channels 4-6. Switching to backup frequencies. + +### Resource Status +| Resource | Level | Rate | +|----------|-------|------| +| Fuel | 67% | -2%/h | +| O2 | 98% | -0.1%/h | +| Power | 85% | +5%/h | + +### Recommendations +1. Refuel at next station +2. Run diagnostics on comm array +3. Continue current heading + +> *ETA to destination: 4h 32m*""" + + +# ============================================================================= +# Tests +# ============================================================================= + +async def test_basic_messages(session: TestSession, delay: float = 2.0): + """Test basic message display.""" + print(f"[{session.name}] Testing basic messages...") + + await session.draw_user_message("Show me a status update") + await asyncio.sleep(delay) + + await session.draw_assistant_message(random.choice(SHORT_MESSAGES)) + await asyncio.sleep(delay) + + await session.draw_assistant_message( + "This is a medium-length response that provides " + "more context and information about the current situation." + ) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Basic messages complete") + + +async def test_markdown(session: TestSession, delay: float = 3.0): + """Test markdown rendering.""" + print(f"[{session.name}] Testing markdown...") + + for i, sample in enumerate(MARKDOWN_SAMPLES): + await session.draw_assistant_message(sample) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Markdown test complete") + + +async def test_long_message(session: TestSession, delay: float = 5.0): + """Test long message with scrolling.""" + print(f"[{session.name}] Testing long message...") + + await session.draw_assistant_message(LONG_MESSAGE) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Long message test complete") + + +async def test_loading_indicator(session: TestSession, delay: float = 1.0): + """Test loading indicator.""" + print(f"[{session.name}] Testing loading indicator...") + + await session.draw_user_message("What's the weather?") + await asyncio.sleep(delay) + + await session.set_loading(True) + await asyncio.sleep(delay * 2) + + await session.set_loading(False) + await session.draw_assistant_message("The weather is sunny with 22°C.") + await asyncio.sleep(delay * 2) + + await session.hide() + print(f"[{session.name}] Loading indicator test complete") + + +async def test_loader_only(session: TestSession, delay: float = 1.0): + """Test loading indicator without any message content.""" + print(f"[{session.name}] Testing loader-only (no message)...") + + # Show loader without any prior message + await session.set_loading(True) + await asyncio.sleep(delay * 3) + + # Hide loader + await session.set_loading(False) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Loader-only test complete") + + +async def test_sequential_messages(session: TestSession, delay: float = 1.5): + """Test sequential messages to verify cache invalidation.""" + print(f"[{session.name}] Testing sequential messages...") + + messages = [ + "First message - this should display properly.", + "Second message - cache should invalidate.", + "Third message with **markdown** formatting.", + "Fourth message - all messages should render correctly.", + ] + + for i, msg in enumerate(messages, 1): + await session.draw_assistant_message(msg) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Sequential messages test complete") + + +# ============================================================================= +# Run All Tests +# ============================================================================= + +async def run_all_message_tests(session: TestSession): + """Run all message tests.""" + await test_basic_messages(session) + await asyncio.sleep(1) + await test_markdown(session) + await asyncio.sleep(1) + await test_long_message(session) + await asyncio.sleep(1) + await test_loading_indicator(session) + await asyncio.sleep(1) + await test_loader_only(session) + await asyncio.sleep(1) + await test_sequential_messages(session) + + +if __name__ == "__main__": + from hud_server.tests.test_runner import run_interactive_test + run_interactive_test(run_all_message_tests) + diff --git a/hud_server/tests/test_multiuser.py b/hud_server/tests/test_multiuser.py new file mode 100644 index 000000000..dd984a822 --- /dev/null +++ b/hud_server/tests/test_multiuser.py @@ -0,0 +1,910 @@ +""" +Test Multiuser - Multi-user HUD session tests with shared groups. + +Tests: +- Multiple users each with their own HUD groups +- Shared groups accessible by multiple users +- Disconnect and reconnect scenarios: + - With state persistence (save/restore) + - Without state persistence (clean start) +- HUDs with different configurations positioned across the screen + +This test simulates a realistic multi-user scenario like a gaming team +or collaborative workspace where users have private and shared HUD areas. +""" + +import asyncio +from typing import Any, Optional +from dataclasses import dataclass, field + +from hud_server.http_client import HudHttpClient + + +# ============================================================================= +# USER CONFIGURATIONS - Different visual styles across the screen +# ============================================================================= + +USER_CONFIGS = { + "alice": { + "display_name": "Alice", + # Private HUD - top-left corner (blue theme) + "private_hud": { + "x": 20, + "y": 20, + "width": 380, + "max_height": 350, + "bg_color": "#1a2332", + "text_color": "#e8f4ff", + "accent_color": "#00aaff", + "opacity": 0.92, + "border_radius": 10, + "font_size": 15, + "typewriter_effect": True, + "fade_delay": 10.0, + "z_order": 10, + }, + # Private persistent panel - below main HUD + "private_persistent": { + "x": 20, + "y": 390, + "width": 320, + "max_height": 300, + "bg_color": "#1a2332", + "text_color": "#e8f4ff", + "accent_color": "#00d4aa", + "opacity": 0.85, + "border_radius": 8, + "font_size": 14, + "typewriter_effect": False, + "z_order": 5, + }, + }, + "bob": { + "display_name": "Bob", + # Private HUD - top-right corner (orange theme) + "private_hud": { + "x": 1500, + "y": 20, + "width": 400, + "max_height": 380, + "bg_color": "#2a1f1a", + "text_color": "#fff5e8", + "accent_color": "#ff8c42", + "opacity": 0.90, + "border_radius": 14, + "font_size": 16, + "typewriter_effect": True, + "fade_delay": 8.0, + "z_order": 10, + }, + # Private persistent panel - right side + "private_persistent": { + "x": 1520, + "y": 420, + "width": 340, + "max_height": 280, + "bg_color": "#2a1f1a", + "text_color": "#fff5e8", + "accent_color": "#ffa500", + "opacity": 0.82, + "border_radius": 10, + "font_size": 13, + "typewriter_effect": False, + "z_order": 5, + }, + }, + "charlie": { + "display_name": "Charlie", + # Private HUD - bottom-left corner (purple theme) + "private_hud": { + "x": 20, + "y": 720, + "width": 420, + "max_height": 320, + "bg_color": "#1f1a2a", + "text_color": "#f0e8ff", + "accent_color": "#9b59b6", + "opacity": 0.88, + "border_radius": 16, + "font_size": 15, + "typewriter_effect": False, # Charlie prefers instant text + "fade_delay": 12.0, + "z_order": 10, + }, + # Private persistent panel - bottom area + "private_persistent": { + "x": 460, + "y": 800, + "width": 350, + "max_height": 220, + "bg_color": "#1f1a2a", + "text_color": "#f0e8ff", + "accent_color": "#8e44ad", + "opacity": 0.80, + "border_radius": 12, + "font_size": 14, + "typewriter_effect": False, + "z_order": 5, + }, + }, +} + +# Shared group configurations +SHARED_CONFIGS = { + "team_notifications": { + "name": "Team Notifications", + "x": 800, + "y": 20, + "width": 450, + "max_height": 400, + "bg_color": "#1a1a2e", + "text_color": "#ffffff", + "accent_color": "#e74c3c", + "opacity": 0.95, + "border_radius": 12, + "font_size": 16, + "typewriter_effect": True, + "z_order": 20, # Highest priority + }, + "team_chat": { + "name": "Team Chat", + "x": 800, + "y": 440, + "width": 480, + "max_height": 450, + "bg_color": "#16213e", + "text_color": "#e8e8e8", + "accent_color": "#3498db", + "opacity": 0.90, + "border_radius": 10, + "font_size": 14, + "is_chat_window": True, + "auto_hide": False, + "max_messages": 100, + "fade_old_messages": True, + "sender_colors": { + "Alice": "#00aaff", + "Bob": "#ff8c42", + "Charlie": "#9b59b6", + "System": "#95a5a6", + }, + "z_order": 15, + }, + "shared_status": { + "name": "Shared Status", + "x": 1300, + "y": 720, + "width": 360, + "max_height": 300, + "bg_color": "#0d1b2a", + "text_color": "#d0d0d0", + "accent_color": "#2ecc71", + "opacity": 0.85, + "border_radius": 8, + "font_size": 14, + "typewriter_effect": False, + "z_order": 12, + }, +} + + +# ============================================================================= +# USER CLIENT CLASS +# ============================================================================= + +@dataclass +class UserClient: + """Represents a single user with their own HUD groups.""" + + user_id: str + config: dict[str, Any] + base_url: str = "http://127.0.0.1:7862" + _client: Optional[HudHttpClient] = field(default=None, repr=False) + connected: bool = False + saved_states: dict[str, dict] = field(default_factory=dict) + + @property + def display_name(self) -> str: + return self.config.get("display_name", self.user_id.title()) + + @property + def private_hud_group(self) -> str: + return f"user_{self.user_id}_hud" + + @property + def private_persistent_group(self) -> str: + return f"user_{self.user_id}_persistent" + + async def connect(self, timeout: float = 5.0) -> bool: + """Connect to the HUD server.""" + try: + self._client = HudHttpClient(self.base_url) + if await self._client.connect(timeout=timeout): + self.connected = True + print(f"[{self.display_name}] Connected to HUD server") + return True + print(f"[{self.display_name}] Failed to connect") + return False + except Exception as e: + print(f"[{self.display_name}] Connection error: {e}") + return False + + async def disconnect(self): + """Disconnect from the HUD server.""" + if self._client: + await self._client.disconnect() + self.connected = False + print(f"[{self.display_name}] Disconnected") + + async def setup_private_groups(self): + """Create the user's private HUD groups.""" + if not self._client: + return + + # Create private HUD + await self._client.create_group( + self.private_hud_group, + props=self.config["private_hud"] + ) + print(f"[{self.display_name}] Created private HUD group") + + # Create private persistent panel + await self._client.create_group( + self.private_persistent_group, + props=self.config["private_persistent"] + ) + print(f"[{self.display_name}] Created private persistent group") + + async def cleanup_private_groups(self): + """Delete the user's private HUD groups.""" + if not self._client: + return + + await self._client.delete_group(self.private_hud_group) + await self._client.delete_group(self.private_persistent_group) + print(f"[{self.display_name}] Cleaned up private groups") + + # State persistence methods + async def save_state(self, group_name: str) -> bool: + """Save the current state of a group for later restore.""" + if not self._client: + return False + + result = await self._client.get_state(group_name) + if result and "state" in result: + self.saved_states[group_name] = result["state"] + print(f"[{self.display_name}] Saved state for '{group_name}'") + return True + return False + + async def restore_state(self, group_name: str) -> bool: + """Restore a previously saved group state.""" + if not self._client: + return False + + if group_name not in self.saved_states: + print(f"[{self.display_name}] No saved state for '{group_name}'") + return False + + result = await self._client.restore_state( + group_name, + self.saved_states[group_name] + ) + if result: + print(f"[{self.display_name}] Restored state for '{group_name}'") + return True + return False + + def clear_saved_states(self): + """Clear all saved states (simulating no persistence).""" + self.saved_states.clear() + print(f"[{self.display_name}] Cleared all saved states") + + # Private HUD operations + async def show_private_message(self, title: str, content: str, + color: Optional[str] = None): + """Show a message in the user's private HUD.""" + if not self._client: + return + await self._client.show_message( + self.private_hud_group, + title=title, + content=content, + color=color or self.config["private_hud"]["accent_color"], + ) + + async def show_private_loader(self, show: bool = True): + """Show/hide loader in private HUD.""" + if not self._client: + return + await self._client.show_loader(self.private_hud_group, show) + + async def add_private_item(self, title: str, description: str, + duration: Optional[float] = None): + """Add a persistent item to private panel.""" + if not self._client: + return + await self._client.add_item( + self.private_persistent_group, + title=title, + description=description, + duration=duration, + ) + + async def update_private_item(self, title: str, description: str): + """Update a persistent item in private panel.""" + if not self._client: + return + await self._client.update_item( + self.private_persistent_group, + title=title, + description=description, + ) + + async def remove_private_item(self, title: str): + """Remove a persistent item from private panel.""" + if not self._client: + return + await self._client.remove_item(self.private_persistent_group, title) + + async def show_private_progress(self, title: str, current: float, + maximum: float = 100, description: str = ""): + """Show a progress bar in private panel.""" + if not self._client: + return + await self._client.show_progress( + self.private_persistent_group, + title=title, + current=current, + maximum=maximum, + description=description, + ) + + async def show_private_timer(self, title: str, duration: float, + description: str = "", auto_close: bool = True): + """Show a timer in private panel.""" + if not self._client: + return + await self._client.show_timer( + self.private_persistent_group, + title=title, + duration=duration, + description=description, + auto_close=auto_close, + ) + + +# ============================================================================= +# SHARED GROUP MANAGER +# ============================================================================= + +class SharedGroupManager: + """Manages shared groups accessible by multiple users.""" + + def __init__(self, base_url: str = "http://127.0.0.1:7862"): + self.base_url = base_url + self._client: Optional[HudHttpClient] = None + self.connected = False + + async def connect(self) -> bool: + """Connect to the HUD server.""" + self._client = HudHttpClient(self.base_url) + if await self._client.connect(): + self.connected = True + print("[SharedGroupManager] Connected") + return True + return False + + async def disconnect(self): + """Disconnect from the HUD server.""" + if self._client: + await self._client.disconnect() + self.connected = False + + async def setup_shared_groups(self): + """Create all shared groups.""" + if not self._client: + return + + for group_id, config in SHARED_CONFIGS.items(): + if config.get("is_chat_window"): + await self._client.create_chat_window( + name=group_id, + x=config["x"], + y=config["y"], + width=config["width"], + max_height=config["max_height"], + auto_hide=config.get("auto_hide", False), + max_messages=config.get("max_messages", 50), + sender_colors=config.get("sender_colors"), + fade_old_messages=config.get("fade_old_messages", True), + ) + else: + await self._client.create_group(group_id, props=config) + print(f"[SharedGroupManager] Created shared group: {config['name']}") + + async def cleanup_shared_groups(self): + """Delete all shared groups.""" + if not self._client: + return + + for group_id, config in SHARED_CONFIGS.items(): + if config.get("is_chat_window"): + await self._client.delete_chat_window(group_id) + else: + await self._client.delete_group(group_id) + print("[SharedGroupManager] Cleaned up all shared groups") + + async def send_team_notification(self, title: str, content: str, + color: Optional[str] = None): + """Send a notification to the team notifications panel.""" + if not self._client: + return + await self._client.show_message( + "team_notifications", + title=title, + content=content, + color=color, + ) + + async def send_team_chat(self, sender: str, text: str, + color: Optional[str] = None): + """Send a message to the team chat.""" + if not self._client: + return + await self._client.send_chat_message( + "team_chat", + sender=sender, + text=text, + color=color, + ) + + async def update_shared_status(self, title: str, description: str): + """Update an item in the shared status panel.""" + if not self._client: + return + await self._client.add_item( + "shared_status", + title=title, + description=description, + ) + + +# ============================================================================= +# TEST SCENARIOS +# ============================================================================= + +async def test_multiuser_basic_setup(users: dict[str, UserClient], + shared: SharedGroupManager, + delay: float = 1.5): + """Test basic multi-user setup with private and shared groups.""" + print("\n" + "="*60) + print("TEST: Basic Multi-User Setup") + print("="*60) + + # Each user sets up their private groups + for user in users.values(): + await user.setup_private_groups() + await asyncio.sleep(delay) + + # Setup shared groups + await shared.setup_shared_groups() + await asyncio.sleep(delay) + + # Each user sends a message to their private HUD + for user_id, user in users.items(): + await user.show_private_message( + f"Welcome, {user.display_name}!", + f"""This is your **private HUD**. + +- Only you can see this +- Positioned at `x={user.config['private_hud']['x']}, y={user.config['private_hud']['y']}` +- Theme color: `{user.config['private_hud']['accent_color']}` +""" + ) + await asyncio.sleep(0.5) + + await asyncio.sleep(delay) + + # Team notification + await shared.send_team_notification( + "Session Started", + """All team members connected! + +**Active Users:** +- Alice *(Top-Left)* +- Bob *(Top-Right)* +- Charlie *(Bottom-Left)* + +> Team communication is ready. +""" + ) + await asyncio.sleep(delay) + + # Each user adds private persistent items + await users["alice"].add_private_item("Task", "Complete report") + await users["bob"].add_private_item("Objective", "Review pull requests") + await users["charlie"].add_private_item("Note", "Prepare presentation") + + await asyncio.sleep(delay) + print("Basic setup test complete") + + +async def test_multiuser_shared_interaction(users: dict[str, UserClient], + shared: SharedGroupManager, + delay: float = 1.0): + """Test multiple users interacting with shared groups.""" + print("\n" + "="*60) + print("TEST: Shared Group Interaction") + print("="*60) + + # Simulate team chat conversation + conversation = [ + ("Alice", "Hey team! Ready to start?"), + ("Bob", "Ready here!"), + ("Charlie", "Just finishing up something, give me a sec..."), + ("System", "Meeting starting in **2 minutes**"), + ("Alice", "No rush Charlie, we can wait"), + ("Charlie", "Okay I'm good now! Let's go"), + ("Bob", "Perfect, let's do this!"), + ] + + for sender, text in conversation: + await shared.send_team_chat(sender, text) + await asyncio.sleep(delay) + + # Shared status updates + await shared.update_shared_status("Team Status", "All members **online**") + await asyncio.sleep(delay * 0.5) + + await shared.update_shared_status("Current Task", "Sprint Planning") + await asyncio.sleep(delay * 0.5) + + await shared.update_shared_status("Time Remaining", "`45 minutes`") + + await asyncio.sleep(delay) + print("Shared interaction test complete") + + +async def test_disconnect_reconnect_with_save(users: dict[str, UserClient], + shared: SharedGroupManager, + delay: float = 2.0): + """Test disconnect and reconnect WITH state persistence.""" + print("\n" + "="*60) + print("TEST: Disconnect/Reconnect WITH State Save") + print("="*60) + + # Alice adds content to her private panels + await users["alice"].show_private_message( + "Important Data", + """This message should **persist** after reconnect! + +- Item 1: Saved +- Item 2: Saved +- State will be restored +""" + ) + await users["alice"].add_private_item("Saved Item 1", "This will persist") + await users["alice"].add_private_item("Saved Item 2", "This too!") + await asyncio.sleep(delay) + + # Bob shows a progress bar + await users["bob"].show_private_progress("Download", 65, 100, "65% complete") + await users["bob"].add_private_item("Pinned", "Important bookmark") + await asyncio.sleep(delay) + + # Save states before disconnect + print("\n--- Saving states before disconnect ---") + await users["alice"].save_state(users["alice"].private_hud_group) + await users["alice"].save_state(users["alice"].private_persistent_group) + await users["bob"].save_state(users["bob"].private_hud_group) + await users["bob"].save_state(users["bob"].private_persistent_group) + + await asyncio.sleep(delay) + + # Disconnect both users + print("\n--- Disconnecting users ---") + await users["alice"].disconnect() + await users["bob"].disconnect() + await asyncio.sleep(delay) + + # Reconnect + print("\n--- Reconnecting users ---") + await users["alice"].connect() + await users["bob"].connect() + await asyncio.sleep(delay) + + # Restore saved states + print("\n--- Restoring saved states ---") + await users["alice"].restore_state(users["alice"].private_hud_group) + await users["alice"].restore_state(users["alice"].private_persistent_group) + await users["bob"].restore_state(users["bob"].private_hud_group) + await users["bob"].restore_state(users["bob"].private_persistent_group) + + await asyncio.sleep(delay) + + # Verify restoration by showing confirmation + await users["alice"].show_private_message( + "State Restored!", + "Previous content should be visible above." + ) + + await asyncio.sleep(delay) + print("Disconnect/reconnect WITH save test complete") + + +async def test_disconnect_reconnect_without_save(users: dict[str, UserClient], + delay: float = 2.0): + """Test disconnect and reconnect WITHOUT state persistence.""" + print("\n" + "="*60) + print("TEST: Disconnect/Reconnect WITHOUT State Save (Clean Start)") + print("="*60) + + # Charlie adds content + await users["charlie"].show_private_message( + "Temporary Content", + """This message will be **lost** after reconnect! + +- No state save +- Fresh start on reconnect +- Content will disappear +""" + ) + await users["charlie"].add_private_item("Temp Item", "Will not persist") + await users["charlie"].show_private_timer("Session Timer", 30.0, "Running...") + + await asyncio.sleep(delay) + + # Clear any saved states to simulate no persistence + users["charlie"].clear_saved_states() + + # Disconnect + print("\n--- Disconnecting Charlie (no state save) ---") + await users["charlie"].disconnect() + await asyncio.sleep(delay) + + # Reconnect + print("\n--- Reconnecting Charlie ---") + await users["charlie"].connect() + await asyncio.sleep(delay) + + # Setup fresh groups (previous content is lost) + await users["charlie"].setup_private_groups() + await asyncio.sleep(delay) + + # Show that this is a fresh start + await users["charlie"].show_private_message( + "Fresh Start", + """Previous content is **gone**! + +This is a clean slate: +- No messages restored +- No items restored +- Starting fresh +""" + ) + await users["charlie"].add_private_item("New Item", "Created after reconnect") + + await asyncio.sleep(delay) + print("Disconnect/reconnect WITHOUT save test complete") + + +async def test_mixed_hud_configs(users: dict[str, UserClient], + shared: SharedGroupManager, + delay: float = 1.5): + """Test HUDs with different configurations across the screen.""" + print("\n" + "="*60) + print("TEST: Mixed HUD Configurations Across Screen") + print("="*60) + + # Show each user's unique configuration + config_info = { + "alice": ("Top-Left", "Blue theme, typewriter ON"), + "bob": ("Top-Right", "Orange theme, typewriter ON, larger font"), + "charlie": ("Bottom-Left", "Purple theme, typewriter OFF (instant)"), + } + + # Demonstrate different configurations simultaneously + for user_id, user in users.items(): + pos, desc = config_info[user_id] + hud_cfg = user.config["private_hud"] + + await user.show_private_message( + f"{user.display_name}'s Config", + f"""**Position:** {pos} + +**Style:** +- {desc} +- Border radius: `{hud_cfg['border_radius']}px` +- Font size: `{hud_cfg['font_size']}px` +- Opacity: `{hud_cfg['opacity']}` +- Accent: `{hud_cfg['accent_color']}` +""" + ) + await asyncio.sleep(0.3) + + await asyncio.sleep(delay) + + # Demonstrate progress bars with different colors in each user's panel + await users["alice"].show_private_progress("Blue Progress", 75, 100) + await users["bob"].show_private_progress("Orange Progress", 45, 100) + await users["charlie"].show_private_progress("Purple Progress", 90, 100) + + await asyncio.sleep(delay) + + # Timers with different durations + await users["alice"].show_private_timer("Short Timer", 5.0, "5 seconds") + await users["bob"].show_private_timer("Medium Timer", 10.0, "10 seconds") + await users["charlie"].show_private_timer("Long Timer", 15.0, "15 seconds") + + await asyncio.sleep(delay) + + # Team notification about the test + await shared.send_team_notification( + "Layout Test", + """HUDs displayed across screen: + +| User | Position | Theme | +|------|----------|-------| +| Alice | Top-Left | Blue | +| Bob | Top-Right | Orange | +| Charlie | Bottom-Left | Purple | +| Shared | Center | Dark | + +All HUDs running with unique configurations! +""" + ) + + await asyncio.sleep(delay * 2) + print("Mixed HUD configurations test complete") + + +async def test_concurrent_operations(users: dict[str, UserClient], + shared: SharedGroupManager, + delay: float = 0.5): + """Test concurrent operations from multiple users.""" + print("\n" + "="*60) + print("TEST: Concurrent Operations") + print("="*60) + + async def user_activity(user: UserClient, iteration: int): + """Simulate user activity.""" + await user.show_private_message( + f"Activity #{iteration}", + f"User **{user.display_name}** is active!\n\nIteration: `{iteration}`" + ) + await asyncio.sleep(0.2) + await user.add_private_item( + f"Item {iteration}", + f"Added at iteration {iteration}" + ) + + # Run concurrent operations from all users + for i in range(1, 4): + print(f" Iteration {i}...") + await asyncio.gather( + user_activity(users["alice"], i), + user_activity(users["bob"], i), + user_activity(users["charlie"], i), + shared.send_team_chat("System", f"Round {i} complete"), + ) + await asyncio.sleep(delay) + + # Rapid-fire team chat + messages = [ + ("Alice", "Quick message 1"), + ("Bob", "Quick message 2"), + ("Charlie", "Quick message 3"), + ("Alice", "Quick message 4"), + ("Bob", "Quick message 5"), + ] + + for sender, text in messages: + await shared.send_team_chat(sender, text) + await asyncio.sleep(0.1) + + await asyncio.sleep(delay) + print("Concurrent operations test complete") + + +# ============================================================================= +# MAIN TEST RUNNER +# ============================================================================= + +async def run_all_multiuser_tests(base_url: str = "http://127.0.0.1:7862", + delay_multiplier: float = 1.0): + """Run all multi-user tests.""" + print("\n" + "="*70) + print(" MULTIUSER HUD TEST SUITE") + print(" Testing multiple users, shared groups, disconnect/reconnect") + print("="*70) + + # Create users + users: dict[str, UserClient] = {} + for user_id, config in USER_CONFIGS.items(): + users[user_id] = UserClient( + user_id=user_id, + config=config, + base_url=base_url + ) + + # Create shared group manager + shared = SharedGroupManager(base_url) + + try: + # Connect all users + print("\n--- Connecting Users ---") + for user in users.values(): + await user.connect() + await shared.connect() + + # Run tests + await test_multiuser_basic_setup(users, shared, 1.5 * delay_multiplier) + await asyncio.sleep(2) + + await test_multiuser_shared_interaction(users, shared, 1.0 * delay_multiplier) + await asyncio.sleep(2) + + await test_disconnect_reconnect_with_save(users, shared, 2.0 * delay_multiplier) + await asyncio.sleep(2) + + await test_disconnect_reconnect_without_save(users, 2.0 * delay_multiplier) + await asyncio.sleep(2) + + await test_mixed_hud_configs(users, shared, 1.5 * delay_multiplier) + await asyncio.sleep(2) + + await test_concurrent_operations(users, shared, 0.5 * delay_multiplier) + await asyncio.sleep(2) + + print("\n" + "="*70) + print(" ALL MULTIUSER TESTS COMPLETE") + print("="*70) + + except Exception as e: + print(f"\nTest error: {e}") + import traceback + traceback.print_exc() + + finally: + # Cleanup + print("\n--- Cleanup ---") + for user in users.values(): + if user.connected: + await user.cleanup_private_groups() + await user.disconnect() + + if shared.connected: + await shared.cleanup_shared_groups() + await shared.disconnect() + + print("Cleanup complete") + + +async def run_with_server(): + """Run tests with automatic server management.""" + from hud_server import HudServer + + server = HudServer() + if not server.start(host="127.0.0.1", port=7862): + print("Failed to start HUD server") + return + + try: + await asyncio.sleep(1) # Wait for server to be ready + await run_all_multiuser_tests() + finally: + await server.stop() + + +if __name__ == "__main__": + import sys + + if "--with-server" in sys.argv: + # Run with automatic server management + asyncio.run(run_with_server()) + else: + # Assume server is already running + print("Connecting to existing HUD server...") + print("(Run with --with-server to auto-start the server)") + asyncio.run(run_all_multiuser_tests()) diff --git a/hud_server/tests/test_persistent.py b/hud_server/tests/test_persistent.py new file mode 100644 index 000000000..e39ba478b --- /dev/null +++ b/hud_server/tests/test_persistent.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +""" +Test Persistent - Persistent info panel tests. +""" + +import asyncio +from hud_server.tests.test_session import TestSession + +# Emoji constants using Unicode escape sequences (avoids file encoding issues) +EMOJI_TARGET = "\U0001F3AF" # 🎯 +EMOJI_SHIELD = "\U0001F6E1\uFE0F" # 🛡️ +EMOJI_TIMER = "\u23F1\uFE0F" # ⏱️ + + +async def test_persistent_info(session: TestSession, delay: float = 2.0): + """Test persistent info add/update/remove.""" + print(f"[{session.name}] Testing persistent info...") + + # Add items + await session.add_persistent_info(f"{EMOJI_TARGET} Objective", "Deliver cargo to **Station Alpha**") + await asyncio.sleep(delay) + + await session.add_persistent_info(f"{EMOJI_SHIELD} Shields", "Front: **100%** | Rear: *78%*") + await asyncio.sleep(delay) + + await session.add_persistent_info(f"{EMOJI_TIMER} Timer", "Auto-remove in 5s", duration=5.0) + await asyncio.sleep(delay) + + # Update + await session.update_persistent_info(f"{EMOJI_SHIELD} Shields", "Front: **100%** | Rear: **95%** *(charging)*") + await asyncio.sleep(delay) + + # Remove + await session.remove_persistent_info(f"{EMOJI_TARGET} Objective") + await asyncio.sleep(delay) + + # Wait for timer to expire + await asyncio.sleep(3) + + await session.clear_all_persistent_info() + print(f"[{session.name}] Persistent info test complete") + + +async def test_persistent_markdown(session: TestSession, delay: float = 3.0): + """Test markdown in persistent info.""" + print(f"[{session.name}] Testing persistent markdown...") + + await session.add_persistent_info("Status", """**Online** - All systems go +- Power: `98%` +- Fuel: *67%* +- Hull: ~~damaged~~ **repaired**""") + + await asyncio.sleep(delay * 2) + + await session.add_persistent_info("Tasks", """1. [x] Launch sequence +2. [x] Clear atmosphere +3. [ ] Set course +4. [ ] Engage autopilot""") + + await asyncio.sleep(delay * 2) + await session.clear_all_persistent_info() + print(f"[{session.name}] Persistent markdown test complete") + + +# ============================================================================= +# Run All Tests +# ============================================================================= + +async def run_all_persistent_tests(session: TestSession): + """Run all persistent tests.""" + await test_persistent_info(session) + await asyncio.sleep(1) + await test_persistent_markdown(session) diff --git a/hud_server/tests/test_progress.py b/hud_server/tests/test_progress.py new file mode 100644 index 000000000..26ab7b861 --- /dev/null +++ b/hud_server/tests/test_progress.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" +Test Progress - Progress bars and timer tests. +""" +import asyncio +from hud_server.tests.test_session import TestSession + +# Emoji constants using Unicode escape sequences (avoids file encoding issues) +EMOJI_DOWNLOAD = "\U0001F4E5" # 📥 +EMOJI_BATTERY = "\U0001F50B" # 🔋 +EMOJI_SATELLITE = "\U0001F4E1" # 📡 +EMOJI_TIMER = "\u23F1\uFE0F" # ⏱️ +EMOJI_REFRESH = "\U0001F504" # 🔄 + +async def test_progress_bars(session: TestSession, delay: float = 0.3): + print(f"[{session.name}] Testing progress bars...") + await session.show_progress(f"{EMOJI_DOWNLOAD} Download", 0, 100, "Starting download...") + for i in range(0, 101, 10): + await session.show_progress(f"{EMOJI_DOWNLOAD} Download", i, 100, f"Downloading... {i}%") + await asyncio.sleep(delay) + await asyncio.sleep(1) + await session.remove_persistent_info(f"{EMOJI_DOWNLOAD} Download") + await session.show_progress(f"{EMOJI_BATTERY} Charging", 0, 100, "Battery", progress_color="#4cd964") + await session.show_progress(f"{EMOJI_SATELLITE} Upload", 0, 500, "Sending data", progress_color="#ff9500") + for i in range(10): + await session.show_progress(f"{EMOJI_BATTERY} Charging", i * 10, 100) + await session.show_progress(f"{EMOJI_SATELLITE} Upload", i * 50, 500) + await asyncio.sleep(delay) + await asyncio.sleep(1) + await session.clear_all_persistent_info() + print(f"[{session.name}] Progress bars test complete") + +async def test_timers(session: TestSession): + print(f"[{session.name}] Testing timers...") + await session.show_timer(f"{EMOJI_TIMER} Cooldown", 5.0, "Jump drive charging...", auto_close=True) + await asyncio.sleep(2) + await session.show_timer(f"{EMOJI_REFRESH} Scan", 8.0, "Scanning area...", auto_close=False, progress_color="#9b59b6") + await asyncio.sleep(6) + await session.remove_persistent_info(f"{EMOJI_REFRESH} Scan") + await asyncio.sleep(2) + await session.clear_all_persistent_info() + print(f"[{session.name}] Timers test complete") +async def test_auto_close(session: TestSession): + print(f"[{session.name}] Testing auto-close...") + await session.show_progress("Auto-Close Test", 0, 100, "Will auto-close at 100%", auto_close=True) + for i in range(0, 101, 25): + await session.show_progress("Auto-Close Test", i, 100, f"Progress: {i}%", auto_close=True) + await asyncio.sleep(0.5) + await asyncio.sleep(3) + print(f"[{session.name}] Auto-close test complete") +async def run_all_progress_tests(session: TestSession): + await test_progress_bars(session) + await asyncio.sleep(1) + await test_timers(session) + await asyncio.sleep(1) + await test_auto_close(session) diff --git a/hud_server/tests/test_runner.py b/hud_server/tests/test_runner.py new file mode 100644 index 000000000..9754c5802 --- /dev/null +++ b/hud_server/tests/test_runner.py @@ -0,0 +1,95 @@ +""" +Test Runner - Utilities for running tests with the HUD server. +""" + +import asyncio +from typing import Callable + +from hud_server import HudServer +from hud_server.tests.test_session import TestSession, SESSION_CONFIGS + + +async def run_test(session: TestSession, test_func: Callable, *args, **kwargs): + """Run a single async test on a session.""" + try: + await test_func(session, *args, **kwargs) + except Exception as e: + print(f"[{session.name}] Test error: {e}") + import traceback + traceback.print_exc() + + +async def run_tests_sequential(sessions: list[TestSession], test_func: Callable, *args, **kwargs): + """Run a test function on all sessions sequentially.""" + for session in sessions: + await run_test(session, test_func, *args, **kwargs) + + +async def run_tests_parallel(sessions: list[TestSession], test_func: Callable, *args, **kwargs): + """Run a test function on all sessions in parallel.""" + tasks = [run_test(session, test_func, *args, **kwargs) for session in sessions] + await asyncio.gather(*tasks) + + +async def create_sessions(server_url: str = "http://127.0.0.1:7862", + session_ids: list[int] = None) -> list[TestSession]: + """Create and connect test sessions.""" + if session_ids is None: + session_ids = [1, 2, 3] + + sessions = [] + for sid in session_ids: + if sid in SESSION_CONFIGS: + session = TestSession(sid, SESSION_CONFIGS[sid], server_url) + if await session.start(): + sessions.append(session) + return sessions + + +async def cleanup_sessions(sessions: list[TestSession]): + """Disconnect all sessions.""" + for session in sessions: + await session.stop() + + +class TestContext: + """Context manager for running tests with automatic server and session management.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 7862, session_ids: list[int] = None): + self.host = host + self.port = port + self.session_ids = session_ids or [1] + self.server: HudServer = None + self.sessions: list[TestSession] = [] + + async def __aenter__(self): + # Start server + self.server = HudServer() + started = self.server.start(host=self.host, port=self.port) + if not started: + raise RuntimeError("Failed to start HUD server") + + # Create sessions + base_url = f"http://{self.host}:{self.port}" + self.sessions = await create_sessions(base_url, self.session_ids) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + # Cleanup sessions + await cleanup_sessions(self.sessions) + + # Stop server + if self.server: + await self.server.stop() + + +def run_interactive_test(test_func: Callable, session_ids: list[int] = None): + """Run a test interactively with automatic server management.""" + async def _run(): + async with TestContext(session_ids=session_ids or [1]) as ctx: + for session in ctx.sessions: + await test_func(session) + + asyncio.run(_run()) + diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py new file mode 100644 index 000000000..633789de4 --- /dev/null +++ b/hud_server/tests/test_session.py @@ -0,0 +1,343 @@ +""" +Test Session - HTTP-based test infrastructure for HUD Server testing. + +Provides the TestSession class that uses the HTTP API to send commands +to the HUD server and overlay. +""" + +from typing import Optional, Any + +from hud_server.http_client import HudHttpClient + + +# ============================================================================= +# SESSION CONFIGURATIONS +# ============================================================================= + +SESSION_CONFIGS = { + 1: { + "name": "Atlas", + # Layout (anchor-based) + "anchor": "top_left", + "priority": 20, + "persistent_anchor": "top_left", + "persistent_priority": 10, + "layout_mode": "auto", + # Sizes + "hud_width": 450, + "persistent_width": 350, + "hud_max_height": 500, + # Visual + "bg_color": "#1e212b", + "text_color": "#f0f0f0", + "accent_color": "#00aaff", + "user_color": "#4cd964", + "opacity": 0.9, + "border_radius": 12, + "font_size": 16, + "content_padding": 16, + "typewriter_effect": True, + }, + 2: { + "name": "Nova", + # Layout (anchor-based) + "anchor": "top_right", + "priority": 20, + "persistent_anchor": "top_right", + "persistent_priority": 10, + "layout_mode": "auto", + # Sizes + "hud_width": 400, + "persistent_width": 320, + "hud_max_height": 450, + # Visual + "bg_color": "#1a1f2e", + "text_color": "#e8e8e8", + "accent_color": "#ff6b35", + "user_color": "#ffd700", + "opacity": 0.85, + "border_radius": 8, + "font_size": 14, + "content_padding": 14, + "typewriter_effect": True, + }, + 3: { + "name": "Orion", + # Layout (anchor-based) + "anchor": "bottom_left", + "priority": 20, + "persistent_anchor": "bottom_left", + "persistent_priority": 10, + "layout_mode": "auto", + # Sizes + "hud_width": 380, + "persistent_width": 300, + "hud_max_height": 480, + # Visual + "bg_color": "#12161f", + "text_color": "#d0d0d0", + "accent_color": "#9b59b6", + "user_color": "#2ecc71", + "opacity": 0.88, + "border_radius": 16, + "font_size": 15, + "content_padding": 18, + "typewriter_effect": False, + }, +} + + +class TestSession: + """Manages a single HUD test session using HTTP API.""" + + def __init__(self, session_id: int, config: dict[str, Any], base_url: str = "http://127.0.0.1:7862"): + self.session_id = session_id + self.config = config + self.name = config["name"] + self.base_url = base_url + self._client: Optional[HudHttpClient] = None + self.running = False + + # Group name for this session + self.group_name = f"session_{session_id}_{self.name.lower()}" + self.persistent_group = f"persistent_{session_id}" + + async def start(self) -> bool: + """Connect to the HUD server.""" + try: + self._client = HudHttpClient(self.base_url) + if await self._client.connect(timeout=5.0): + self.running = True + print(f"[Session {self.session_id} - {self.name}] Connected to {self.base_url}") + return True + else: + print(f"[Session {self.session_id}] Failed to connect") + return False + except Exception as e: + print(f"[Session {self.session_id}] Error: {e}") + return False + + async def stop(self): + """Disconnect from the HUD server.""" + if self._client: + await self._client.disconnect() + self.running = False + print(f"[Session {self.session_id} - {self.name}] Disconnected") + + def _get_props(self) -> dict: + """Get display properties from config.""" + return { + # Layout (anchor-based) + "anchor": self.config.get("anchor", "top_left"), + "priority": self.config.get("priority", 20), + "layout_mode": self.config.get("layout_mode", "auto"), + # Size + "width": self.config["hud_width"], + "max_height": self.config["hud_max_height"], + # Visual + "bg_color": self.config["bg_color"], + "text_color": self.config["text_color"], + "accent_color": self.config["accent_color"], + "opacity": self.config["opacity"], + "border_radius": self.config["border_radius"], + "font_size": self.config["font_size"], + "content_padding": self.config["content_padding"], + "typewriter_effect": self.config["typewriter_effect"], + "duration": 8.0, + } + + def _get_persistent_props(self) -> dict: + """Get persistent panel properties from config.""" + return { + # Layout (anchor-based) + "anchor": self.config.get("persistent_anchor", "top_left"), + "priority": self.config.get("persistent_priority", 10), + "layout_mode": self.config.get("layout_mode", "auto"), + # Size + "width": self.config["persistent_width"], + # Visual + "bg_color": self.config["bg_color"], + "text_color": self.config["text_color"], + "accent_color": self.config["accent_color"], + "opacity": self.config["opacity"], + "border_radius": self.config["border_radius"], + "font_size": self.config["font_size"], + "content_padding": self.config["content_padding"], + } + + # ========================================================================= + # Message Commands + # ========================================================================= + + async def draw_message(self, title: str, message: str, color: Optional[str] = None, + tools: Optional[list[dict]] = None): + """Draw a message on the overlay.""" + if not self._client: + return + await self._client.show_message( + group_name=self.group_name, + title=title, + content=message, + color=color or self.config["accent_color"], + tools=tools, + props=self._get_props(), + ) + + async def draw_user_message(self, message: str): + """Draw a user message.""" + await self.draw_message("USER", message, self.config["user_color"]) + + async def draw_assistant_message(self, message: str, tools: Optional[list[dict]] = None): + """Draw an assistant message.""" + await self.draw_message(self.name, message, self.config["accent_color"], tools) + + async def hide(self): + """Hide the current message.""" + if not self._client: + return + await self._client.hide_message(group_name=self.group_name) + + async def set_loading(self, state: bool): + """Set loading indicator state.""" + if not self._client: + return + await self._client.show_loader( + group_name=self.group_name, + show=state, + color=self.config["accent_color"], + ) + + # ========================================================================= + # Persistent Info Commands + # ========================================================================= + + async def add_persistent_info(self, title: str, description: str, duration: Optional[float] = None): + """Add persistent information.""" + if not self._client: + return + await self._client.add_item( + group_name=self.persistent_group, + title=title, + description=description, + duration=duration, + ) + + async def update_persistent_info(self, title: str, description: str): + """Update persistent information.""" + if not self._client: + return + await self._client.update_item( + group_name=self.persistent_group, + title=title, + description=description, + ) + + async def remove_persistent_info(self, title: str): + """Remove persistent information.""" + if not self._client: + return + await self._client.remove_item(group_name=self.persistent_group, title=title) + + async def clear_all_persistent_info(self): + """Clear all persistent information.""" + if not self._client: + return + await self._client.clear_items(group_name=self.persistent_group) + + # ========================================================================= + # Progress Commands + # ========================================================================= + + async def show_progress(self, title: str, current: float, maximum: float, + description: str = "", auto_close: bool = False, + progress_color: Optional[str] = None): + """Show a graphical progress bar.""" + if not self._client: + return + await self._client.show_progress( + group_name=self.persistent_group, + title=title, + current=current, + maximum=maximum, + description=description, + color=progress_color, + auto_close=auto_close, + ) + + async def show_timer(self, title: str, duration: float, description: str = "", + auto_close: bool = True, progress_color: Optional[str] = None): + """Show a timer-based progress bar.""" + if not self._client: + return + await self._client.show_timer( + group_name=self.persistent_group, + title=title, + duration=duration, + description=description, + color=progress_color, + auto_close=auto_close, + ) + + # ========================================================================= + # Chat Window Commands + # ========================================================================= + + async def create_chat_window(self, name: str, **props): + """Create a chat window.""" + if not self._client: + return + await self._client.create_chat_window(name=name, **props) + + async def send_chat_message(self, window_name: str, sender: str, text: str, + color: Optional[str] = None): + """Send a message to a chat window.""" + if not self._client: + return + await self._client.send_chat_message( + window_name=window_name, + sender=sender, + text=text, + color=color, + ) + + async def clear_chat_window(self, name: str): + """Clear a chat window.""" + if not self._client: + return + await self._client.clear_chat_window(name) + + async def delete_chat_window(self, name: str): + """Delete a chat window.""" + if not self._client: + return + await self._client.delete_chat_window(name) + + async def show_chat_window(self, name: str): + """Show a chat window.""" + if not self._client: + return + await self._client.show_chat_window(name) + + async def hide_chat_window(self, name: str): + """Hide a chat window.""" + if not self._client: + return + await self._client.hide_chat_window(name) + + # ========================================================================= + # State Management + # ========================================================================= + + async def get_state(self) -> Optional[dict]: + """Get the current state of this session's group.""" + if not self._client: + return None + result = await self._client.get_state(self.group_name) + return result.get("state") if result else None + + async def health_check(self) -> bool: + """Check if the server is healthy.""" + if not self._client: + return False + return await self._client.health_check() + diff --git a/hud_server/tests/test_unicode_stress.py b/hud_server/tests/test_unicode_stress.py new file mode 100644 index 000000000..ae6131f9d --- /dev/null +++ b/hud_server/tests/test_unicode_stress.py @@ -0,0 +1,557 @@ +# -*- coding: utf-8 -*- +""" +Test Unicode Stress - Comprehensive Unicode, emoji, and special character tests. + +This is a stress test for: +- Emojis in all contexts (messages, persistent info, chat) +- Unicode symbols (arrows, math, currency, etc.) +- Emojis combined with markdown formatting +- Multi-character emoji sequences (skin tones, ZWJ, flags) +- Edge cases and unusual characters +""" + +import asyncio +from hud_server.tests.test_session import TestSession + +# ============================================================================= +# Unicode Constants - Using escape sequences to avoid file encoding issues +# ============================================================================= + +# Basic Emojis +EMOJI_ROCKET = "\U0001F680" # 🚀 +EMOJI_FIRE = "\U0001F525" # 🔥 +EMOJI_SPARKLES = "\u2728" # ✨ +EMOJI_STAR = "\u2B50" # ⭐ +EMOJI_CHECK = "\u2705" # ✅ +EMOJI_CROSS = "\u274C" # ❌ +EMOJI_WARNING = "\u26A0\uFE0F" # ⚠️ +EMOJI_INFO = "\u2139\uFE0F" # ℹ️ +EMOJI_QUESTION = "\u2753" # ❓ +EMOJI_EXCLAIM = "\u2757" # ❗ + +# Objects & Symbols +EMOJI_GEAR = "\u2699\uFE0F" # ⚙️ +EMOJI_WRENCH = "\U0001F527" # 🔧 +EMOJI_HAMMER = "\U0001F528" # 🔨 +EMOJI_SHIELD = "\U0001F6E1\uFE0F" # 🛡️ +EMOJI_SWORD = "\u2694\uFE0F" # ⚔️ +EMOJI_TARGET = "\U0001F3AF" # 🎯 +EMOJI_TROPHY = "\U0001F3C6" # 🏆 +EMOJI_MEDAL = "\U0001F3C5" # 🏅 +EMOJI_CROWN = "\U0001F451" # 👑 +EMOJI_GEM = "\U0001F48E" # 💎 + +# Tech & Gaming +EMOJI_CONTROLLER = "\U0001F3AE" # 🎮 +EMOJI_COMPUTER = "\U0001F4BB" # 💻 +EMOJI_SATELLITE = "\U0001F4E1" # 📡 +EMOJI_BATTERY = "\U0001F50B" # 🔋 +EMOJI_PLUG = "\U0001F50C" # 🔌 +EMOJI_DISK = "\U0001F4BE" # 💾 +EMOJI_FOLDER = "\U0001F4C1" # 📁 +EMOJI_CHART = "\U0001F4CA" # 📊 +EMOJI_CLIPBOARD = "\U0001F4CB" # 📋 +EMOJI_LOCK = "\U0001F512" # 🔒 + +# Nature & Weather +EMOJI_SUN = "\u2600\uFE0F" # ☀️ +EMOJI_MOON = "\U0001F319" # 🌙 +EMOJI_CLOUD = "\u2601\uFE0F" # ☁️ +EMOJI_LIGHTNING = "\u26A1" # ⚡ +EMOJI_SNOWFLAKE = "\u2744\uFE0F" # ❄️ +EMOJI_DROPLET = "\U0001F4A7" # 💧 +EMOJI_TREE = "\U0001F333" # 🌳 +EMOJI_MOUNTAIN = "\u26F0\uFE0F" # ⛰️ + +# Faces & People +EMOJI_SMILE = "\U0001F604" # 😄 +EMOJI_THINK = "\U0001F914" # 🤔 +EMOJI_COOL = "\U0001F60E" # 😎 +EMOJI_ROBOT = "\U0001F916" # 🤖 +EMOJI_ALIEN = "\U0001F47D" # 👽 +EMOJI_GHOST = "\U0001F47B" # 👻 +EMOJI_SKULL = "\U0001F480" # 💀 +EMOJI_THUMBSUP = "\U0001F44D" # 👍 +EMOJI_WAVE = "\U0001F44B" # 👋 +EMOJI_CLAP = "\U0001F44F" # 👏 + +# Arrows +ARROW_RIGHT = "\u2192" # → +ARROW_LEFT = "\u2190" # ← +ARROW_UP = "\u2191" # ↑ +ARROW_DOWN = "\u2193" # ↓ +ARROW_DOUBLE = "\u21D2" # ⇒ +ARROW_CYCLE = "\U0001F504" # 🔄 + +# Math & Currency +SYMBOL_INFINITY = "\u221E" # ∞ +SYMBOL_PLUSMINUS = "\u00B1" # ± +SYMBOL_DEGREE = "\u00B0" # ° +SYMBOL_MICRO = "\u00B5" # µ +SYMBOL_OMEGA = "\u03A9" # Ω +SYMBOL_DELTA = "\u0394" # Δ +SYMBOL_PI = "\u03C0" # π +SYMBOL_SIGMA = "\u03A3" # Σ +CURRENCY_DOLLAR = "\u0024" # $ +CURRENCY_EURO = "\u20AC" # € +CURRENCY_POUND = "\u00A3" # £ +CURRENCY_YEN = "\u00A5" # ¥ +CURRENCY_BITCOIN = "\u20BF" # ₿ + +# Box Drawing & Shapes +BOX_HORIZONTAL = "\u2500" # ─ +BOX_VERTICAL = "\u2502" # │ +BOX_CORNER_TL = "\u250C" # ┌ +BOX_CORNER_TR = "\u2510" # ┐ +BOX_CORNER_BL = "\u2514" # └ +BOX_CORNER_BR = "\u2518" # ┘ +SHAPE_SQUARE = "\u25A0" # ■ +SHAPE_CIRCLE = "\u25CF" # ● +SHAPE_TRIANGLE = "\u25B2" # ▲ +SHAPE_DIAMOND = "\u25C6" # ◆ + +# Bullets & Lists +BULLET_ROUND = "\u2022" # • +BULLET_TRIANGLE = "\u2023" # ‣ +BULLET_STAR = "\u2605" # ★ +BULLET_CHECK = "\u2713" # ✓ +BULLET_CROSS = "\u2717" # ✗ + +# Colored Circles (for status indicators) +CIRCLE_RED = "\U0001F534" # 🔴 +CIRCLE_ORANGE = "\U0001F7E0" # 🟠 +CIRCLE_YELLOW = "\U0001F7E1" # 🟡 +CIRCLE_GREEN = "\U0001F7E2" # 🟢 +CIRCLE_BLUE = "\U0001F535" # 🔵 +CIRCLE_PURPLE = "\U0001F7E3" # 🟣 + + +# ============================================================================= +# Test Functions +# ============================================================================= + +async def test_emoji_messages(session: TestSession, delay: float = 3.0): + """Test emojis in basic messages.""" + print(f"[{session.name}] Testing emoji messages...") + + # Simple emoji message + await session.draw_assistant_message( + f"""## {EMOJI_ROCKET} Mission Control {EMOJI_ROCKET} + +Welcome aboard, Commander! {EMOJI_STAR} + +Systems Status: +{EMOJI_CHECK} Navigation: Online +{EMOJI_CHECK} Shields: Active +{EMOJI_CHECK} Weapons: Armed +{EMOJI_WARNING} Fuel: 67% + +{EMOJI_TARGET} Current objective: Reach **Alpha Centauri** +{EMOJI_INFO} ETA: `4h 32m` + +> {EMOJI_SPARKLES} *All systems nominal* {EMOJI_SPARKLES} +""" + ) + await asyncio.sleep(delay) + + # Tech-themed message + await session.draw_assistant_message( + f"""## {EMOJI_COMPUTER} System Diagnostics {EMOJI_GEAR} + +Running full system check... + +{EMOJI_BATTERY} Power: `98%` {ARROW_RIGHT} Optimal +{EMOJI_SATELLITE} Signal: `Strong` {EMOJI_CHECK} +{EMOJI_DISK} Storage: `1.2TB / 2TB` +{EMOJI_LOCK} Security: **Enabled** + +### Component Status +| Module | Status | Temp | +|--------|--------|------| +| CPU {EMOJI_COMPUTER} | {CIRCLE_GREEN} OK | 45{SYMBOL_DEGREE}C | +| GPU {EMOJI_CONTROLLER} | {CIRCLE_GREEN} OK | 52{SYMBOL_DEGREE}C | +| RAM {EMOJI_CHART} | {CIRCLE_YELLOW} 78% | 38{SYMBOL_DEGREE}C | +| SSD {EMOJI_DISK} | {CIRCLE_GREEN} OK | 35{SYMBOL_DEGREE}C | + +{EMOJI_THUMBSUP} All checks passed! +""" + ) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Emoji messages test complete") + + +async def test_emoji_markdown_combo(session: TestSession, delay: float = 4.0): + """Test emojis combined with various markdown elements.""" + print(f"[{session.name}] Testing emoji + markdown combinations...") + + # Headers with emojis + await session.draw_assistant_message( + f"""## {EMOJI_TROPHY} Achievement Unlocked! {EMOJI_CROWN} + +You've earned the **Legendary** rank! {EMOJI_MEDAL} + +### {EMOJI_STAR} Stats Summary +- {EMOJI_SWORD} Battles Won: **1,337** +- {EMOJI_SHIELD} Damage Blocked: *2.5M* +- {EMOJI_TARGET} Accuracy: `98.7%` +- {EMOJI_GEM} Loot Collected: ~~1000~~ **1500** items + +### {EMOJI_CHART} Progress +1. [x] Complete tutorial {EMOJI_CHECK} +2. [x] Win first battle {EMOJI_SWORD} +3. [x] Reach level 50 {EMOJI_TROPHY} +4. [ ] Defeat final boss {EMOJI_SKULL} + +> {EMOJI_SPARKLES} *"The stars await, Commander!"* {EMOJI_ROCKET} + +--- + +{ARROW_DOUBLE} Next objective: **Sector 7** {EMOJI_ALIEN} +""" + ) + await asyncio.sleep(delay) + + # Code blocks with emojis + await session.draw_assistant_message( + f"""## {EMOJI_WRENCH} Configuration + +Here's your config file {EMOJI_FOLDER}: + +```yaml +# {EMOJI_GEAR} Settings +server: + host: "localhost" + port: 8080 # {EMOJI_PLUG} + +features: + - {EMOJI_SHIELD} shields + - {EMOJI_ROCKET} turbo + - {EMOJI_SATELLITE} radar +``` + +{EMOJI_INFO} **Note:** Changes require restart {ARROW_CYCLE} + +Math symbols: {SYMBOL_PI} = 3.14159, {SYMBOL_INFINITY} loops, {SYMBOL_PLUSMINUS}5% +Currency: {CURRENCY_DOLLAR}99.99 / {CURRENCY_EURO}89.99 / {CURRENCY_BITCOIN}0.002 +""" + ) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Emoji + markdown combo test complete") + + +async def test_emoji_persistent_info(session: TestSession, delay: float = 2.5): + """Test emojis in persistent info panels.""" + print(f"[{session.name}] Testing emoji persistent info...") + + # Status indicators with colored circles + await session.add_persistent_info( + f"{EMOJI_SATELLITE} Comm Status", + f"""{CIRCLE_GREEN} Primary: **Online** +{CIRCLE_GREEN} Backup: *Standby* +{CIRCLE_YELLOW} Emergency: `Charging` +{CIRCLE_RED} Deep Space: ~~Offline~~ **Connecting...**""" + ) + await asyncio.sleep(delay) + + # Ship systems + await session.add_persistent_info( + f"{EMOJI_ROCKET} Ship Systems", + f"""{EMOJI_SHIELD} Shields: `100%` {EMOJI_CHECK} +{EMOJI_BATTERY} Power: `87%` {EMOJI_LIGHTNING} +{EMOJI_GEAR} Engine: *Optimal* {EMOJI_FIRE} +{EMOJI_SATELLITE} Radar: **Active** {EMOJI_TARGET}""" + ) + await asyncio.sleep(delay) + + # Weather/environment + await session.add_persistent_info( + f"{EMOJI_CLOUD} Environment", + f"""{EMOJI_SUN} Solar radiation: **Low** +{EMOJI_MOON} Night cycle in: `2h 15m` +{EMOJI_SNOWFLAKE} Hull temp: -127{SYMBOL_DEGREE}C +{EMOJI_DROPLET} Humidity: 0% (vacuum)""" + ) + await asyncio.sleep(delay) + + # Mission objectives with checkmarks + await session.add_persistent_info( + f"{EMOJI_TARGET} Mission Objectives", + f"""{BULLET_CHECK} Objective 1: ~~Collect samples~~ **Done** +{BULLET_CHECK} Objective 2: ~~Deploy beacon~~ **Done** +{BULLET_STAR} Objective 3: **Explore crater** {ARROW_LEFT} Current +{BULLET_ROUND} Objective 4: Return to base""" + ) + await asyncio.sleep(delay) + + # Inventory with mixed symbols + await session.add_persistent_info( + f"{EMOJI_CLIPBOARD} Inventory", + f"""{SHAPE_DIAMOND} Credits: {CURRENCY_DOLLAR}15,000 +{EMOJI_GEM} Crystals: **42** {EMOJI_SPARKLES} +{EMOJI_WRENCH} Repair kits: `3` +{EMOJI_BATTERY} Fuel cells: *5 / 10*""" + ) + await asyncio.sleep(delay * 2) + + # Clear and show we're done + await session.clear_all_persistent_info() + print(f"[{session.name}] Emoji persistent info test complete") + + +async def test_emoji_progress_bars(session: TestSession, delay: float = 0.4): + """Test emojis in progress bar titles.""" + print(f"[{session.name}] Testing emoji progress bars...") + + # Multiple progress bars with emoji titles + await session.show_progress(f"{EMOJI_BATTERY} Charging", 0, 100, "Initializing...") + await session.show_progress(f"{EMOJI_DISK} Saving", 0, 100, "Preparing...") + await session.show_progress(f"{EMOJI_SATELLITE} Uploading", 0, 100, "Connecting...") + + # Animate all three + for i in range(0, 101, 5): + await session.show_progress(f"{EMOJI_BATTERY} Charging", i, 100, f"{i}% {EMOJI_LIGHTNING}") + await session.show_progress(f"{EMOJI_DISK} Saving", min(i + 10, 100), 100, f"Saving... {EMOJI_CHECK if i >= 90 else ''}") + await session.show_progress(f"{EMOJI_SATELLITE} Uploading", max(0, i - 20), 100, f"Sending data {ARROW_UP}") + await asyncio.sleep(delay) + + await asyncio.sleep(1) + await session.clear_all_persistent_info() + print(f"[{session.name}] Emoji progress bars test complete") + + +async def test_emoji_chat(session: TestSession, delay: float = 1.5): + """Test emojis in chat messages.""" + print(f"[{session.name}] Testing emoji chat...") + + chat_name = f"{session.name}_emoji_chat" + + # Create chat with emoji-rich sender names + await session.create_chat_window( + chat_name, + max_messages=20, + auto_hide=False, + sender_colors={ + f"Captain {EMOJI_CROWN}": "#FFD700", + f"Engineer {EMOJI_WRENCH}": "#00AAFF", + f"Pilot {EMOJI_ROCKET}": "#FF6B6B", + f"AI {EMOJI_ROBOT}": "#00FF88", + "System": "#888888", + } + ) + + # Chat conversation with lots of emojis + conversation = [ + ("System", f"{EMOJI_CHECK} Communication channel open"), + (f"Captain {EMOJI_CROWN}", f"All stations, report! {EMOJI_SATELLITE}"), + (f"Engineer {EMOJI_WRENCH}", f"Engineering ready! Shields at **100%** {EMOJI_SHIELD}"), + (f"Pilot {EMOJI_ROCKET}", f"Navigation locked {EMOJI_TARGET} {ARROW_RIGHT} Alpha Centauri"), + (f"AI {EMOJI_ROBOT}", f"All systems nominal {EMOJI_CHECK}{EMOJI_CHECK}{EMOJI_CHECK}"), + (f"Captain {EMOJI_CROWN}", f"Excellent! {EMOJI_THUMBSUP} Prepare for jump!"), + ("System", f"{EMOJI_WARNING} Quantum drive spooling..."), + (f"Engineer {EMOJI_WRENCH}", f"Power levels: {EMOJI_LIGHTNING}{EMOJI_LIGHTNING}{EMOJI_LIGHTNING}"), + (f"Pilot {EMOJI_ROCKET}", f"3... 2... 1... {EMOJI_FIRE}"), + ("System", f"{EMOJI_SPARKLES} Jump complete! Welcome to **Alpha Centauri** {EMOJI_STAR}"), + (f"AI {EMOJI_ROBOT}", f"Scanning... {EMOJI_SATELLITE} Found: 3 planets, 2 moons {EMOJI_MOON}"), + (f"Captain {EMOJI_CROWN}", f"{EMOJI_TROPHY} Great work team! {EMOJI_CLAP}{EMOJI_CLAP}"), + ] + + for sender, text in conversation: + await session.send_chat_message(chat_name, sender, text) + await asyncio.sleep(delay) + + await asyncio.sleep(2) + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Emoji chat test complete") + + +async def test_special_unicode(session: TestSession, delay: float = 3.0): + """Test special Unicode characters and edge cases.""" + print(f"[{session.name}] Testing special Unicode characters...") + + # Box drawing characters + await session.draw_assistant_message( + f"""## Box Drawing Characters + +Custom borders and frames: + +{BOX_CORNER_TL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_CORNER_TR} +{BOX_VERTICAL} DATA {BOX_VERTICAL} +{BOX_CORNER_BL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_HORIZONTAL}{BOX_CORNER_BR} + +Shapes: {SHAPE_SQUARE} {SHAPE_CIRCLE} {SHAPE_TRIANGLE} {SHAPE_DIAMOND} +Arrows: {ARROW_LEFT} {ARROW_UP} {ARROW_DOWN} {ARROW_RIGHT} {ARROW_DOUBLE} {ARROW_CYCLE} +Bullets: {BULLET_ROUND} {BULLET_TRIANGLE} {BULLET_STAR} {BULLET_CHECK} {BULLET_CROSS} +""" + ) + await asyncio.sleep(delay) + + # Math and science + await session.draw_assistant_message( + f"""## Math & Science {SYMBOL_SIGMA} + +### Mathematical Expressions + +Area of circle: {SYMBOL_PI}r{SYMBOL_DEGREE} +Temperature: 25{SYMBOL_DEGREE}C {SYMBOL_PLUSMINUS} 2{SYMBOL_DEGREE} +Resistance: 47k{SYMBOL_OMEGA} +Change: {SYMBOL_DELTA}v = 15 m/s +Sum: {SYMBOL_SIGMA}(1..n) = n(n+1)/2 +Limit: x {ARROW_RIGHT} {SYMBOL_INFINITY} + +### Scientific Notation +- 6.022 {SYMBOL_MICRO} x 10^23 +- Wavelength: 550nm ({EMOJI_SUN} visible) +""" + ) + await asyncio.sleep(delay) + + # Currency showcase + await session.draw_assistant_message( + f"""## Currency Exchange {EMOJI_CHART} + +### Current Rates + +| Currency | Symbol | Rate | +|----------|--------|------| +| USD | {CURRENCY_DOLLAR} | 1.00 | +| EUR | {CURRENCY_EURO} | 0.92 | +| GBP | {CURRENCY_POUND} | 0.79 | +| JPY | {CURRENCY_YEN} | 149.50 | +| BTC | {CURRENCY_BITCOIN} | 0.000023 | + +{EMOJI_SPARKLES} *Updated in real-time* {ARROW_CYCLE} +""" + ) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Special Unicode test complete") + + +async def test_emoji_status_indicators(session: TestSession, delay: float = 2.0): + """Test colored circle emojis as status indicators.""" + print(f"[{session.name}] Testing status indicators...") + + # Server status panel + await session.add_persistent_info( + f"{EMOJI_COMPUTER} Server Status", + f"""{CIRCLE_GREEN} API Server: **Running** +{CIRCLE_GREEN} Database: **Connected** +{CIRCLE_YELLOW} Cache: **Warming up** +{CIRCLE_RED} Backup: **Offline** +{CIRCLE_BLUE} CDN: **Syncing** +{CIRCLE_PURPLE} ML Engine: **Training**""" + ) + await asyncio.sleep(delay) + + # Player status + await session.add_persistent_info( + f"{EMOJI_CONTROLLER} Squad Status", + f"""{CIRCLE_GREEN} Player1 {EMOJI_CROWN}: *In Game* +{CIRCLE_GREEN} Player2 {EMOJI_SWORD}: *In Game* +{CIRCLE_YELLOW} Player3 {EMOJI_SHIELD}: *AFK* +{CIRCLE_RED} Player4 {EMOJI_ALIEN}: *Disconnected*""" + ) + await asyncio.sleep(delay) + + # Alert levels + await session.add_persistent_info( + f"{EMOJI_WARNING} Alert Level", + f"""Current: {CIRCLE_YELLOW} **CAUTION** + +{CIRCLE_GREEN} Green: All clear +{CIRCLE_YELLOW} Yellow: Caution advised +{EMOJI_FIRE} Orange: High alert +{CIRCLE_RED} Red: Emergency""" + ) + await asyncio.sleep(delay * 2) + + await session.clear_all_persistent_info() + print(f"[{session.name}] Status indicators test complete") + + +async def test_extreme_emoji_density(session: TestSession, delay: float = 4.0): + """Stress test with extremely high emoji density.""" + print(f"[{session.name}] Testing extreme emoji density (stress test)...") + + # Message packed with emojis + await session.draw_assistant_message( + f"""## {EMOJI_FIRE}{EMOJI_FIRE}{EMOJI_FIRE} STRESS TEST {EMOJI_FIRE}{EMOJI_FIRE}{EMOJI_FIRE} + +{EMOJI_ROCKET}{EMOJI_STAR}{EMOJI_SPARKLES}{EMOJI_TROPHY}{EMOJI_MEDAL}{EMOJI_CROWN}{EMOJI_GEM}{EMOJI_TARGET} + +### {EMOJI_LIGHTNING} Every {EMOJI_LIGHTNING} Word {EMOJI_LIGHTNING} Has {EMOJI_LIGHTNING} Emoji {EMOJI_LIGHTNING} + +{EMOJI_CHECK} Test {EMOJI_CHECK} One {EMOJI_CHECK} Two {EMOJI_CHECK} Three {EMOJI_CHECK} + +| {EMOJI_STAR} | {EMOJI_FIRE} | {EMOJI_ROCKET} | {EMOJI_SHIELD} | +|---|---|---|---| +| {CIRCLE_RED} | {CIRCLE_ORANGE} | {CIRCLE_YELLOW} | {CIRCLE_GREEN} | +| {EMOJI_THUMBSUP} | {EMOJI_CLAP} | {EMOJI_WAVE} | {EMOJI_COOL} | + +- {EMOJI_SWORD}{EMOJI_SHIELD} Combat: **Ready** {EMOJI_CHECK} +- {EMOJI_SATELLITE}{EMOJI_COMPUTER} Systems: *Online* {EMOJI_GEAR} +- {EMOJI_BATTERY}{EMOJI_PLUG} Power: `100%` {EMOJI_LIGHTNING} + +> {EMOJI_ROBOT} *"Processing {SYMBOL_INFINITY} possibilities..."* {EMOJI_ALIEN} + +{ARROW_UP}{ARROW_RIGHT}{ARROW_DOWN}{ARROW_LEFT} Navigation {ARROW_CYCLE} + +{EMOJI_SKULL}{EMOJI_GHOST}{EMOJI_ALIEN}{EMOJI_ROBOT}{EMOJI_COOL}{EMOJI_THINK}{EMOJI_SMILE} + +**{CURRENCY_DOLLAR}1000 {CURRENCY_EURO}920 {CURRENCY_POUND}790 {CURRENCY_YEN}149500 {CURRENCY_BITCOIN}0.023** + +{EMOJI_SPARKLES}{EMOJI_STAR}{EMOJI_SPARKLES}{EMOJI_STAR}{EMOJI_SPARKLES}{EMOJI_STAR}{EMOJI_SPARKLES} +""" + ) + await asyncio.sleep(delay) + + await session.hide() + print(f"[{session.name}] Extreme emoji density test complete") + + +# ============================================================================= +# Run All Tests +# ============================================================================= + +async def run_all_unicode_stress_tests(session: TestSession): + """Run all Unicode and emoji stress tests.""" + print("\n" + "=" * 60) + print("UNICODE & EMOJI STRESS TEST SUITE") + print("=" * 60) + + await test_emoji_messages(session) + await asyncio.sleep(1) + + await test_emoji_markdown_combo(session) + await asyncio.sleep(1) + + await test_emoji_persistent_info(session) + await asyncio.sleep(1) + + await test_emoji_progress_bars(session) + await asyncio.sleep(1) + + await test_emoji_chat(session) + await asyncio.sleep(1) + + await test_special_unicode(session) + await asyncio.sleep(1) + + await test_emoji_status_indicators(session) + await asyncio.sleep(1) + + await test_extreme_emoji_density(session) + + print("\n" + "=" * 60) + print("UNICODE & EMOJI STRESS TEST COMPLETE") + print("=" * 60) + + +if __name__ == "__main__": + from hud_server.tests.test_runner import run_interactive_test + run_interactive_test(run_all_unicode_stress_tests) diff --git a/skills/hud/default_config.yaml b/skills/hud/default_config.yaml new file mode 100644 index 000000000..e11a4ac4d --- /dev/null +++ b/skills/hud/default_config.yaml @@ -0,0 +1,161 @@ +module: skills.hud.main +name: HUD +display_name: HUD Overlay +auto_activate: true +author: JayMatthew +tags: + - Utility + - Overlay +description: + en: Display messages, information panels, progress bars, and timers on a transparent HUD overlay. The HUD server must be enabled in global settings for this skill to work. + de: Zeige Nachrichten, Informationspanels, Fortschrittsbalken und Timer auf einem transparenten HUD-Overlay an. Der HUD-Server muss in den globalen Einstellungen aktiviert sein. +hint: + en: Please make sure the HUD server in the global settings is enabled. + de: Bitte stelle sicher, dass der HUD-Server in den globalen Einstellungen aktiviert ist. +custom_properties: + - id: user_color + name: User Color + hint: Color for user name in messages (hex format). + property_type: string + value: "#4cd964" + required: false + + - id: accent_color + name: Accent Color + hint: Accent color for assistant messages and highlights (hex format). + property_type: string + value: "#00aaff" + required: false + + - id: bg_color + name: Background Color + hint: Background color of HUD elements (hex format). + property_type: string + value: "#1e212b" + required: false + + - id: text_color + name: Text Color + hint: Main text color (hex format). + property_type: string + value: "#f0f0f0" + required: false + + - id: chat_anchor + name: Chat Window Position + hint: Screen position for the chat window. Options are top_left, top_center, top_right, left_center, center, right_center, bottom_left, bottom_center, bottom_right. + property_type: string + value: "top_left" + required: false + + - id: chat_priority + name: Chat Window Priority + hint: Stacking priority for the chat window. Higher values appear closer to the anchor point. + property_type: number + value: 20 + required: false + + - id: hud_width + name: Chat Window Width + hint: Width of the chat window in pixels. + property_type: number + value: 400 + required: false + + - id: hud_max_height + name: Chat Window Max Height + hint: Maximum height of the chat window in pixels. + property_type: number + value: 600 + required: false + + - id: persistent_anchor + name: Info Panel Position + hint: Screen position for persistent info panels. Options are top_left, top_center, top_right, left_center, center, right_center, bottom_left, bottom_center, bottom_right. + property_type: string + value: "top_left" + required: false + + - id: persistent_priority + name: Info Panel Priority + hint: Stacking priority for info panels. Higher values appear closer to the anchor point. + property_type: number + value: 10 + required: false + + - id: persistent_width + name: Info Panel Width + hint: Width of persistent info panels in pixels. + property_type: number + value: 400 + required: false + + - id: opacity + name: Opacity + hint: Transparency of HUD elements (0.0 to 1.0). + property_type: number + value: 0.85 + required: false + + - id: border_radius + name: Border Radius + hint: Corner roundness of HUD elements in pixels. + property_type: number + value: 12 + required: false + + - id: content_padding + name: Content Padding + hint: Padding inside HUD elements in pixels. + property_type: number + value: 16 + required: false + + - id: font_size + name: Font Size + hint: Text font size in pixels. + property_type: number + value: 16 + required: false + + - id: font_family + name: Font Family + hint: Font family for HUD text. + property_type: string + value: "Segoe UI" + required: false + + - id: max_display_time + name: Audio Wait Time + hint: Maximum time in seconds to wait for audio playback before auto-hiding messages. If audio doesn't start within this time, the message will be hidden. + property_type: number + value: 5 + required: false + + - id: typewriter_effect + name: Typewriter Effect + hint: Animate text appearing character by character. + property_type: boolean + value: true + required: false + + - id: restore_persistent_items + name: Restore Persistent Items + hint: Restore info panels and progress bars after restart. + property_type: boolean + value: true + required: false + + - id: show_chat_messages + name: Show Chat Messages + hint: When enabled, conversations will appear on the HUD. + property_type: boolean + value: true + required: false + + - id: display_tool_names + name: Display Tool Names + hint: Show the actual tool function names instead of skill/source names. + property_type: boolean + value: false + required: false diff --git a/skills/hud/logo.png b/skills/hud/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0cf380d48f82d4a953e2ffd1e4ed7c283c39cd9b GIT binary patch literal 5812 zcmeHLX;f2Lw!Q&upd9c@Atgr8pkT?U(IN8X=md(dj_NR`X z+~O08Vx+#*^Rhp&Iom`_@5l8^u9{q1nK~VKf_KsY!-C6x^^iy5M<)nwqke9Aq0Dn8> zy@fg!V52|)=(-I6$v1TXV0%?*ULF6l?rvRUkvjN72mn0XfI?`}2Y~bEm4>K_U2AJ& zmhb`DQPOG}3K@g^;J)nW5OsW1ow{|JrOh^Q0?;Xk{s_78k01vCVE0~xZj)7in*7&! z$xkhHN)&SG^TWpZ{pwuQZy#sX=*r*oxS0Dt6ZKDZ?u_lz#%&}>??L1i&_UDBO^Dm> zMHn^)@w)`VtY5fkLAvl3bHQVG{(=!z@$5VdyO?9joCQJ#m&?dhEy!F3s8Fp1rp(`; zo(g5TH=Ef~)O*|({M{1H#`%F@j(fAR)e7s{Fv$We@c{6Nb(|xfnN)kSM281!;r*K% z!r~gSn4wi5i%QK|*BSXv3k9%ohSBsfsU%#SnRZ zl{TXoZYB2GH0CS!0D%3=>li^{YtIsbG#!3k$-F#(SYk5@4O+~<7Z<~tBZSzQx$wIH zFoNS-W12Q$hWrSf#jq1OkGT&3BKaFWi#Ul|_-Al*!{ESr0Kn-7wQwoMU_=Sb650&! zLgsRCei9^w5>!?p+A&DEi0tl2gUlc(t$4z7NIht8xSrjnymPKeMP+e1Sy%gV1+q%U z%=4aUO~k9cEf2H_QXV%!Ri@j6)Jj|&<6Z9roilNtZVU#XL5g%IW$2vosQ04}y+Nyf z;5s)x-dcr-+oX+~3OU#@T#pi%Sr8)~x@E(NFBmCr@lMG}Z@a!N+5^kgUkL!|_VNR6 z3;b!5J&`h~)*jQe0b|-=?U+DoQPWQm95`Hx=v34KUq_Ekze8o+rFM35N%?A1P|_H5 zjBZFSF7PJcr)?aQw1DKb)y?dlZ8J+T0uYou4@%+`5D3G+4Ma5{d^&gs`_rff{!_;C z>YxVn=WFnlXA!R@NI9lebJ6&1>Dsd|%>}z!w-ec&J|4fxktAnYV*?tU|0*wmYJ;aw zITm=3LkWWk(@DsflKWKRIbrPnW|g^M-QD3gb}3Nd;UP;T2JdY%he1g)M1J)lR(g=I zW6CKIlLG*`7FdJ=clYK>OS9sn?jMuukeH626Bc4;+CWJuNDA%_Z{^;=Q>*Nd53QT9 zlx>&-dr}U*2=2_UWxYkp#t84MFl%44%)=KuqO=qhFXpwPookj3jeGPDI(YRqT8N5w zEGGM_EPeI-lhL;&*e^r`I5{Sc>W~0{FhN_I2%-_My%;2%zVQV1Jj7MagS& z4EvFt<AnF9c?_W{aW>n+X*%*x;XBnE-0pFR#sQtm5P0`41; z?3|IaS2JQ{#bgC``L z-KU}v3xq5h>Qmz^FiEkmY{MN$INgYn$J}ogjuAvxmLc;}L$ykf*(m)`f!>ZB19;!> zpx@A8doMa`BUsg_xGAH!#W?orC+8hTxBiSI3;kDs=0HO0BK0hCGisz|t`b@LyzlX4 zygAzcTHAQfe4X@qsBrkw!H(pT+A^B5311(;W6N5MR*szuKS%)3dumfwC*#b}OsW;UwkB;@L z5Iy+u#sEnlNIv`w_N{x^scW%fd<2n9jUe2LY5cqUNWDVMEt#i@`=bBS+VOVL@QzS4 zlCr>n{C83ycj^%FJ0%6%bNqK}<-~-aLGs6``ZEKu9_Ym}3v2mAO4TIF9#0tDiD?Ra zWjnN;ghR%k6hAN;7-~>BjQpDUHX|gjP}8^Ite0H55eRJDq5EhGRW1~Zm$6UBvD-y< z`P(zU(E&>CQG@im@n)oGROF=&0G-W1rl5yk?!b zKy5XY+DBg7S%7@ZoDZFidCfwFX^o5;w*DNx>&lo^6Of0?Mu54bUnk$hK7)6#9kdhmz z@i6gp(gI@4fB>F6@3W&-&E>xYYIa#*rkc(u`$B{fcr<=-D6$M^_96b;xy}=b#^1FX zdWCCVAc{T+u+e6|6&J^`fV-;xl>A&Lp|@5d-e488RqL}W9(@b}7m|llFNy~xN7-Xj z;sG<#3=?mP{&- z5jQ$iM^o*EMx%Q9+T|=TOx>Xs$jVVA_O}l%Bm_5wb>Buxi+$&V$?kR9BYS>_m~{8* z(AWp7)~N95p5S6M!UZGAj749QZeqUPY<3x@%= zbA)$=NbWW}^4sWpkW_3Q9yZ3@b%G#+Ad2hh-q(d5CdZHxB8(HYi_DA@OBD4!ii@tD z@&+6xyfTW|$>GX8OuP^ai#G1CkK=}}pqzkvx}|W3U)_8jz%%EVm$@2&GP-?Vi-Cv9 zYFiZh62sfkPCcAz+QZ6p1TqEMPm>0nG~ZyFPQOShxJ@<7D>qx+0kD0;70xD^rui%+*ELBRfKu$umRwJfOixPJwDi(cun64YE2oWwnicZMevpk%LMj6;D! zQ%_aA*H;RztdQm``_$c!FOsD|EMwiLur0DdmGWMtVWT}zoc)B}i`ks*VRR|$Z6$Vw zXdy~l^fKJJzj=q1OmdxY`PaWi)m$0ZL=&f4K5CX1!xdFClq|^IqkrrbxYgD?(nAM8 zcRmLgh$^>k?dn8#ZMhmWC1d^ccO8174eq9MkmJ)6h#c+45980k(u3 z=eEGmS1Nvh=b!VA6E1f`7B;*RmxM<2CQJMhnQ? z6P#I}Lw7t)47&O-=R_~DwV(*Dx5@c-syvG5qKa@M3^u?8wX{c+iBu^%{1?mCWp}7` zb6=Z;V^Bcr_$F|x=~CaxU{KNx`(P8NQee2=PFDk>!qO`@7g{b)Gm4cFdtFnYSoh{= z%$d36@aTvpm%Gs0rX1U^2jDEIahKf^WKRYM&!i+5hO1iG@O&yn4suKH(JVq}e#r=| zKTeP_U}Lvt@&4wl{mp(^?>Pz17}I%Tw_8n)2%uEovP(3#W%Jw zfRLd*stno-U+^s>3!>VSTEyALXNBaFbi<#T$CiMSUm*EBR4dy`X(PQHHpk9%2dQ`R z0c1|%0gQlRTR0u^6^lj*qb>hxX{mLHNsaw0G2U7?%Uk!Rm{w&Mj_?vnEl}Ly+_j`9 zejCB7rH$NVty={|a<|P;h`k2*2gWKNrjKdk{2Y9xV>&dBzbEk#JaH&nTRLhHa10%S zOQHzW{l%~ZJLAYnSSIuKH>QoCG|&C3oyRqA=7w_@!L}8cf&q?-J4NqJIo0n-5o}62 zL{EbptnnMr*!s7b*VMY0R~{B15P9vm4zU+UQ1=HpC$~)hHpsMLkN~s3 zo?#=9RoP&h1-Yg}#n~xDHCdq($C*rsLT5=5r>5s01(}MglbkT>nkAGK-5iX-%(mxj zC8z~>9tl?OC+u>;sC%L&<~H(^;&$2Bgz;C-9qGGaFnwGTdscXIeOks=1Atb}MH}j< z@*$pLP`Osp0m)Oj=nxCsGNRq&>4YDyYhoO=0jJy4!r}`hm*xZzjv;Y4&7GA+7Tz>y zr=&oX0;-rN-CavU3Q?wY>~q&GnCE3zzUjD;K)tT{SGPP^&sCIOm$+(>u80=z=ZM|#xNgpw-@%ZQ@?4(Rh(`Gls4@8yA zC4L4s2xTWN9?dUs6MTbXzTjO)YMc3t&RL=Bse)?P=CLEK=Q1lE@Rv{(LO+9>=4-Mt z)Mwlh26Int#?on2@r?hJodfE{&9lO_@B~6uUQ0UJ>A};>Pw_TkI?lbnHpPc}v@R5L zu4=}pj$TuvyY&EG?m3fTdJ#+y_;$o8TQMLLDa51^jfA@}Ql|Uz@SHAZ&9gq68EL?U zGf!V4Tgl_>SBWiqW|7O@i%mLB7hY<@T^spa`2y({B{(xr#fy2;nCM*;)i8uJNm zgNj)Pl3kTm>|%JLhV%G(miL(vY|O&`#BCT`|EE_}o3ds_&D61DoL&yyEWo9W@*P*F zkzX$R0s1QBOv~~@@wy({5aIp$HMXZnTWY3esji8{*?omIuE97I;j;RtOr;7VIAktp z0!e*A>Ysvat{%$4r;T zx{g69a^d|jOT`WyTHy*TJQR2AEA$k7F4nfcy4b7iQWu~CV`+&fUVO(Nh0rx87REHj z#XBzn20qBf=6tIL3!naLzl^G None: + super().__init__(config=config, settings=settings, wingman=wingman) + + # State + self.active = False + self.stop_event = threading.Event() + self.current_display_text = "" + self.expecting_audio = False + self.audio_expect_start_time = 0.0 + + # Persistent items storage + self._persistent_items: dict[str, dict] = {} + + # Data persistence + self.data_path = get_writable_dir(path.join("skills", "hud", "data")) + self.persistent_file = path.join( + self.data_path, + f"persistent_info_{self.wingman.name}.json" + ) + + # HTTP client + self._client: Optional[HudHttpClient] = None + self._monitor_task: Optional[asyncio.Task] = None + self._main_loop: Optional[asyncio.AbstractEventLoop] = None + + # Groups configuration + self._messages_group = "messages" + self._persistent_group = "persistent" + + # ─────────────────────────────── Configuration ─────────────────────────────── # + + async def validate(self) -> list[WingmanInitializationError]: + """Validate skill configuration.""" + errors = await super().validate() + + # Check if HUD server is enabled + hud_settings = getattr(self.settings, 'hud_server', None) + if not hud_settings or not hud_settings.enabled: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message="HUD Server is not enabled in global settings. " + "Go to Settings → HUD Server and enable it.", + error_type=WingmanInitializationErrorType.UNKNOWN + ) + ) + + # Validate custom properties + valid_anchors = [ + "top_left", "top_center", "top_right", + "left_center", "center", "right_center", + "bottom_left", "bottom_center", "bottom_right" + ] + + # Color validation helper + def is_valid_hex_color(color: str) -> bool: + if not isinstance(color, str): + return False + if not color.startswith('#'): + return False + hex_part = color[1:] + if len(hex_part) not in (3, 6): + return False + try: + int(hex_part, 16) + return True + except ValueError: + return False + + # Validate user_color + user_color = self.retrieve_custom_property_value("user_color", errors) + if not is_valid_hex_color(user_color): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid user_color: '{user_color}'. Must be a valid hex color (e.g., #ffffff).", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate accent_color + accent_color = self.retrieve_custom_property_value("accent_color", errors) + if not is_valid_hex_color(accent_color): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid accent_color: '{accent_color}'. Must be a valid hex color (e.g., #ffffff).", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate bg_color + bg_color = self.retrieve_custom_property_value("bg_color", errors) + if not is_valid_hex_color(bg_color): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid bg_color: '{bg_color}'. Must be a valid hex color (e.g., #ffffff).", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate text_color + text_color = self.retrieve_custom_property_value("text_color", errors) + if not is_valid_hex_color(text_color): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid text_color: '{text_color}'. Must be a valid hex color (e.g., #ffffff).", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate chat_anchor + chat_anchor = self.retrieve_custom_property_value("chat_anchor", errors) + if chat_anchor not in valid_anchors: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid chat_anchor: '{chat_anchor}'. Must be one of: {', '.join(valid_anchors)}.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate chat_priority + chat_priority = self.retrieve_custom_property_value("chat_priority", errors) + if not isinstance(chat_priority, (int, float)) or chat_priority < 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid chat_priority: '{chat_priority}'. Must be a non-negative number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate hud_width + hud_width = self.retrieve_custom_property_value("hud_width", errors) + if not isinstance(hud_width, (int, float)) or hud_width <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid hud_width: '{hud_width}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate hud_max_height + hud_max_height = self.retrieve_custom_property_value("hud_max_height", errors) + if not isinstance(hud_max_height, (int, float)) or hud_max_height <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid hud_max_height: '{hud_max_height}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate persistent_anchor + persistent_anchor = self.retrieve_custom_property_value("persistent_anchor", errors) + if persistent_anchor not in valid_anchors: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid persistent_anchor: '{persistent_anchor}'. Must be one of: {', '.join(valid_anchors)}.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate persistent_priority + persistent_priority = self.retrieve_custom_property_value("persistent_priority", errors) + if not isinstance(persistent_priority, (int, float)) or persistent_priority < 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid persistent_priority: '{persistent_priority}'. Must be a non-negative number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate persistent_width + persistent_width = self.retrieve_custom_property_value("persistent_width", errors) + if not isinstance(persistent_width, (int, float)) or persistent_width <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid persistent_width: '{persistent_width}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate opacity + opacity = self.retrieve_custom_property_value("opacity", errors) + if not isinstance(opacity, (int, float)) or not (0.0 <= opacity <= 1.0): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid opacity: '{opacity}'. Must be a number between 0.0 and 1.0.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate border_radius + border_radius = self.retrieve_custom_property_value("border_radius", errors) + if not isinstance(border_radius, (int, float)) or border_radius < 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid border_radius: '{border_radius}'. Must be a non-negative number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate content_padding + content_padding = self.retrieve_custom_property_value("content_padding", errors) + if not isinstance(content_padding, (int, float)) or content_padding < 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid content_padding: '{content_padding}'. Must be a non-negative number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate font_size + font_size = self.retrieve_custom_property_value("font_size", errors) + if not isinstance(font_size, (int, float)) or font_size <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid font_size: '{font_size}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate font_family + font_family = self.retrieve_custom_property_value("font_family", errors) + if not isinstance(font_family, str) or not font_family.strip(): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid font_family: '{font_family}'. Must be a non-empty string.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate max_display_time + max_display_time = self.retrieve_custom_property_value("max_display_time", errors) + if not isinstance(max_display_time, (int, float)) or max_display_time <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid max_display_time: '{max_display_time}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate typewriter_effect + typewriter_effect = self.retrieve_custom_property_value("typewriter_effect", errors) + if not isinstance(typewriter_effect, bool): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid typewriter_effect: '{typewriter_effect}'. Must be a boolean.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate restore_persistent_items + restore_persistent_items = self.retrieve_custom_property_value("restore_persistent_items", errors) + if not isinstance(restore_persistent_items, bool): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid restore_persistent_items: '{restore_persistent_items}'. Must be a boolean.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate show_chat_messages + show_chat_messages = self.retrieve_custom_property_value("show_chat_messages", errors) + if not isinstance(show_chat_messages, bool): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid show_chat_messages: '{show_chat_messages}'. Must be a boolean.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + # Validate display_tool_names + display_tool_names = self.retrieve_custom_property_value("display_tool_names", errors) + if not isinstance(display_tool_names, bool): + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid display_tool_names: '{display_tool_names}'. Must be a boolean.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + + return errors + + def _get_prop(self, key: str, default): + """Get a custom property value with fallback to default.""" + val = self.retrieve_custom_property_value(key, []) + return val if val is not None else default + + def _get_hud_props(self) -> dict: + """Get all HUD visual properties as a dictionary.""" + return { + 'anchor': str(self._get_prop("chat_anchor", "top_left")), + 'priority': int(self._get_prop("chat_priority", 20)), + 'layout_mode': 'auto', + 'width': int(self._get_prop("hud_width", 400)), + 'max_height': int(self._get_prop("hud_max_height", 600)), + 'bg_color': str(self._get_prop("bg_color", "#1e212b")), + 'text_color': str(self._get_prop("text_color", "#f0f0f0")), + 'accent_color': str(self._get_prop("accent_color", "#00aaff")), + 'opacity': float(self._get_prop("opacity", 0.85)), + 'border_radius': int(self._get_prop("border_radius", 12)), + 'font_size': int(self._get_prop("font_size", 16)), + 'content_padding': int(self._get_prop("content_padding", 16)), + 'font_family': str(self._get_prop("font_family", "Segoe UI")), + 'typewriter_effect': bool(self._get_prop("typewriter_effect", True)), + } + + def _get_persistent_props(self) -> dict: + """Get properties for persistent info panels.""" + return { + 'anchor': str(self._get_prop("persistent_anchor", "top_left")), + 'priority': int(self._get_prop("persistent_priority", 10)), + 'layout_mode': 'auto', + 'persistent_width': int(self._get_prop("persistent_width", 400)), + 'bg_color': str(self._get_prop("bg_color", "#1e212b")), + 'text_color': str(self._get_prop("text_color", "#f0f0f0")), + 'accent_color': str(self._get_prop("accent_color", "#00aaff")), + 'opacity': float(self._get_prop("opacity", 0.85)), + 'border_radius': int(self._get_prop("border_radius", 12)), + 'font_size': int(self._get_prop("font_size", 16)), + 'content_padding': int(self._get_prop("content_padding", 16)), + 'font_family': str(self._get_prop("font_family", "Segoe UI")), + } + + async def update_config(self, new_config) -> None: + """Handle configuration updates - recreate HUD groups with new settings.""" + # Check if custom_properties actually changed before doing anything + old_config = self.config + await super().update_config(new_config) + + if old_config.custom_properties == new_config.custom_properties: + await printr.print_async( + "[HUD] update_config: custom_properties unchanged, skipping", + color=LogType.INFO, + server_only=True + ) + return + + await printr.print_async( + "[HUD] update_config: custom_properties CHANGED, recreating groups...", + color=LogType.INFO, + server_only=True + ) + + if not await self._ensure_connected(): + await printr.print_async( + "[HUD] update_config: failed to connect, aborting", + color=LogType.WARNING, + server_only=True + ) + return + + # Get new props + msg_props = self._get_hud_props() + pers_props = self._get_persistent_props() + + # Delete and recreate message group + await printr.print_async( + f"[HUD] update_config: recreating messages group '{self._messages_group}'", + color=LogType.INFO, + server_only=True + ) + await self._client.delete_group(self._messages_group) + await self._client.create_group(self._messages_group, props=msg_props) + + # Delete and recreate persistent group, then restore items + await printr.print_async( + f"[HUD] update_config: recreating persistent group '{self._persistent_group}'", + color=LogType.INFO, + server_only=True + ) + await self._client.delete_group(self._persistent_group) + await self._client.create_group(self._persistent_group, props=pers_props) + + # Re-add all persistent items with the new group settings + if self._persistent_items: + await self._restore_persistent_items() + await printr.print_async( + f"[HUD] update_config: restoring {len(self._persistent_items)} persistent item(s)", + color=LogType.INFO, + server_only=True + ) + + async def _ensure_connected(self) -> bool: + """Ensure the HUD client is connected. Create client and connect if needed.""" + # Get HUD server settings + hud_settings = getattr(self.settings, 'hud_server', None) + if not hud_settings or not hud_settings.enabled: + return False + + # Check if we're in a different event loop than when the client was created + # If so, we need to create a new client + try: + current_loop = asyncio.get_running_loop() + if self._main_loop is not None and self._main_loop != current_loop: + # Event loop changed - need to recreate client + if self._client: + try: + await self._client.disconnect() + except Exception: + pass + self._client = None + self._main_loop = current_loop + except RuntimeError: + pass + + # Create client if it doesn't exist (e.g., after skill reactivation or loop change) + if not self._client: + base_url = f"http://{hud_settings.host}:{hud_settings.port}" + self._client = HudHttpClient(base_url=base_url) + + # Store current loop reference + try: + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + pass + + # Setup group names if not done + if self._messages_group == "messages": + sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) + self._messages_group = f"messages_{sanitized_name}" + self._persistent_group = f"persistent_{sanitized_name}" + + if not self._client.connected: + # Try to connect/reconnect + try: + if await self._client.connect(timeout=3.0): + await printr.print_async( + "[HUD] Connected to HUD server", + color=LogType.INFO, + server_only=True + ) + self.active = True + + # Create/update groups after connect + msg_props = self._get_hud_props() + pers_props = self._get_persistent_props() + await self._client.create_group(self._messages_group, props=msg_props) + await self._client.create_group(self._persistent_group, props=pers_props) + + # Start audio monitor if not running + if not self._monitor_task or self._monitor_task.done(): + self.stop_event.clear() + self._monitor_task = asyncio.create_task(self._audio_monitor_loop()) + + return True + else: + return False + except Exception as e: + await printr.print_async( + f"[HUD] Connection failed: {e}", + color=LogType.WARNING, + server_only=True + ) + return False + return True + + # ─────────────────────────────── Lifecycle ─────────────────────────────── # + + async def prepare(self) -> None: + """Prepare the skill - connect to HUD server.""" + await super().prepare() + self.stop_event.clear() + + # Get HUD server settings + hud_settings = getattr(self.settings, 'hud_server', None) + if not hud_settings or not hud_settings.enabled: + await printr.print_async( + "[HUD] HUD Server is not enabled in global settings.", + color=LogType.ERROR, + server_only=True + ) + self.active = False + return + + # Connect to HUD server + base_url = f"http://{hud_settings.host}:{hud_settings.port}" + self._client = HudHttpClient(base_url=base_url) + + # store the loop where the client was created + try: + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + pass + + # Setup groups with unique names per wingman + sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) + self._messages_group = f"messages_{sanitized_name}" + self._persistent_group = f"persistent_{sanitized_name}" + + try: + if await self._client.connect(timeout=3.0): + await printr.print_async( + f"[HUD] Connected to HUD server at {base_url}", + color=LogType.INFO, + server_only=True + ) + self.active = True + + # Create/Ensure groups exist with this wingman's HUD props + try: + msg_props = self._get_hud_props() + pers_props = self._get_persistent_props() + await self._client.create_group(self._messages_group, props=msg_props) + await self._client.create_group(self._persistent_group, props=pers_props) + except Exception: + pass + else: + await printr.print_async( + f"[HUD] Failed to connect to HUD server at {base_url}. " + "Make sure it's enabled and running.", + color=LogType.ERROR, + server_only=True + ) + self._client = None + self.active = False + return + except Exception as e: + await printr.print_async( + f"[HUD] Connection error: {e}", + color=LogType.ERROR, + server_only=True + ) + self._client = None + self.active = False + return + + # Restore persistent items + await self._restore_persistent_items() + + # Start audio monitor + self._monitor_task = asyncio.create_task(self._audio_monitor_loop()) + + # Show init message + accent_color = str(self._get_prop("accent_color", "#00aaff")) + message = "HUD initialized" + if self._get_prop("restore_persistent_items", True): + message += " & restored elements" + await self._show_message(self.wingman.name, message, accent_color, duration=4.0) + + async def unload(self) -> None: + """Cleanup when skill is unloaded.""" + await super().unload() + + printr.print( + f"[HUD] Unloading for {self.wingman.name}", + color=LogType.INFO, + server_only=True + ) + + self.stop_event.set() + + # Cancel monitor task + if self._monitor_task: + try: + task_loop = self._monitor_task.get_loop() + current_loop = None + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + pass + + if current_loop and task_loop == current_loop: + self._monitor_task.cancel() + try: + await self._monitor_task + except asyncio.CancelledError: + pass + else: + # Task is on a different loop, await would crash + if not task_loop.is_closed(): + task_loop.call_soon_threadsafe(self._monitor_task.cancel) + except Exception as e: + printr.print( + f"[HUD] Error cancelling monitor task: {e}", + color=LogType.WARNING, + server_only=True + ) + self._monitor_task = None + + # Save state + self._save_persistent_items() + self._persistent_items.clear() + + # Disconnect client + if self._client: + try: + await self._client.disconnect() + except Exception: + pass + self._client = None + + self.active = False + + # Reset prepared state so skill can be reactivated + # (base class doesn't do this, so we need to do it explicitly) + self.is_prepared = False + self.is_validated = False + self.is_unloaded = False # Allow unload to be called again on next deactivation + + # ─────────────────────────────── Audio Monitor ─────────────────────────────── # + + async def _audio_monitor_loop(self): + """Monitor audio playback and hide messages when audio stops.""" + was_playing = False + + while not self.stop_event.is_set(): + try: + # Check audio status + is_playing = False + try: + if self.wingman and self.wingman.audio_player: + is_playing = self.wingman.audio_player.is_playing + except Exception: + pass + + # Audio just started - reset expecting flag + if is_playing and not was_playing: + await printr.print_async( + f"[HUD] Audio started playing, resetting expecting_audio flag", + color=LogType.INFO, + server_only=True + ) + self.expecting_audio = False + + # Hide message when audio stops + if was_playing and not is_playing: + await printr.print_async( + f"[HUD] Audio stopped playing, waiting 0.5s before hiding", + color=LogType.INFO, + server_only=True + ) + await asyncio.sleep(0.5) # Brief delay for readability + + # Re-check if audio started during the delay + still_not_playing = True + try: + if self.wingman and self.wingman.audio_player: + still_not_playing = not self.wingman.audio_player.is_playing + except Exception: + pass + + if still_not_playing: + await printr.print_async( + f"[HUD] Audio still not playing, calling hide_message", + color=LogType.INFO, + server_only=True + ) + await self._hide_message() + else: + await printr.print_async( + f"[HUD] Audio started again during delay, NOT hiding", + color=LogType.INFO, + server_only=True + ) + self.expecting_audio = False + + was_playing = is_playing + + # Handle audio timeout - hide message if audio doesn't start in time + if not is_playing and self.expecting_audio: + max_display_time = float(self._get_prop("max_display_time", 5)) + elapsed = time.time() - self.audio_expect_start_time + if elapsed > max_display_time: + await printr.print_async( + f"[HUD] Audio timeout ({elapsed:.1f}s > {max_display_time}s), hiding message", + color=LogType.INFO, + server_only=True + ) + self.expecting_audio = False + await self._hide_message() + + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + break + except Exception as e: + await printr.print_async( + f"[HUD] Monitor error: {e}", + color=LogType.ERROR, + server_only=True + ) + await asyncio.sleep(1.0) + + # ─────────────────────────────── HTTP Client Helpers ─────────────────────────────── # + + async def _show_message( + self, + title: str, + message: str, + color: str, + tools: list = None, + duration: float = 60.0 + ): + """Show a message on the HUD.""" + if not await self._ensure_connected(): + return + + props = self._get_hud_props() + props['duration'] = duration + + result = await self._client.show_message( + group_name=self._messages_group, + title=title, + content=message, + color=color, + tools=tools, + props=props, + duration=duration + ) + if result is None and self.active: + await printr.print_async( + "[HUD] Failed to show message - server may be unavailable", + color=LogType.WARNING, + server_only=True + ) + + async def _hide_message(self): + """Hide the current message.""" + if not await self._ensure_connected(): + await printr.print_async( + "[HUD] _hide_message called but no client connected", + color=LogType.WARNING, + server_only=True + ) + return + + await printr.print_async( + f"[HUD] Sending hide_message request to server for group '{self._messages_group}'", + color=LogType.INFO, + server_only=True + ) + result = await self._client.hide_message(group_name=self._messages_group) + if result is None: + await printr.print_async( + f"[HUD] hide_message returned None (possible error or group not found)", + color=LogType.WARNING, + server_only=True + ) + else: + await printr.print_async( + f"[HUD] hide_message successful: {result}", + color=LogType.INFO, + server_only=True + ) + + async def _show_loader(self, show: bool, color: str = None): + """Show or hide the loading animation.""" + if not await self._ensure_connected(): + return + await self._client.show_loader(group_name=self._messages_group, show=show, color=color) + + def _send_command_sync(self, coro): + """Send a command synchronously (for @tool methods).""" + if self._main_loop and self._main_loop.is_running(): + asyncio.run_coroutine_threadsafe(coro, self._main_loop) + return + + try: + loop = asyncio.get_running_loop() + loop.create_task(coro) + except RuntimeError: + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + future = asyncio.run_coroutine_threadsafe(coro, loop) + future.result(timeout=5.0) + else: + loop.run_until_complete(coro) + except Exception as e: + printr.print( + f"[HUD] Command error: {e}", + color=LogType.ERROR, + server_only=True + ) + + # ─────────────────────────────── Persistence ─────────────────────────────── # + + def _save_persistent_items(self): + """Save persistent items to file.""" + try: + os.makedirs(os.path.dirname(self.persistent_file), exist_ok=True) + with open(self.persistent_file, "w", encoding="utf-8") as f: + json.dump(self._persistent_items, f, indent=2) + except Exception as e: + printr.print( + f"[HUD] Failed to save state: {e}", + color=LogType.ERROR, + server_only=True + ) + + async def _restore_persistent_items(self): + """Restore persistent items from file.""" + if not path.exists(self.persistent_file): + return + + if not self._get_prop("restore_persistent_items", True): + return + + try: + with open(self.persistent_file, "r", encoding="utf-8") as f: + saved = json.load(f) + except Exception as e: + await printr.print_async( + f"[HUD] Failed to load state: {e}", + color=LogType.ERROR, + server_only=True + ) + return + + now = time.time() + for title, item in saved.items(): + # Skip expired items + if item.get('expiry') and now > item['expiry']: + continue + + if item.get('is_progress'): + if item.get('is_timer'): + # Handle timer restoration + elapsed = now - item.get('timer_start', item['added_at']) + remaining = item['timer_duration'] - elapsed + + if remaining > 0 or not item.get('auto_close', True): + self._persistent_items[title] = item + self._send_command_sync( + self._client.show_timer( + group_name=self._persistent_group, + title=title, + duration=item['timer_duration'], + description=item.get('description', ''), + color=item.get('color'), + auto_close=item.get('auto_close', True), + initial_progress=elapsed + ) + ) + else: + # Regular progress bar + self._persistent_items[title] = item + self._send_command_sync( + self._client.show_progress( + group_name=self._persistent_group, + title=title, + current=item.get('current', 0), + maximum=item.get('maximum', 100), + description=item.get('description', ''), + color=item.get('color'), + auto_close=item.get('auto_close', False) + ) + ) + else: + # Info panel + remaining_duration = None + if item.get('duration'): + remaining_duration = item['duration'] - (now - item['added_at']) + if remaining_duration <= 0: + continue + + self._persistent_items[title] = item + self._send_command_sync( + self._client.add_item( + group_name=self._persistent_group, + title=title, + description=item.get('description', ''), + duration=remaining_duration + ) + ) + + # ─────────────────────────────── Event Hooks ─────────────────────────────── # + + async def on_add_user_message(self, message: str) -> None: + """Handle user message - display on HUD.""" + if not self._get_prop("show_chat_messages", True): + return + + self.current_display_text = message + user_color = str(self._get_prop("user_color", "#4cd964")) + await self._show_message("USER", message, user_color) + + accent_color = str(self._get_prop("accent_color", "#00aaff")) + await self._show_loader(True, accent_color) + + async def on_add_assistant_message(self, message: str, tool_calls: list) -> None: + """Handle assistant message - display on HUD with tool info.""" + if not self._get_prop("show_chat_messages", True): + return + + accent_color = str(self._get_prop("accent_color", "#00aaff")) + display_tool_names = bool(self._get_prop("display_tool_names", False)) + is_processing = bool(tool_calls) + + await self._show_loader(is_processing, accent_color) + + # Build tool info + tools_data = [] + if tool_calls: + await printr.print_async( + f"[HUD] Processing {len(tool_calls)} tool call(s)", + color=LogType.INFO, + server_only=True + ) + for tc in tool_calls: + tool_name = tc.function.name + source = "System" + source_type = "system" + icon_path = None + + # Check if skill + if self.wingman.tool_skills and tool_name in self.wingman.tool_skills: + skill = self.wingman.tool_skills[tool_name] + source = skill.name + source_type = "skill" + try: + skill_file = inspect.getfile(skill.__class__) + skill_dir = os.path.dirname(skill_file) + logo_path = os.path.join(skill_dir, "logo.png") + if os.path.exists(logo_path): + icon_path = logo_path + except Exception: + pass + + # Check if MCP tool + elif (self.wingman.mcp_registry and + hasattr(self.wingman.mcp_registry, '_tool_to_server')): + server_name = self.wingman.mcp_registry._tool_to_server.get(tool_name) + if server_name: + if (hasattr(self.wingman.mcp_registry, '_manifests') and + server_name in self.wingman.mcp_registry._manifests): + source = self.wingman.mcp_registry._manifests[server_name].display_name + else: + source = server_name + source_type = "mcp" + + # Use tool name if configured + if display_tool_names: + source = tool_name + + await printr.print_async( + f"[HUD] Tool: {tool_name} -> source='{source}', type={source_type}", + color=LogType.INFO, + server_only=True + ) + + tools_data.append({ + 'name': tool_name, + 'source': source, + 'type': source_type, + 'icon': icon_path + }) + + if message: + self.current_display_text = message + self.expecting_audio = True + self.audio_expect_start_time = time.time() + await self._show_message( + self.wingman.name, + message, + accent_color, + tools=tools_data + ) + elif tool_calls and tools_data: + await self._show_message(self.wingman.name, "", accent_color, tools=tools_data) + else: + await self._hide_message() + + # ─────────────────────────────── Tool Methods ─────────────────────────────── # + + @tool() + def hud_add_info( + self, + title: str, + description_markdown: str, + duration: Optional[float] = None + ) -> str: + """ + Add or update a persistent information panel on the HUD overlay. + Use Markdown formatting for better readability. + + :param title: Unique identifier and display title for this info panel. + :param description_markdown: Content to display (Markdown supported). + :param duration: Auto-remove after this many seconds. If not set, stays until removed. + """ + valid_duration = duration if duration and duration > 0 else None + + self._persistent_items[title] = { + 'description': description_markdown, + 'duration': valid_duration, + 'added_at': time.time(), + 'expiry': time.time() + valid_duration if valid_duration else None + } + + if self._client: + self._send_command_sync( + self._client.add_item( + group_name=self._persistent_group, + title=title, + description=description_markdown, + duration=valid_duration + ) + ) + + self._save_persistent_items() + return f"Added/Updated info panel: {title}" + + @tool() + def hud_remove_info(self, title: str) -> str: + """ + Remove a persistent information panel from the HUD. + + :param title: The title of the info panel to remove. + """ + self._persistent_items.pop(title, None) + + if self._client: + self._send_command_sync( + self._client.remove_item(group_name=self._persistent_group, title=title) + ) + + self._save_persistent_items() + return f"Removed info panel: {title}" + + @tool() + def hud_list_info(self) -> str: + """ + List all currently visible information panels on the HUD. + Returns JSON with all active panels. + """ + now = time.time() + active_items = [] + expired_keys = [] + + for title, info in self._persistent_items.items(): + if info.get('expiry') and now > info['expiry']: + expired_keys.append(title) + continue + + active_items.append({ + 'title': title, + 'description': info.get('description', ''), + 'expires_in_seconds': ( + int(info['expiry'] - now) if info.get('expiry') else None + ) + }) + + for k in expired_keys: + del self._persistent_items[k] + + self._save_persistent_items() + + if not active_items: + return "No information panels currently displayed." + + return json.dumps(active_items, indent=2) + + @tool() + def hud_clear_all(self) -> str: + """ + Remove all information panels and progress bars from the HUD. + """ + # Create a copy of keys to iterate because we will modify the dict + items_to_remove = list(self._persistent_items.keys()) + cleared_count = len(items_to_remove) + + for title in items_to_remove: + self._persistent_items.pop(title, None) + if self._client: + self._send_command_sync( + self._client.remove_item(group_name=self._persistent_group, title=title) + ) + + self._save_persistent_items() + + return f"Cleared {cleared_count} item(s) from HUD." + + @tool() + def hud_show_progress( + self, + title: str, + current: float, + maximum: float, + description_markdown: Optional[str] = None, + auto_close: bool = False, + color: Optional[str] = None + ) -> str: + """ + Show or update a progress bar on the HUD. + + :param title: Unique identifier and title for this progress bar. + :param current: Current progress value. + :param maximum: Maximum value (100% when current equals maximum). + :param description_markdown: Optional description below the progress bar. + :param auto_close: If True, removes the bar when reaching 100%. + :param color: Optional color for the progress bar (hex color like #00ff00). + """ + if maximum <= 0: + maximum = 100 + + percentage = min(100.0, max(0.0, (current / maximum) * 100)) + + self._persistent_items[title] = { + 'description': description_markdown or '', + 'added_at': time.time(), + 'is_progress': True, + 'current': current, + 'maximum': maximum, + 'auto_close': auto_close, + 'color': color + } + + if self._client: + self._send_command_sync( + self._client.show_progress( + group_name=self._persistent_group, + title=title, + current=current, + maximum=maximum, + description=description_markdown or '', + color=color, + auto_close=auto_close + ) + ) + + self._save_persistent_items() + return f"Progress '{title}': {percentage:.1f}%" + + @tool() + def hud_show_timer( + self, + title: str, + duration_seconds: float, + description_markdown: Optional[str] = None, + auto_close: bool = True, + color: Optional[str] = None + ) -> str: + """ + Show a countdown timer that fills a progress bar over the specified duration. + + :param title: Unique identifier and title for this timer. + :param duration_seconds: Time in seconds until the progress bar reaches 100%. + :param description_markdown: Optional description below the timer. + :param auto_close: If True (default), removes the timer after completion. + :param color: Optional color for the timer bar (hex color like #00ff00). + """ + if duration_seconds <= 0: + duration_seconds = 1 + + now = time.time() + + self._persistent_items[title] = { + 'description': description_markdown or '', + 'added_at': now, + 'is_progress': True, + 'is_timer': True, + 'timer_start': now, + 'timer_duration': duration_seconds, + 'auto_close': auto_close, + 'color': color + } + + if self._client: + self._send_command_sync( + self._client.show_timer( + group_name=self._persistent_group, + title=title, + duration=duration_seconds, + description=description_markdown or '', + color=color, + auto_close=auto_close + ) + ) + + self._save_persistent_items() + return f"Timer '{title}' started: {duration_seconds:.1f}s" + + @tool() + def hud_update_progress( + self, + title: str, + current: float, + maximum: Optional[float] = None, + description_markdown: Optional[str] = None + ) -> str: + """ + Update an existing progress bar's values. + + :param title: The title of the progress bar to update. + :param current: The new current value. + :param maximum: Optional new maximum value. + :param description_markdown: Optional new description. + """ + if title not in self._persistent_items: + return f"Progress '{title}' not found. Use hud_show_progress first." + + item = self._persistent_items[title] + if not item.get('is_progress'): + return f"'{title}' is not a progress bar." + + if item.get('is_timer'): + return f"'{title}' is a timer. Timers cannot be updated manually." + + if maximum is not None and maximum > 0: + item['maximum'] = maximum + else: + maximum = item.get('maximum', 100) + + item['current'] = current + if description_markdown is not None: + item['description'] = description_markdown + + percentage = min(100.0, max(0.0, (current / maximum) * 100)) + + if self._client: + self._send_command_sync( + self._client.show_progress( + group_name=self._persistent_group, + title=title, + current=current, + maximum=maximum, + description=description_markdown or item.get('description', ''), + color=item.get('color') + ) + ) + + self._save_persistent_items() + return f"Updated progress '{title}': {percentage:.1f}%" + + @tool() + def hud_update_info( + self, + title: str, + description_markdown: str, + duration: Optional[float] = None + ) -> str: + """ + Update an existing information panel's content. + + :param title: The title of the info panel to update. + :param description_markdown: The new content (Markdown supported). + :param duration: Optional new auto-remove timer in seconds. + """ + if title not in self._persistent_items: + return f"Info '{title}' not found. Use hud_add_info first." + + item = self._persistent_items[title] + item['description'] = description_markdown + + send_duration = None + if duration is not None: + if duration > 0: + item['duration'] = duration + item['expiry'] = time.time() + duration + send_duration = duration + else: + item['duration'] = None + item['expiry'] = None + + if self._client: + self._send_command_sync( + self._client.update_item( + group_name=self._persistent_group, + title=title, + description=description_markdown, + duration=send_duration + ) + ) + + self._save_persistent_items() + return f"Updated info panel: {title}" diff --git a/templates/configs/settings.yaml b/templates/configs/settings.yaml index da864f833..6cfdb4424 100644 --- a/templates/configs/settings.yaml +++ b/templates/configs/settings.yaml @@ -43,4 +43,9 @@ xvasynth: process_device: cpu pocket_tts: enable: true - custom_model_path: "" \ No newline at end of file + custom_model_path: "" +hud_server: + enabled: true + host: "127.0.0.1" + port: 7862 + framerate: 60 diff --git a/wingman_core.py b/wingman_core.py index f3b545c15..06b06d4cf 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -58,6 +58,7 @@ from services.system_manager import SystemManager from services.tower import Tower from services.websocket_user import WebSocketUser +from hud_server.server import HudServer class WingmanCore(WebSocketUser): @@ -349,6 +350,9 @@ def __init__( self.tower: Tower = None + # HUD Server + self._hud_server: Optional[HudServer] = None + self.active_recording = {"key": "", "wingman": None} self.is_started = False @@ -422,6 +426,44 @@ async def startup(self): if self.settings_service.settings.voice_activation.enabled: await self.set_voice_activation(is_enabled=True) + # Start HUD Server if enabled + await self._start_hud_server_if_enabled() + + async def _start_hud_server_if_enabled(self): + """Start the HUD server if enabled in settings.""" + hud_settings = getattr(self.settings_service.settings, 'hud_server', None) + if not hud_settings or not hud_settings.enabled: + return + + try: + self._hud_server = HudServer() + if not self._hud_server.start( + host=hud_settings.host, + port=hud_settings.port, + framerate=getattr(hud_settings, 'framerate', 60), + layout_margin=getattr(hud_settings, 'layout_margin', 20), + layout_spacing=getattr(hud_settings, 'layout_spacing', 15), + ): + self.printr.print( + f"HUD Server failed to start on port {hud_settings.port}", + color=LogType.ERROR, + server_only=True, + ) + self._hud_server = None + except Exception as e: + self.printr.print( + f"HUD Server error: {e}", + color=LogType.ERROR, + server_only=True, + ) + self._hud_server = None + + async def _stop_hud_server(self): + """Stop the HUD server if running.""" + if self._hud_server and self._hud_server.is_running: + await self._hud_server.stop() + self._hud_server = None + async def set_core_state(self, state: CoreState) -> None: """Update the core state and broadcast to all connected clients. @@ -1592,6 +1634,9 @@ async def get_elevenlabs_subscription_data(self): async def shutdown(self): await self.set_core_state(CoreState.SHUTTING_DOWN) + # Stop HUD Server + await self._stop_hud_server() + if self.settings_service.settings.xvasynth.enable: await self.stop_xvasynth() if self.settings_service.settings.pocket_tts.enable: From 7643f81c6145cfac2b554374c88052be85c1515e Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 5 Feb 2026 11:35:05 +0100 Subject: [PATCH 02/27] removing unneeded code --- hud_server/http_client.py | 118 ++++---------------- hud_server/hud_types.py | 228 -------------------------------------- hud_server/server.py | 31 ------ 3 files changed, 24 insertions(+), 353 deletions(-) delete mode 100644 hud_server/hud_types.py diff --git a/hud_server/http_client.py b/hud_server/http_client.py index dfb9bd8d7..302c0e87f 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -18,24 +18,18 @@ import asyncio import threading import time +import httpx from typing import Optional, Any from urllib.parse import quote +from services.printr import Printr -try: - import httpx - HTTPX_AVAILABLE = True -except ImportError: - HTTPX_AVAILABLE = False - httpx = None +printr = Printr() class HudHttpClient: """Async HTTP client for the HUD Server.""" def __init__(self, base_url: str = "http://127.0.0.1:7862"): - if not HTTPX_AVAILABLE: - raise ImportError("httpx library not installed. Run: pip install httpx") - self.base_url = base_url.rstrip("/") self._client: Optional[httpx.AsyncClient] = None self._connected = False @@ -46,7 +40,6 @@ def connected(self) -> bool: async def connect(self, timeout: float = 5.0) -> bool: """Connect to the HUD server.""" - import sys try: # Close existing client if any - ignore all errors since the loop might be closed if self._client: @@ -70,7 +63,7 @@ async def connect(self, timeout: float = 5.0) -> bool: self._connected = True return True return False - except Exception as e: + except Exception: self._connected = False return False @@ -95,29 +88,30 @@ async def _request( json: Optional[dict] = None ) -> Optional[dict]: """Make an HTTP request to the server.""" - import sys # Reconnect if not connected (either no client or marked as disconnected) if not self._client or not self._connected: if not await self.connect(): return None - try: + async def _execute_request(): + """Execute the HTTP request with the given method.""" if method == "GET": - response = await self._client.get(path) + return await self._client.get(path) elif method == "POST": - response = await self._client.post(path, json=json) + return await self._client.post(path, json=json) elif method == "PUT": - response = await self._client.put(path, json=json) + return await self._client.put(path, json=json) elif method == "DELETE": - response = await self._client.delete(path) + return await self._client.delete(path) else: return None - if response.status_code >= 200 and response.status_code < 300: + try: + response = await _execute_request() + if response and 200 <= response.status_code < 300: return response.json() - else: - return None + return None except RuntimeError as e: # Handle "Event loop is closed" error by reconnecting if "loop" in str(e).lower() or "closed" in str(e).lower(): @@ -126,25 +120,21 @@ async def _request( # Try to reconnect and retry once if await self.connect(): try: - if method == "GET": - response = await self._client.get(path) - elif method == "POST": - response = await self._client.post(path, json=json) - elif method == "PUT": - response = await self._client.put(path, json=json) - elif method == "DELETE": - response = await self._client.delete(path) - else: - return None - - if response.status_code >= 200 and response.status_code < 300: + response = await _execute_request() + if response and 200 <= response.status_code < 300: return response.json() except Exception as retry_e: - sys.stderr.write(f"[HUD HTTP] _request: retry failed: {retry_e}\n") + printr.print( + f"[HUD HTTP] _request: retry failed: {retry_e}", + server_only=True + ) self._connected = False return None except Exception as e: - sys.stderr.write(f"[HUD HTTP] _request: {method} {path} exception: {e}\n") + printr.print( + f"[HUD HTTP] _request: {method} {path} exception: {e}", + server_only=True + ) self._connected = False return None @@ -462,46 +452,6 @@ async def hide_chat_window(self, name: str) -> Optional[dict]: encoded_name = quote(name, safe='') return await self._request("POST", f"/chat/hide/{encoded_name}") - # ─────────────────────────────── Legacy ─────────────────────────────── # - - async def legacy_draw( - self, - title: str, - message: str, - color: Optional[str] = None, - tools: Optional[list] = None, - props: Optional[dict] = None, - group: str = "default", - duration: Optional[float] = None - ) -> Optional[dict]: - """Legacy draw command for backwards compatibility.""" - data = { - "group": group, - "title": title, - "message": message, - "color": color, - "tools": tools, - "props": props, - "duration": duration - } - return await self._request("POST", "/legacy/draw", data) - - async def legacy_hide(self, group: str = "default") -> Optional[dict]: - """Legacy hide command for backwards compatibility.""" - return await self._request("POST", "/legacy/hide", {"group": group}) - - async def legacy_loading( - self, - state: bool, - color: Optional[str] = None, - group: str = "default" - ) -> Optional[dict]: - """Legacy loading command for backwards compatibility.""" - return await self._request("POST", "/legacy/loading", { - "group": group, - "state": state, - "color": color - }) class HudHttpClientSync: @@ -721,24 +671,4 @@ def show_chat_window(self, name: str): def hide_chat_window(self, name: str): return self._run_coro(self._client.hide_chat_window(name)) if self._client else None - # Legacy methods - def legacy_draw( - self, - title: str, - message: str, - color: Optional[str] = None, - tools: Optional[list] = None, - props: Optional[dict] = None, - group: str = "default", - duration: Optional[float] = None - ): - return self._run_coro(self._client.legacy_draw( - title, message, color, tools, props, group, duration - )) if self._client else None - - def legacy_hide(self, group: str = "default"): - return self._run_coro(self._client.legacy_hide(group)) if self._client else None - - def legacy_loading(self, state: bool, color: Optional[str] = None, group: str = "default"): - return self._run_coro(self._client.legacy_loading(state, color, group)) if self._client else None diff --git a/hud_server/hud_types.py b/hud_server/hud_types.py deleted file mode 100644 index e74068f5c..000000000 --- a/hud_server/hud_types.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -HeadsUp HUD - Generalized HUD overlay system with named groups. - -Each HUD group has its own: -- Position (x, y) -- Size (width, max_height) -- Visual properties (colors, opacity, fonts) -- Behavior (typewriter effect, loader, auto-fade) - -Props can be: -- Set when creating a group -- Updated at any time via update_group() -- Overridden per-message/item - -This allows multiple independent HUD areas on screen with full flexibility. -""" - -from dataclasses import dataclass, field -from typing import Dict, Any, Optional, List - - -@dataclass -class HUDGroupProps: - """ - Properties for a HUD group. - - All properties are optional when updating - only provided values will be changed. - When creating a new group, defaults will be used for any unspecified properties. - """ - # Position & Size - x: int = 20 - y: int = 20 - width: int = 400 - max_height: int = 600 - - # Colors - bg_color: str = "#1e212b" - text_color: str = "#f0f0f0" - accent_color: str = "#00aaff" - title_color: Optional[str] = None # If None, uses accent_color - - # Visual - opacity: float = 0.85 - border_radius: int = 12 - font_size: int = 16 - font_family: str = "Segoe UI" - content_padding: int = 16 - - # Behavior - typewriter_effect: bool = True - typewriter_speed: int = 200 # chars per second - show_loader: bool = True - auto_fade: bool = True - fade_delay: float = 8.0 # seconds before fade starts - fade_duration: float = 0.5 # fade animation duration - - # Rendering - z_order: int = 0 # Higher = rendered on top - - # Layout Management - layout_mode: str = "auto" # 'auto', 'manual', or 'hybrid' - anchor: str = "top_left" # 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center' - priority: int = 10 # Stacking priority (higher = closer to anchor) - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary, resolving defaults.""" - return { - 'x': self.x, - 'y': self.y, - 'width': self.width, - 'max_height': self.max_height, - 'bg_color': self.bg_color, - 'text_color': self.text_color, - 'accent_color': self.accent_color, - 'title_color': self.title_color or self.accent_color, - 'opacity': self.opacity, - 'border_radius': self.border_radius, - 'font_size': self.font_size, - 'font_family': self.font_family, - 'content_padding': self.content_padding, - 'typewriter_effect': self.typewriter_effect, - 'typewriter_speed': self.typewriter_speed, - 'show_loader': self.show_loader, - 'auto_fade': self.auto_fade, - 'fade_delay': self.fade_delay, - 'fade_duration': self.fade_duration, - 'z_order': self.z_order, - 'layout_mode': self.layout_mode, - 'anchor': self.anchor, - 'priority': self.priority, - } - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "HUDGroupProps": - """Create from dictionary, ignoring unknown keys.""" - import dataclasses - valid_fields = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in valid_fields}) - - def merge_with(self, overrides: Dict[str, Any]) -> "HUDGroupProps": - """Create a new HUDGroupProps with overrides applied.""" - base = self.to_dict() - base.update({k: v for k, v in overrides.items() if v is not None}) - return HUDGroupProps.from_dict(base) - - -@dataclass -class HUDMessage: - """A message to display in a HUD group.""" - title: str = "" - content: str = "" - color: Optional[str] = None # Override title/accent color for this message - tools: List[Dict[str, Any]] = field(default_factory=list) - id: Optional[str] = None # For tracking/updating specific messages - - # Per-message prop overrides (optional) - props: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - result = { - 'title': self.title, - 'content': self.content, - 'color': self.color, - 'tools': self.tools, - 'id': self.id, - } - if self.props: - result['props'] = self.props - return result - - -@dataclass -class HUDItem: - """A persistent item in a HUD group.""" - title: str - description: str = "" - color: Optional[str] = None - duration: Optional[float] = None # Auto-remove after duration (seconds) - - # Progress bar support - is_progress: bool = False - progress_current: float = 0 - progress_maximum: float = 100 - progress_color: Optional[str] = None - - # Timer support - is_timer: bool = False - timer_duration: float = 0 - auto_close: bool = True - - # Per-item prop overrides (optional) - props: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - result = { - 'title': self.title, - 'description': self.description, - 'color': self.color, - 'duration': self.duration, - 'is_progress': self.is_progress, - 'progress_current': self.progress_current, - 'progress_maximum': self.progress_maximum, - 'progress_color': self.progress_color, - 'is_timer': self.is_timer, - 'timer_duration': self.timer_duration, - 'auto_close': self.auto_close, - } - if self.props: - result['props'] = self.props - return result - - -@dataclass -class ChatMessage: - """A single chat message for the chat window.""" - sender: str - text: str - color: Optional[str] = None # Override sender color - timestamp: Optional[float] = None # When the message was added - - def to_dict(self) -> Dict[str, Any]: - return { - 'sender': self.sender, - 'text': self.text, - 'color': self.color, - 'timestamp': self.timestamp, - } - - -@dataclass -class ChatWindowProps(HUDGroupProps): - """ - Properties for a Chat Window HUD group. - - Extends HUDGroupProps with chat-specific settings. - """ - # Chat-specific settings - auto_hide: bool = False # Hide window after auto_hide_delay seconds - auto_hide_delay: float = 10.0 # Seconds after last message before hiding - max_messages: int = 50 # Maximum messages to keep in history - sender_colors: Optional[Dict[str, str]] = None # Map sender names to colors - show_timestamps: bool = False # Show timestamp next to messages - message_spacing: int = 8 # Vertical spacing between messages - fade_old_messages: bool = True # Fade out messages that overflow at top - - def to_dict(self) -> Dict[str, Any]: - """Convert to dictionary, including chat-specific props.""" - base = super().to_dict() - base.update({ - 'auto_hide': self.auto_hide, - 'auto_hide_delay': self.auto_hide_delay, - 'max_messages': self.max_messages, - 'sender_colors': self.sender_colors or {}, - 'show_timestamps': self.show_timestamps, - 'message_spacing': self.message_spacing, - 'fade_old_messages': self.fade_old_messages, - 'is_chat_window': True, # Flag to identify chat windows - }) - return base - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ChatWindowProps": - """Create from dictionary, ignoring unknown keys.""" - import dataclasses - valid_fields = {f.name for f in dataclasses.fields(cls)} - return cls(**{k: v for k, v in data.items() if k in valid_fields}) - - diff --git a/hud_server/server.py b/hud_server/server.py index 246b1a149..46e3869fb 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -441,37 +441,6 @@ async def hide_chat_window(name: str): raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") return OperationResponse(status="ok") - # ─────────────────────────────── Legacy Compatibility ─────────────────────────────── # - # These endpoints provide compatibility with the old WebSocket-based commands - - @app.post("/legacy/draw", response_model=OperationResponse, tags=["legacy"]) - async def legacy_draw(cmd: dict[str, Any]): - """Legacy draw command (WebSocket compatibility).""" - group = cmd.get("group", "default") - self.manager.show_message( - group_name=group, - title=cmd.get("title", ""), - content=cmd.get("message", ""), - color=cmd.get("color"), - tools=cmd.get("tools"), - props=cmd.get("props"), - duration=cmd.get("duration") - ) - return OperationResponse(status="ok") - - @app.post("/legacy/hide", response_model=OperationResponse, tags=["legacy"]) - async def legacy_hide(cmd: dict[str, Any]): - """Legacy hide command (WebSocket compatibility).""" - group = cmd.get("group", "default") - self.manager.hide_message(group) - return OperationResponse(status="ok") - - @app.post("/legacy/loading", response_model=OperationResponse, tags=["legacy"]) - async def legacy_loading(cmd: dict[str, Any]): - """Legacy loading command (WebSocket compatibility).""" - group = cmd.get("group", "default") - self.manager.set_loader(group, cmd.get("state", True), cmd.get("color")) - return OperationResponse(status="ok") # ─────────────────────────────── Overlay Support ─────────────────────────────── # From 6e8954a89646d592903175fe648081cf8bafceaa Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 5 Feb 2026 12:18:59 +0100 Subject: [PATCH 03/27] Cleanup HUD logging and standardize error reporting - Remove excessive debug logging from `hud_server`, `hud_manager`, and `skills/hud` to reduce console noise. - Replace `sys.stderr.write` with `_report_exception` in `overlay.py` for consistent error handling. - Reduce log noise for 404 errors in `hud_server` exception handler. - Clean up exception handling in `http_client` to suppress unnecessary retry logs. --- hud_server/http_client.py | 15 ++---- hud_server/hud_manager.py | 31 ------------ hud_server/overlay/overlay.py | 10 ++-- hud_server/server.py | 71 ++++----------------------- skills/hud/main.py | 92 +---------------------------------- 5 files changed, 18 insertions(+), 201 deletions(-) diff --git a/hud_server/http_client.py b/hud_server/http_client.py index 302c0e87f..12899ee02 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -123,18 +123,11 @@ async def _execute_request(): response = await _execute_request() if response and 200 <= response.status_code < 300: return response.json() - except Exception as retry_e: - printr.print( - f"[HUD HTTP] _request: retry failed: {retry_e}", - server_only=True - ) + except Exception: + pass self._connected = False return None - except Exception as e: - printr.print( - f"[HUD HTTP] _request: {method} {path} exception: {e}", - server_only=True - ) + except Exception: self._connected = False return None @@ -670,5 +663,3 @@ def show_chat_window(self, name: str): def hide_chat_window(self, name: str): return self._run_coro(self._client.hide_chat_window(name)) if self._client else None - - diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py index cc3e82940..3d13699f2 100644 --- a/hud_server/hud_manager.py +++ b/hud_server/hud_manager.py @@ -198,12 +198,6 @@ def unregister_command_callback(self, callback): def _notify_callbacks(self, command: dict[str, Any]): """Notify all registered callbacks of a command.""" - cmd_type = command.get('type', 'unknown') - printr.print( - f"[HUD Manager] _notify_callbacks: command type='{cmd_type}', {len(self._command_callbacks)} callback(s)", - color=LogType.INFO, - server_only=True - ) for i, callback in enumerate(self._command_callbacks): try: callback(command) @@ -236,31 +230,10 @@ def create_group(self, group_name: str, props: Optional[dict[str, Any]] = None) def update_group(self, group_name: str, props: dict[str, Any]) -> bool: """Update properties of an existing group.""" - printr.print( - f"[HUD Manager] update_group called: group='{group_name}', props keys={list(props.keys())}", - color=LogType.INFO, - server_only=True - ) - if 'width' in props: - printr.print( - f"[HUD Manager] update_group: width={props['width']}", - color=LogType.INFO, - server_only=True - ) with self._lock: if group_name not in self._groups: - printr.print( - f"[HUD Manager] update_group: group '{group_name}' NOT FOUND in groups: {list(self._groups.keys())}", - color=LogType.WARNING, - server_only=True - ) return False - printr.print( - f"[HUD Manager] update_group: group '{group_name}' found, updating props", - color=LogType.INFO, - server_only=True - ) self._groups[group_name].props.update(props) self._notify_callbacks({ @@ -324,12 +297,9 @@ def show_message( duration: Optional[float] = None ) -> bool: """Show a message in a group.""" - import sys with self._lock: if group_name not in self._groups: self.create_group(group_name) - else: - sys.stderr.write(f"[HUD Manager] show_message: using existing group '{group_name}'\n") self._groups[group_name].current_message = HudMessage( title=title, @@ -730,4 +700,3 @@ def clear_all(self): self._notify_callbacks({ "type": "clear_all" }) - diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index f312cb638..d818e4421 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -469,7 +469,7 @@ def _update_all_windows(self): # Note: Chat windows use the legacy system for now except Exception as e: - sys.stderr.write(f"[HUD] Window {name} update error: {e}\n") + self._report_exception(f"update_window_{name}", e) # Second pass: check collisions and update persistent windows for group, pers_win in persistent_windows.items(): @@ -479,7 +479,7 @@ def _update_all_windows(self): self._update_persistent_fade(pers_win, collision) self._blit_window(self._get_window_name(self.WINDOW_TYPE_PERSISTENT, group), pers_win) except Exception as e: - sys.stderr.write(f"[HUD] Persistent window {group} collision error: {e}\n") + self._report_exception(f"persistent_collision_{group}", e) # Third pass: Update ALL window positions from layout manager # This ensures windows reposition when others hide/show/resize @@ -1478,7 +1478,7 @@ def _draw_main_frame(self): props_hash ) - # Skip render if state hasn't changed + # Skip render if state unchanged if self.last_render_state == current_state and self.canvas: return @@ -2101,7 +2101,7 @@ def run(self): self._update_chat_windows() self._draw_chat_windows() except Exception as e: - sys.stderr.write(f"Draw chat windows error: {e}\n") + self._report_exception("draw_chat_windows", e) now = time.time() if now - last_z > 0.1: @@ -3184,7 +3184,7 @@ def _draw_chat_windows(self): try: self._draw_chat_frame(chat_name, chat) except Exception as e: - sys.stderr.write(f"Draw chat window error: {e}\n") + self._report_exception("draw_chat_windows", e) def _draw_chat_frame(self, chat_name: str, chat: Dict): """Draw a single chat window frame with full markdown support.""" diff --git a/hud_server/server.py b/hud_server/server.py index 46e3869fb..ada8d6937 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -139,13 +139,15 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): """Log HTTP exceptions (404, etc.).""" - path = request.url.path - method = request.method - printr.print( - f"[HUD Server] {exc.status_code} on {method} {path}: {exc.detail}", - color=LogType.WARNING, - server_only=True - ) + # Reduce noise for 404s + if exc.status_code != 404: + path = request.url.path + method = request.method + printr.print( + f"[HUD Server] {exc.status_code} on {method} {path}: {exc.detail}", + color=LogType.WARNING, + server_only=True + ) return JSONResponse( status_code=exc.status_code, content={"status": "error", "message": exc.detail} @@ -208,23 +210,7 @@ async def update_group(group_name: str, request: UpdateGroupRequest): @app.patch("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) async def patch_group(group_name: str, request: UpdateGroupRequest): """Update properties of an existing group (PATCH).""" - printr.print( - f"[HUD Server] PATCH /groups/{group_name}: props keys={list(request.props.keys()) if request.props else []}", - color=LogType.INFO, - server_only=True - ) - if request.props and 'width' in request.props: - printr.print( - f"[HUD Server] PATCH /groups/{group_name}: width={request.props['width']}", - color=LogType.INFO, - server_only=True - ) result = self.manager.update_group(group_name, request.props) - printr.print( - f"[HUD Server] PATCH /groups/{group_name}: manager.update_group returned {result}", - color=LogType.INFO, - server_only=True - ) if not result: raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok", message=f"Group '{group_name}' updated") @@ -262,11 +248,6 @@ async def restore_state(request: StateRestoreRequest): @app.post("/message", response_model=OperationResponse, tags=["messages"]) async def show_message(request: MessageRequest): """Show a message in a HUD group.""" - printr.print( - f"[HUD Server] show_message called for group '{request.group_name}'", - color=LogType.INFO, - server_only=True - ) self.manager.show_message( group_name=request.group_name, title=request.title, @@ -289,13 +270,6 @@ async def append_message(request: AppendMessageRequest): async def hide_message(group_name: str): """Hide the current message in a group.""" if not self.manager.hide_message(group_name): - available_groups = self.manager.get_groups() - printr.print( - f"[HUD Server] hide_message failed: group '{group_name}' not found. " - f"Available groups: {available_groups}", - color=LogType.WARNING, - server_only=True - ) raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok") @@ -495,26 +469,6 @@ def _stop_overlay(self): def _send_to_overlay(self, command: dict[str, Any]): """Send a command to the overlay renderer.""" - cmd_type = command.get('type', 'unknown') - group = command.get('group', 'unknown') - printr.print( - f"[HUD Server] _send_to_overlay: type='{cmd_type}', group='{group}'", - color=LogType.INFO, - server_only=True - ) - if cmd_type == 'update_group': - props = command.get('props', {}) - printr.print( - f"[HUD Server] _send_to_overlay: update_group props keys={list(props.keys())}", - color=LogType.INFO, - server_only=True - ) - if 'width' in props: - printr.print( - f"[HUD Server] _send_to_overlay: width={props['width']}", - color=LogType.INFO, - server_only=True - ) if self._command_queue: try: self._command_queue.put(command) @@ -524,12 +478,6 @@ def _send_to_overlay(self, command: dict[str, Any]): color=LogType.ERROR, server_only=True ) - else: - printr.print( - f"[HUD Server] _send_to_overlay: NO command queue!", - color=LogType.WARNING, - server_only=True - ) # ─────────────────────────────── Server Lifecycle ─────────────────────────────── # @@ -658,4 +606,3 @@ def base_url(self) -> str: port=args.port, reload=False ) - diff --git a/skills/hud/main.py b/skills/hud/main.py index 06bf1d70f..1d3d5483a 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -396,25 +396,9 @@ async def update_config(self, new_config) -> None: await super().update_config(new_config) if old_config.custom_properties == new_config.custom_properties: - await printr.print_async( - "[HUD] update_config: custom_properties unchanged, skipping", - color=LogType.INFO, - server_only=True - ) return - await printr.print_async( - "[HUD] update_config: custom_properties CHANGED, recreating groups...", - color=LogType.INFO, - server_only=True - ) - if not await self._ensure_connected(): - await printr.print_async( - "[HUD] update_config: failed to connect, aborting", - color=LogType.WARNING, - server_only=True - ) return # Get new props @@ -422,31 +406,16 @@ async def update_config(self, new_config) -> None: pers_props = self._get_persistent_props() # Delete and recreate message group - await printr.print_async( - f"[HUD] update_config: recreating messages group '{self._messages_group}'", - color=LogType.INFO, - server_only=True - ) await self._client.delete_group(self._messages_group) await self._client.create_group(self._messages_group, props=msg_props) # Delete and recreate persistent group, then restore items - await printr.print_async( - f"[HUD] update_config: recreating persistent group '{self._persistent_group}'", - color=LogType.INFO, - server_only=True - ) await self._client.delete_group(self._persistent_group) await self._client.create_group(self._persistent_group, props=pers_props) # Re-add all persistent items with the new group settings if self._persistent_items: await self._restore_persistent_items() - await printr.print_async( - f"[HUD] update_config: restoring {len(self._persistent_items)} persistent item(s)", - color=LogType.INFO, - server_only=True - ) async def _ensure_connected(self) -> bool: """Ensure the HUD client is connected. Create client and connect if needed.""" @@ -683,20 +652,10 @@ async def _audio_monitor_loop(self): # Audio just started - reset expecting flag if is_playing and not was_playing: - await printr.print_async( - f"[HUD] Audio started playing, resetting expecting_audio flag", - color=LogType.INFO, - server_only=True - ) self.expecting_audio = False # Hide message when audio stops if was_playing and not is_playing: - await printr.print_async( - f"[HUD] Audio stopped playing, waiting 0.5s before hiding", - color=LogType.INFO, - server_only=True - ) await asyncio.sleep(0.5) # Brief delay for readability # Re-check if audio started during the delay @@ -708,18 +667,7 @@ async def _audio_monitor_loop(self): pass if still_not_playing: - await printr.print_async( - f"[HUD] Audio still not playing, calling hide_message", - color=LogType.INFO, - server_only=True - ) await self._hide_message() - else: - await printr.print_async( - f"[HUD] Audio started again during delay, NOT hiding", - color=LogType.INFO, - server_only=True - ) self.expecting_audio = False was_playing = is_playing @@ -729,11 +677,6 @@ async def _audio_monitor_loop(self): max_display_time = float(self._get_prop("max_display_time", 5)) elapsed = time.time() - self.audio_expect_start_time if elapsed > max_display_time: - await printr.print_async( - f"[HUD] Audio timeout ({elapsed:.1f}s > {max_display_time}s), hiding message", - color=LogType.INFO, - server_only=True - ) self.expecting_audio = False await self._hide_message() @@ -785,31 +728,9 @@ async def _show_message( async def _hide_message(self): """Hide the current message.""" if not await self._ensure_connected(): - await printr.print_async( - "[HUD] _hide_message called but no client connected", - color=LogType.WARNING, - server_only=True - ) return - await printr.print_async( - f"[HUD] Sending hide_message request to server for group '{self._messages_group}'", - color=LogType.INFO, - server_only=True - ) - result = await self._client.hide_message(group_name=self._messages_group) - if result is None: - await printr.print_async( - f"[HUD] hide_message returned None (possible error or group not found)", - color=LogType.WARNING, - server_only=True - ) - else: - await printr.print_async( - f"[HUD] hide_message successful: {result}", - color=LogType.INFO, - server_only=True - ) + await self._client.hide_message(group_name=self._messages_group) async def _show_loader(self, show: bool, color: str = None): """Show or hide the loading animation.""" @@ -960,11 +881,6 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None # Build tool info tools_data = [] if tool_calls: - await printr.print_async( - f"[HUD] Processing {len(tool_calls)} tool call(s)", - color=LogType.INFO, - server_only=True - ) for tc in tool_calls: tool_name = tc.function.name source = "System" @@ -1001,12 +917,6 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None if display_tool_names: source = tool_name - await printr.print_async( - f"[HUD] Tool: {tool_name} -> source='{source}', type={source_type}", - color=LogType.INFO, - server_only=True - ) - tools_data.append({ 'name': tool_name, 'source': source, From 194627ddd60c4ea986a89dd63cdd962ed7a92402 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 5 Feb 2026 13:48:51 +0100 Subject: [PATCH 04/27] Refactor HUD server with centralized constants and improved documentation - Create `hud_server/constants.py` to centralize configuration, timeouts, limits, and API paths. - Update `HudServer` and `HudHttpClient` to utilize centralized constants for consistent behavior. - Enhance `HudHttpClientSync` with improved thread safety, locking, and event loop management during startup. - Add Pydantic field validation, constraints, and default values in `hud_server/models.py`. - Consolidate documentation into `hud_server/README.md` and `hud_server/API.md`, removing scattered README files. - Improve logging and error handling during server and overlay lifecycle events. --- hud_server/API.md | 703 +++++++++++++++++++++++++++++++++++ hud_server/README.md | 520 ++++++++++++++++++++++++++ hud_server/constants.py | 137 +++++++ hud_server/http_client.py | 165 ++++++-- hud_server/hud_manager.py | 32 +- hud_server/layout/README.md | 230 ------------ hud_server/layout/manager.py | 7 +- hud_server/models.py | 82 ++-- hud_server/server.py | 171 ++++++--- hud_server/tests/README.md | 55 --- 10 files changed, 1703 insertions(+), 399 deletions(-) create mode 100644 hud_server/API.md create mode 100644 hud_server/README.md create mode 100644 hud_server/constants.py delete mode 100644 hud_server/layout/README.md delete mode 100644 hud_server/tests/README.md diff --git a/hud_server/API.md b/hud_server/API.md new file mode 100644 index 000000000..38ee04a94 --- /dev/null +++ b/hud_server/API.md @@ -0,0 +1,703 @@ +# HUD Server API Reference + +Complete API reference for the Wingman AI HUD Server REST API. + +**Base URL**: `http://127.0.0.1:7862` + +**Content-Type**: `application/json` + +--- + +## Table of Contents + +- [Health & Status](#health--status) +- [Groups](#groups) +- [Messages](#messages) +- [Loader](#loader) +- [Persistent Items](#persistent-items) +- [Progress & Timers](#progress--timers) +- [Chat Windows](#chat-windows) +- [State Management](#state-management) +- [Error Responses](#error-responses) + +--- + +## Health & Status + +### GET /health + +Check server health and get list of active groups. + +**Response**: `200 OK` + +```json +{ + "status": "healthy", + "groups": ["wingman1", "wingman2"], + "version": "1.0.0" +} +``` + +### GET / + +Root endpoint - same as `/health`. + +--- + +## Groups + +### POST /groups + +Create or update a HUD group. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "props": { + "x": 20, + "y": 20, + "width": 400, + "max_height": 600, + "bg_color": "#1e212b", + "text_color": "#f0f0f0", + "accent_color": "#00aaff", + "opacity": 0.85, + "border_radius": 12, + "font_size": 16, + "font_family": "Segoe UI", + "typewriter_effect": true, + "typewriter_speed": 200, + "auto_fade": true, + "fade_delay": 8.0, + "layout_mode": "auto", + "anchor": "top_left", + "priority": 10 + } +} +``` + +**Response**: `200 OK` + +```json +{ + "status": "ok", + "message": "Group 'my_wingman' created" +} +``` + +### PATCH /groups/{group_name} + +Update properties of an existing group (real-time updates). + +**URL Parameters**: +- `group_name` (string): Name of the group to update + +**Request Body**: + +```json +{ + "props": { + "opacity": 0.9, + "font_size": 18 + } +} +``` + +**Response**: `200 OK` + +```json +{ + "status": "ok", + "message": "Group 'my_wingman' updated" +} +``` + +### DELETE /groups/{group_name} + +Delete a HUD group. + +**URL Parameters**: +- `group_name` (string): Name of the group to delete + +**Response**: `200 OK` + +```json +{ + "status": "ok", + "message": "Group 'my_wingman' deleted" +} +``` + +### GET /groups + +Get list of all group names. + +**Response**: `200 OK` + +```json +{ + "groups": ["wingman1", "wingman2", "alerts"] +} +``` + +--- + +## Messages + +### POST /message + +Show a message in a HUD group. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "title": "Hello!", + "content": "This is a **Markdown** message with `code` and [links](https://example.com).", + "color": "#00ff00", + "tools": [ + { + "name": "search", + "status": "active" + } + ], + "props": { + "typewriter_effect": false + }, + "duration": 10.0 +} +``` + +**Fields**: +- `group_name` (string, required): Target group name +- `title` (string, required): Message title (1-200 chars) +- `content` (string, required): Message content with Markdown support (max 50000 chars) +- `color` (string, optional): Hex color for title/accent (#RRGGBB) +- `tools` (array, optional): Tool information for display +- `props` (object, optional): Property overrides for this message +- `duration` (number, optional): Auto-hide after N seconds (0.1-3600) + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### POST /message/append + +Append content to the current message (for streaming). + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "content": " Additional text..." +} +``` + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### POST /message/hide/{group_name} + +Hide the current message in a group. + +**URL Parameters**: +- `group_name` (string): Target group name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Loader + +### POST /loader + +Show or hide the loader animation. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "show": true, + "color": "#00aaff" +} +``` + +**Fields**: +- `group_name` (string, required): Target group name +- `show` (boolean, required): Show (true) or hide (false) +- `color` (string, optional): Hex color for loader (#RRGGBB) + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Persistent Items + +### POST /items + +Add a persistent item to a group. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "title": "Status", + "description": "System operational", + "color": "#00ff00", + "duration": 30.0 +} +``` + +**Fields**: +- `group_name` (string, required): Target group name +- `title` (string, required): Item title/identifier (unique within group) +- `description` (string, optional): Item description +- `color` (string, optional): Hex color (#RRGGBB) +- `duration` (number, optional): Auto-remove after N seconds + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### PUT /items + +Update an existing item. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "title": "Status", + "description": "Updated description", + "color": "#ffaa00" +} +``` + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### DELETE /items/{group_name}/{title} + +Remove an item from a group. + +**URL Parameters**: +- `group_name` (string): Target group name +- `title` (string): Item title to remove + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### DELETE /items/{group_name} + +Clear all items from a group. + +**URL Parameters**: +- `group_name` (string): Target group name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Progress & Timers + +### POST /progress + +Show or update a progress bar. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "title": "Loading", + "current": 50, + "maximum": 100, + "description": "Processing files...", + "color": "#00aaff", + "auto_close": false, + "props": {} +} +``` + +**Fields**: +- `group_name` (string, required): Target group name +- `title` (string, required): Progress bar title +- `current` (number, required): Current value +- `maximum` (number, optional): Maximum value (default: 100) +- `description` (string, optional): Progress description +- `color` (string, optional): Hex color for progress bar (#RRGGBB) +- `auto_close` (boolean, optional): Auto-close when complete (default: false) +- `props` (object, optional): Property overrides + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### POST /timer + +Show a countdown timer. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "title": "Cooldown", + "duration": 60.0, + "description": "Ability recharging...", + "color": "#ff9900", + "auto_close": true, + "initial_progress": 10.0, + "props": {} +} +``` + +**Fields**: +- `group_name` (string, required): Target group name +- `title` (string, required): Timer title +- `duration` (number, required): Total duration in seconds +- `description` (string, optional): Timer description +- `color` (string, optional): Hex color (#RRGGBB) +- `auto_close` (boolean, optional): Auto-close when complete (default: true) +- `initial_progress` (number, optional): Start at N seconds (for resume) +- `props` (object, optional): Property overrides + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Chat Windows + +### POST /chat/window + +Create a new chat window. + +**Request Body**: + +```json +{ + "name": "team_chat", + "x": 20, + "y": 20, + "width": 400, + "max_height": 400, + "auto_hide": false, + "auto_hide_delay": 10.0, + "max_messages": 50, + "sender_colors": { + "Alice": "#ff6b6b", + "Bob": "#4ecdc4" + }, + "fade_old_messages": true, + "props": {} +} +``` + +**Fields**: +- `name` (string, required): Unique chat window name +- `x` (integer, optional): X position (default: 20) +- `y` (integer, optional): Y position (default: 20) +- `width` (integer, optional): Width in pixels (default: 400) +- `max_height` (integer, optional): Max height (default: 400) +- `auto_hide` (boolean, optional): Auto-hide when inactive (default: false) +- `auto_hide_delay` (number, optional): Delay before auto-hide in seconds (default: 10.0) +- `max_messages` (integer, optional): Max message history (default: 50) +- `sender_colors` (object, optional): Per-sender color overrides +- `fade_old_messages` (boolean, optional): Fade older messages (default: true) +- `props` (object, optional): Additional properties + +**Response**: `200 OK` + +```json +{ + "status": "ok", + "message": "Chat window 'team_chat' created" +} +``` + +### POST /chat/message + +Send a message to a chat window. + +**Request Body**: + +```json +{ + "window_name": "team_chat", + "sender": "Alice", + "text": "Hello everyone!", + "color": "#ff6b6b" +} +``` + +**Fields**: +- `window_name` (string, required): Target chat window name +- `sender` (string, required): Sender name +- `text` (string, required): Message text +- `color` (string, optional): Sender color override (#RRGGBB) + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### DELETE /chat/messages/{window_name} + +Clear all messages from a chat window. + +**URL Parameters**: +- `window_name` (string): Target chat window name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### POST /chat/show/{name} + +Show a hidden chat window. + +**URL Parameters**: +- `name` (string): Chat window name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### POST /chat/hide/{name} + +Hide a chat window. + +**URL Parameters**: +- `name` (string): Chat window name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +### DELETE /chat/window/{name} + +Delete a chat window. + +**URL Parameters**: +- `name` (string): Chat window name + +**Response**: `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## State Management + +### GET /state/{group_name} + +Get the current state of a group for persistence. + +**URL Parameters**: +- `group_name` (string): Target group name + +**Response**: `200 OK` + +```json +{ + "group_name": "my_wingman", + "state": { + "props": { ... }, + "current_message": { ... }, + "items": { ... }, + "chat_messages": [ ... ], + "loader_visible": false, + "is_chat_window": false, + "visible": true + } +} +``` + +### POST /state/restore + +Restore a group's state from a previous snapshot. + +**Request Body**: + +```json +{ + "group_name": "my_wingman", + "state": { + "props": { ... }, + "items": { ... } + } +} +``` + +**Response**: `200 OK` + +```json +{ + "status": "ok", + "message": "State restored for 'my_wingman'" +} +``` + +--- + +## Error Responses + +All errors follow this format: + +### 404 Not Found + +```json +{ + "status": "error", + "message": "Group 'unknown' not found" +} +``` + +### 422 Validation Error + +```json +{ + "status": "error", + "message": "Validation error", + "detail": [ + { + "loc": ["body", "title"], + "msg": "field required", + "type": "value_error.missing" + } + ] +} +``` + +### 500 Internal Server Error + +```json +{ + "status": "error", + "message": "Internal server error", + "detail": "Exception details..." +} +``` + +--- + +## Color Format + +All color fields accept hex format: `#RRGGBB` + +Examples: +- `#ff0000` - Red +- `#00ff00` - Green +- `#0000ff` - Blue +- `#ffffff` - White +- `#000000` - Black +- `#1e212b` - Dark gray (default background) + +--- + +## Markdown Support + +Message content supports the following Markdown features: + +- **Bold**: `**text**` or `__text__` +- **Italic**: `*text*` or `_text_` +- **Code**: `` `inline` `` or ` ```block``` ` +- **Links**: `[text](url)` +- **Images**: `![alt](url)` +- **Headers**: `# H1`, `## H2`, `### H3`, etc. +- **Lists**: `- item` or `1. item` +- **Blockquotes**: `> quote` +- **Horizontal Rules**: `---` or `***` +- **Tables**: Standard Markdown table syntax + +--- + +## Rate Limits + +No rate limits are currently enforced, but avoid: +- Sending more than 100 requests/second per group +- Creating more than 1000 groups +- Storing more than 10MB of state per group + +--- + +## Interactive API Documentation + +When the server is running, visit `http://127.0.0.1:7862/docs` for interactive Swagger UI documentation with request/response examples and a "Try it out" feature. diff --git a/hud_server/README.md b/hud_server/README.md new file mode 100644 index 000000000..4dd479ea1 --- /dev/null +++ b/hud_server/README.md @@ -0,0 +1,520 @@ +# HUD Server + +Production-ready HTTP API server for controlling HUD (Heads-Up Display) overlays in Wingman AI. + +## Overview + +The HUD Server provides a REST API to control HUD overlays from any client. It runs in its own thread with its own event loop, supporting: + +- **Multiple HUD Groups**: Independent overlay groups for different wingmen +- **Message Display**: Show messages with Markdown formatting, typewriter effects, and animations +- **Persistent Items**: Progress bars, timers, and status indicators +- **Chat Windows**: Multi-user chat overlays with auto-hide and message history +- **State Management**: Persist and restore HUD state across sessions +- **Overlay Integration**: Optional PIL-based overlay rendering on Windows + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Wingman AI Core │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Wingman │─────▶│ HUD Server │◀─────│ HTTP │ │ +│ │ Skills │ │ (FastAPI) │ │ Client │ │ +│ └────────────┘ └──────┬───────┘ └────────────┘ │ +│ │ │ +└─────────────────────────────┼───────────────────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ HUD Manager │ + │ (State Storage) │ + └─────────┬────────┘ + │ + ▼ + ┌──────────────────┐ + │ HeadsUpOverlay │ + │ (PIL Renderer) │ + └──────────────────┘ +``` + +### Core Components + +- **`server.py`**: FastAPI-based HTTP server with REST endpoints +- **`hud_manager.py`**: Thread-safe state management for all HUD groups +- **`http_client.py`**: Async/sync HTTP client for interacting with the server +- **`models.py`**: Pydantic models for API requests/responses with validation +- **`overlay/overlay.py`**: PIL-based overlay renderer (Windows only, optional) +- **`layout/manager.py`**: Automatic positioning and stacking system +- **`rendering/markdown.py`**: Sophisticated Markdown renderer +- **`platform/win32.py`**: Windows API integration + +## Quick Start + +### Starting the Server + +```python +from hud_server.server import HudServer + +server = HudServer() +server.start(host="127.0.0.1", port=7862, framerate=60) + +# Server runs in background thread +# API available at http://127.0.0.1:7862 +# Docs at http://127.0.0.1:7862/docs +``` + +### Using the Client (Async) + +```python +from hud_server.http_client import HudHttpClient + +async with HudHttpClient() as client: + # Create a HUD group + await client.create_group("my_wingman", { + "x": 20, "y": 20, "width": 400, + "bg_color": "#1e212b", "accent_color": "#00aaff" + }) + + # Show a message + await client.show_message( + group_name="my_wingman", + title="Hello!", + content="This is a **Markdown** message with `code`.", + duration=10.0 + ) + + # Add a progress bar + await client.show_progress( + group_name="my_wingman", + title="Loading", + current=50, + maximum=100 + ) +``` + +### Using the Client (Sync) + +```python +from hud_server.http_client import HudHttpClientSync + +with HudHttpClientSync() as client: + client.create_group("my_wingman") + client.show_message("my_wingman", "Title", "Content") + client.show_progress("my_wingman", "Loading", 50, 100) +``` + +## API Endpoints + +### Health & Status + +- `GET /health` - Health check and list of active groups +- `GET /` - Same as `/health` + +### Groups + +- `POST /groups` - Create or update a HUD group +- `PATCH /groups/{group_name}` - Update group properties +- `DELETE /groups/{group_name}` - Delete a group +- `GET /groups` - List all groups + +### Messages + +- `POST /message` - Show a message in a group +- `POST /message/append` - Append content to current message (streaming) +- `POST /message/hide/{group_name}` - Hide the current message + +### Persistent Items + +- `POST /items` - Add a persistent item +- `PUT /items` - Update an existing item +- `DELETE /items/{group_name}/{title}` - Remove an item +- `DELETE /items/{group_name}` - Clear all items + +### Progress & Timers + +- `POST /progress` - Show/update a progress bar +- `POST /timer` - Show a countdown timer + +### Chat Windows + +- `POST /chat/window` - Create a chat window +- `DELETE /chat/window/{name}` - Delete a chat window +- `POST /chat/message` - Send a chat message +- `DELETE /chat/messages/{name}` - Clear chat history +- `POST /chat/show/{name}` - Show a hidden chat window +- `POST /chat/hide/{name}` - Hide a chat window + +### State Management + +- `GET /state/{group_name}` - Get group state for persistence +- `POST /state/restore` - Restore group state from snapshot + +## Configuration + +### Server Settings + +```python +from hud_server.models import HudServerSettings + +settings = HudServerSettings( + enabled=True, # Auto-start with Core + host="127.0.0.1", # Local only + port=7862, # Default port + framerate=60, # Overlay FPS (1-240) + layout_margin=20, # Screen edge margin + layout_spacing=15 # Window spacing +) +``` + +### Group Properties + +```python +props = { + # Position & Size + "x": 20, "y": 20, "width": 400, "max_height": 600, + + # Colors (hex format) + "bg_color": "#1e212b", + "text_color": "#f0f0f0", + "accent_color": "#00aaff", + + # Visual + "opacity": 0.85, + "border_radius": 12, + "font_size": 16, + "font_family": "Segoe UI", + + # Behavior + "typewriter_effect": True, + "typewriter_speed": 200, # chars per second + "auto_fade": True, + "fade_delay": 8.0, # seconds + + # Layout (automatic positioning) + "layout_mode": "auto", # auto | manual | hybrid + "anchor": "top_left", # top_left | top_right | bottom_left | bottom_right | center + "priority": 10 # stacking order (0-100) +} +``` + +## Layout System + +The HUD Server includes an intelligent layout system to prevent overlapping windows when multiple HUD groups are active (e.g., messages from different wingmen, persistent info panels, chat windows). + +### Features + +1. **Anchor-based positioning**: Windows anchor to screen corners and edges +2. **Automatic stacking**: Windows at the same anchor stack vertically with configurable spacing +3. **Priority ordering**: Higher priority windows are positioned closer to the anchor point +4. **Dynamic reflow**: When window heights change, other windows reposition automatically +5. **Visibility awareness**: Hidden windows don't take up space in the layout + +### Layout Properties + +These properties can be set when creating or updating a HUD group: + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `layout_mode` | string | `"auto"` | `"auto"`, `"manual"`, or `"hybrid"` | +| `anchor` | string | `"top_left"` | Screen anchor point (see below) | +| `priority` | int | `10` | Stacking priority (higher = closer to anchor) | + +### Layout Modes + +- **`auto`** (default): Windows are automatically positioned and stacked based on anchor and priority +- **`manual`**: Windows use the `x` and `y` properties directly (no auto-stacking) +- **`hybrid`**: Reserved for future use with offset adjustments + +### Anchor Points + +The layout system supports 9 anchor points: + +``` + ┌─────────────────────────────────────────────────────┐ + │ │ + │ TOP_LEFT TOP_CENTER TOP_RIGHT │ + │ ↓ ↓ ↓ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↓ ↓ ↓ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ │ + │ LEFT_CENTER RIGHT_CENTER │ + │ (vertically (vertically │ + │ centered) ┌─────┐ centered) │ + │ ┌─────┐ │ C │ ┌─────┐ │ + │ │ │ └─────┘ │ │ │ + │ └─────┘ └─────┘ │ + │ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ + │ └─────┘ └─────┘ │ + │ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↑ ↑ ↑ │ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ │ │ │ │ │ │ + │ └─────┘ └─────┘ └─────┘ │ + │ ↑ ↑ ↑ │ + │ BOTTOM_LEFT BOTTOM_CENTER BOTTOM_RIGHT │ + │ │ + └─────────────────────────────────────────────────────┘ +``` + +**Anchor Points Reference:** + +| Anchor | Position | Stacking Direction | +|--------|----------|-------------------| +| `top_left` | Top-left corner | Downward | +| `top_center` | Top edge, centered | Downward | +| `top_right` | Top-right corner | Downward | +| `left_center` | Left edge, vertically centered | Downward (centered) | +| `center` | Screen center | No stacking | +| `right_center` | Right edge, vertically centered | Downward (centered) | +| `bottom_left` | Bottom-left corner | Upward | +| `bottom_center` | Bottom edge, centered | Upward | +| `bottom_right` | Bottom-right corner | Upward | + +### Priority-Based Stacking + +Windows with higher priority values are positioned closer to the anchor point: + +``` +Anchor: TOP_LEFT + +Priority 20: ┌─────────────┐ ← Closest to corner (y=20) + │ ATC Message │ + └─────────────┘ +Priority 15: ┌─────────────┐ ← Stacks below (y=130) + │ Navigation │ + └─────────────┘ +Priority 10: ┌─────────────┐ ← Stacks below (y=240) + │ Persistent │ + └─────────────┘ +``` + +### Layout Examples + +#### Multiple Groups at Same Anchor + +```python +# High priority - appears at top +await client.create_group("critical_alerts", { + "anchor": "top_right", + "priority": 100, + "layout_mode": "auto" +}) + +# Lower priority - stacks below critical alerts +await client.create_group("info_messages", { + "anchor": "top_right", + "priority": 50, + "layout_mode": "auto" +}) +``` + +#### Different Anchors for Different Types + +```python +# Main wingman messages on left +await client.create_group("atc", { + "anchor": "top_left", + "priority": 20, + "width": 400 +}) + +# Status info on right +await client.create_group("system_status", { + "anchor": "top_right", + "priority": 15, + "width": 350 +}) + +# Persistent data at bottom +await client.create_group("navigation", { + "anchor": "bottom_left", + "priority": 10, + "width": 450 +}) +``` + +#### Wingman Configuration Example + +In a Wingman's YAML config: + +```yaml +wingmen: + atc: + name: "ATC" + hud: + anchor: "top_left" + priority: 20 + layout_mode: "auto" + + computer: + name: "Computer" + hud: + anchor: "top_left" + priority: 15 + layout_mode: "auto" + + status: + name: "Status Display" + hud: + anchor: "bottom_right" + priority: 10 + width: 300 + layout_mode: "auto" +``` + +### Dynamic Behavior + +#### Height Adjustment + +When a window's content changes and its height increases/decreases, windows below it automatically reposition: + +``` +Before (ATC height=100): After (ATC height=200): +┌─────────────┐ y=20 ┌─────────────┐ y=20 +│ ATC Message │ │ │ +└─────────────┘ │ ATC Message │ +┌─────────────┐ y=130 │ │ +│ Navigation │ └─────────────┘ +└─────────────┘ ┌─────────────┐ y=230 ← Moved down + │ Navigation │ + └─────────────┘ +``` + +#### Visibility Awareness + +Hidden windows (faded out, no content) don't occupy space: + +``` +All visible: Navigation hidden: +┌─────────────┐ y=20 ┌─────────────┐ y=20 +│ ATC │ │ ATC │ +└─────────────┘ └─────────────┘ +┌─────────────┐ y=130 ┌─────────────┐ y=130 ← Moved up! +│ Navigation │ │ Status │ +└─────────────┘ └─────────────┘ +┌─────────────┐ y=240 +│ Status │ +└─────────────┘ +``` + +### Fallback Behavior + +If the layout manager cannot determine a position, the system falls back to using the `x` and `y` properties directly from the group props. + +## Markdown Support + +Messages support rich Markdown formatting: + +- **Bold**: `**text**` or `__text__` +- **Italic**: `*text*` or `_text_` +- **Code**: `` `inline` `` or ` ```block``` ` +- **Links**: `[text](url)` +- **Images**: `![alt](url)` +- **Headers**: `# H1`, `## H2`, etc. +- **Lists**: `- item` or `1. item` +- **Blockquotes**: `> quote` +- **Tables**: Standard Markdown table syntax + +## State Persistence + +Save and restore HUD state across sessions: + +```python +# Get current state +state = await client.get_state("my_wingman") + +# Store state in your database/file +save_to_storage(state) + +# Later, restore it +state = load_from_storage() +await client.restore_state("my_wingman", state) +``` + +## Error Handling + +The server provides detailed error responses: + +```json +{ + "status": "error", + "message": "Group 'unknown' not found", + "detail": "..." +} +``` + +HTTP Status Codes: +- `200` - Success +- `404` - Resource not found +- `422` - Validation error (invalid request data) +- `500` - Internal server error + +### Logging + +All components use `Printr` for consistent logging: + +```python +from services.printr import Printr +from api.enums import LogType + +printr = Printr() +printr.print("HUD Server started", color=LogType.INFO, server_only=True) +``` + +## Testing + +Run the test suite: + +```powershell +python -m hud_server.tests.run_tests +``` + +Individual tests: +```powershell +python -m hud_server.tests.run_tests # Run quick integration test +python -m hud_server.tests.run_tests --all # Run all test suites +python -m hud_server.tests.run_tests --messages # Run message tests +python -m hud_server.tests.run_tests --progress # Run progress tests +python -m hud_server.tests.run_tests --persistent # Run persistent info tests +python -m hud_server.tests.run_tests --chat # Run chat tests +python -m hud_server.tests.run_tests --unicode # Run Unicode/emoji stress tests +python -m hud_server.tests.run_tests --layout # Run layout manager unit tests (no server needed) +python -m hud_server.tests.run_tests --layout-visual # Run visual layout tests with actual HUD windows +``` + +## Troubleshooting + +### Server won't start + +Check if port is already in use: +```powershell +netstat -ano | findstr :7862 +``` + +Try a different port: +```python +server.start(port=7863) +``` + +### Overlay not showing + +1. Check PIL is installed: `pip install Pillow` +2. Windows only - not supported on macOS/Linux +3. Check logs for overlay errors + +### Connection failures + +1. Verify server is running: `http://127.0.0.1:7862/health` +2. Check firewall settings +3. Use correct host/port in client diff --git a/hud_server/constants.py b/hud_server/constants.py new file mode 100644 index 000000000..6f859fdfb --- /dev/null +++ b/hud_server/constants.py @@ -0,0 +1,137 @@ +""" +HUD Server Constants + +Centralized constants and configuration values for the HUD Server. +""" + +# Server Configuration +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 7862 +DEFAULT_FRAMERATE = 60 +DEFAULT_LAYOUT_MARGIN = 20 +DEFAULT_LAYOUT_SPACING = 15 + +# Limits and Bounds +MIN_PORT = 1024 +MAX_PORT = 65535 +MIN_FRAMERATE = 1 +MAX_FRAMERATE = 240 +MIN_FONT_SIZE = 8 +MAX_FONT_SIZE = 72 +MIN_WIDTH = 100 +MAX_WIDTH = 3840 # 4K width +MIN_HEIGHT = 100 +MAX_HEIGHT = 2160 # 4K height +MAX_MESSAGE_LENGTH = 50000 +MAX_GROUP_NAME_LENGTH = 100 +MAX_TITLE_LENGTH = 200 + +# Timeouts (seconds) +SERVER_STARTUP_TIMEOUT = 5.0 +SERVER_STARTUP_CHECK_INTERVAL = 0.1 +SERVER_SHUTDOWN_TIMEOUT = 5.0 +OVERLAY_SHUTDOWN_TIMEOUT = 2.0 +HTTP_CONNECT_TIMEOUT = 5.0 +HTTP_REQUEST_TIMEOUT = 10.0 +SYNC_OPERATION_TIMEOUT = 10.0 + +# Cache Limits +MAX_IMAGE_CACHE_SIZE = 20 +MAX_FONT_CACHE_SIZE = 10 + +# Colors (hex format) +DEFAULT_BG_COLOR = "#1e212b" +DEFAULT_TEXT_COLOR = "#f0f0f0" +DEFAULT_ACCENT_COLOR = "#00aaff" +DEFAULT_LOADING_COLOR = "#00aaff" + +# Visual Defaults +DEFAULT_OPACITY = 0.85 +DEFAULT_BORDER_RADIUS = 12 +DEFAULT_FONT_SIZE = 16 +DEFAULT_FONT_FAMILY = "Segoe UI" +DEFAULT_CONTENT_PADDING = 16 +DEFAULT_LINE_HEIGHT = 26 + +# Behavior Defaults +DEFAULT_TYPEWRITER_SPEED = 200 # chars per second +DEFAULT_FADE_DELAY = 8.0 # seconds +DEFAULT_FADE_DURATION = 0.5 # seconds +DEFAULT_AUTO_HIDE_DELAY = 10.0 # seconds + +# Layout +DEFAULT_ANCHOR = "top_left" +DEFAULT_LAYOUT_MODE = "auto" +DEFAULT_PRIORITY = 10 +DEFAULT_Z_ORDER = 0 + +# Chat +DEFAULT_MAX_MESSAGES = 50 +DEFAULT_MESSAGE_SPACING = 8 + +# Progress/Timer +PROGRESS_TRANSITION_DURATION = 0.5 # seconds + +# Thread Names +THREAD_NAME_SERVER = "HUDServerThread" +THREAD_NAME_OVERLAY = "HUDOverlayThread" +THREAD_NAME_CLIENT_LOOP = "HUDClientLoopThread" + +# API Paths +PATH_HEALTH = "/health" +PATH_ROOT = "/" +PATH_GROUPS = "/groups" +PATH_MESSAGE = "/message" +PATH_MESSAGE_APPEND = "/message/append" +PATH_MESSAGE_HIDE = "/message/hide" +PATH_LOADER = "/loader" +PATH_ITEMS = "/items" +PATH_PROGRESS = "/progress" +PATH_TIMER = "/timer" +PATH_CHAT_WINDOW = "/chat/window" +PATH_CHAT_MESSAGE = "/chat/message" +PATH_STATE = "/state" +PATH_STATE_RESTORE = "/state/restore" + +# Window Types +WINDOW_TYPE_MESSAGE = "message" +WINDOW_TYPE_PERSISTENT = "persistent" +WINDOW_TYPE_CHAT = "chat" + +# Fade States +FADE_STATE_HIDDEN = 0 +FADE_STATE_FADE_IN = 1 +FADE_STATE_VISIBLE = 2 +FADE_STATE_FADE_OUT = 3 + +# Layout Anchors +ANCHOR_TOP_LEFT = "top_left" +ANCHOR_TOP_CENTER = "top_center" +ANCHOR_TOP_RIGHT = "top_right" +ANCHOR_RIGHT_CENTER = "right_center" +ANCHOR_BOTTOM_RIGHT = "bottom_right" +ANCHOR_BOTTOM_CENTER = "bottom_center" +ANCHOR_BOTTOM_LEFT = "bottom_left" +ANCHOR_LEFT_CENTER = "left_center" +ANCHOR_CENTER = "center" + +# Layout Modes +LAYOUT_MODE_AUTO = "auto" +LAYOUT_MODE_MANUAL = "manual" +LAYOUT_MODE_HYBRID = "hybrid" + +# HTTP Status Codes +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_VALIDATION_ERROR = 422 +HTTP_INTERNAL_ERROR = 500 + +# Log Messages +LOG_SERVER_STARTED = "HUD Server started on http://{}:{}" +LOG_SERVER_STOPPED = "HUD Server stopped" +LOG_SERVER_STARTUP_TIMEOUT = "Failed to start within {}s timeout" +LOG_SERVER_ALREADY_RUNNING = "Server already running" +LOG_OVERLAY_STARTED = "Overlay renderer started" +LOG_OVERLAY_STOPPED = "Overlay renderer stopped" +LOG_OVERLAY_NOT_AVAILABLE = "Overlay not available (PIL or HeadsUpOverlay missing)" +LOG_OVERLAY_ALREADY_RUNNING = "Overlay already running" diff --git a/hud_server/http_client.py b/hud_server/http_client.py index 12899ee02..2e446f9d7 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -21,7 +21,9 @@ import httpx from typing import Optional, Any from urllib.parse import quote +from api.enums import LogType from services.printr import Printr +from hud_server import constants as hud_const printr = Printr() @@ -29,7 +31,12 @@ class HudHttpClient: """Async HTTP client for the HUD Server.""" - def __init__(self, base_url: str = "http://127.0.0.1:7862"): + # Timeout constants + DEFAULT_CONNECT_TIMEOUT = hud_const.HTTP_CONNECT_TIMEOUT + DEFAULT_REQUEST_TIMEOUT = hud_const.HTTP_REQUEST_TIMEOUT + RECONNECT_ATTEMPTS = 1 + + def __init__(self, base_url: str = f"http://{hud_const.DEFAULT_HOST}:{hud_const.DEFAULT_PORT}"): self.base_url = base_url.rstrip("/") self._client: Optional[httpx.AsyncClient] = None self._connected = False @@ -38,15 +45,23 @@ def __init__(self, base_url: str = "http://127.0.0.1:7862"): def connected(self) -> bool: return self._connected - async def connect(self, timeout: float = 5.0) -> bool: - """Connect to the HUD server.""" + async def connect(self, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> bool: + """ + Connect to the HUD server. + + Args: + timeout: Connection timeout in seconds + + Returns: + True if connection successful, False otherwise + """ try: # Close existing client if any - ignore all errors since the loop might be closed if self._client: try: await self._client.aclose() except Exception: - pass + pass # Expected during cleanup self._client = None self._client = httpx.AsyncClient( @@ -63,7 +78,17 @@ async def connect(self, timeout: float = 5.0) -> bool: self._connected = True return True return False - except Exception: + except httpx.ConnectError: + # Server not reachable - expected during startup/shutdown + self._connected = False + return False + except Exception as e: + # Unexpected error - log it + printr.print( + f"[HUD HTTP Client] Unexpected connection error: {type(e).__name__}: {e}", + color=LogType.WARNING, + server_only=True + ) self._connected = False return False @@ -87,7 +112,17 @@ async def _request( path: str, json: Optional[dict] = None ) -> Optional[dict]: - """Make an HTTP request to the server.""" + """ + Make an HTTP request to the server. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + path: URL path + json: Optional JSON payload + + Returns: + Response JSON dict if successful, None otherwise + """ # Reconnect if not connected (either no client or marked as disconnected) if not self._client or not self._connected: @@ -105,12 +140,24 @@ async def _execute_request(): elif method == "DELETE": return await self._client.delete(path) else: + printr.print( + f"[HUD HTTP Client] Unsupported HTTP method: {method}", + color=LogType.ERROR, + server_only=True + ) return None try: response = await _execute_request() if response and 200 <= response.status_code < 300: return response.json() + elif response: + # Log non-2xx responses for debugging + printr.print( + f"[HUD HTTP Client] Request {method} {path} failed with status {response.status_code}", + color=LogType.WARNING, + server_only=True + ) return None except RuntimeError as e: # Handle "Event loop is closed" error by reconnecting @@ -124,10 +171,19 @@ async def _execute_request(): if response and 200 <= response.status_code < 300: return response.json() except Exception: - pass + pass # Give up after retry self._connected = False return None - except Exception: + except httpx.ConnectError: + # Server not reachable - don't spam logs + self._connected = False + return None + except Exception as e: + printr.print( + f"[HUD HTTP Client] Request {method} {path} error: {type(e).__name__}: {e}", + color=LogType.WARNING, + server_only=True + ) self._connected = False return None @@ -452,50 +508,113 @@ class HudHttpClientSync: Synchronous wrapper for HudHttpClient. Useful for non-async code that needs to interact with the HUD server. + Uses a background event loop in a dedicated thread for async operations. """ - def __init__(self, base_url: str = "http://127.0.0.1:7862"): + # Timeout for synchronous operations + SYNC_OPERATION_TIMEOUT = hud_const.SYNC_OPERATION_TIMEOUT + + def __init__(self, base_url: str = f"http://{hud_const.DEFAULT_HOST}:{hud_const.DEFAULT_PORT}"): self._base_url = base_url self._client: Optional[HudHttpClient] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._thread: Optional[threading.Thread] = None self._lock = threading.Lock() + self._loop_started = threading.Event() - def _ensure_loop(self): + def _ensure_loop(self) -> None: """Ensure event loop is running in background thread.""" - if self._loop is None or not self._loop.is_running(): - self._loop = asyncio.new_event_loop() - self._thread = threading.Thread(target=self._run_loop, daemon=True) - self._thread.start() - time.sleep(0.1) - - def _run_loop(self): + with self._lock: + if self._loop is None or not self._loop.is_running(): + self._loop_started.clear() + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._run_loop, + daemon=True, + name=hud_const.THREAD_NAME_CLIENT_LOOP + ) + self._thread.start() + # Wait for loop to start + if not self._loop_started.wait(timeout=5.0): + printr.print( + "[HUD HTTP Client Sync] Event loop failed to start", + color=LogType.ERROR, + server_only=True + ) + + def _run_loop(self) -> None: """Run event loop in background thread.""" asyncio.set_event_loop(self._loop) - self._loop.run_forever() + self._loop_started.set() + try: + self._loop.run_forever() + except Exception as e: + printr.print( + f"[HUD HTTP Client Sync] Event loop error: {type(e).__name__}: {e}", + color=LogType.ERROR, + server_only=True + ) def _run_coro(self, coro): """Run a coroutine in the background event loop.""" self._ensure_loop() - future = asyncio.run_coroutine_threadsafe(coro, self._loop) - return future.result(timeout=10.0) + try: + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=self.SYNC_OPERATION_TIMEOUT) + except TimeoutError: + printr.print( + f"[HUD HTTP Client Sync] Operation timed out after {self.SYNC_OPERATION_TIMEOUT}s", + color=LogType.WARNING, + server_only=True + ) + return None + except Exception as e: + printr.print( + f"[HUD HTTP Client Sync] Operation error: {type(e).__name__}: {e}", + color=LogType.WARNING, + server_only=True + ) + return None @property def connected(self) -> bool: + """Check if client is connected to server.""" return self._client is not None and self._client.connected - def connect(self, timeout: float = 5.0) -> bool: + def connect(self, timeout: float = HudHttpClient.DEFAULT_CONNECT_TIMEOUT) -> bool: + """ + Connect to the HUD server. + + Args: + timeout: Connection timeout in seconds + + Returns: + True if connection successful + """ with self._lock: self._ensure_loop() self._client = HudHttpClient(self._base_url) - return self._run_coro(self._client.connect(timeout)) + result = self._run_coro(self._client.connect(timeout)) + return result if result is not None else False - def disconnect(self): + def disconnect(self) -> None: + """Disconnect from the HUD server and cleanup resources.""" with self._lock: if self._client: self._run_coro(self._client.disconnect()) self._client = None + # Stop the event loop + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + + # Wait for thread to finish + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + self._thread = None + + self._loop = None + def __enter__(self): self.connect() return self diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py index 3d13699f2..efedd8e25 100644 --- a/hud_server/hud_manager.py +++ b/hud_server/hud_manager.py @@ -177,6 +177,7 @@ class HudManager: Manages all HUD groups and their state. Thread-safe for concurrent access from multiple clients. + Supports callbacks for real-time overlay integration. """ def __init__(self): @@ -184,26 +185,37 @@ def __init__(self): self._lock = threading.RLock() self._command_callbacks: list = [] # Callbacks for overlay integration - def register_command_callback(self, callback): - """Register a callback to receive commands for overlay rendering.""" + def register_command_callback(self, callback) -> None: + """ + Register a callback to receive commands for overlay rendering. + + Args: + callback: Callable that accepts a dict command parameter + """ with self._lock: if callback not in self._command_callbacks: self._command_callbacks.append(callback) - def unregister_command_callback(self, callback): - """Unregister a command callback.""" + def unregister_command_callback(self, callback) -> None: + """ + Unregister a command callback. + + Args: + callback: Previously registered callback to remove + """ with self._lock: if callback in self._command_callbacks: self._command_callbacks.remove(callback) - def _notify_callbacks(self, command: dict[str, Any]): + def _notify_callbacks(self, command: dict[str, Any]) -> None: """Notify all registered callbacks of a command.""" for i, callback in enumerate(self._command_callbacks): try: callback(command) except Exception as e: printr.print( - f"[HUD Manager] _notify_callbacks: callback {i} FAILED: {e}", + f"[HUD Manager] Callback {i} failed for command '{command.get('type', 'unknown')}': " + f"{type(e).__name__}: {e}", color=LogType.ERROR, server_only=True ) @@ -692,8 +704,12 @@ def hide_chat_window(self, name: str) -> bool: return True - def clear_all(self): - """Clear all groups (fresh start).""" + def clear_all(self) -> None: + """ + Clear all groups and reset state (fresh start). + + Useful for resetting the HUD system without restarting the server. + """ with self._lock: self._groups.clear() diff --git a/hud_server/layout/README.md b/hud_server/layout/README.md deleted file mode 100644 index 6831ff202..000000000 --- a/hud_server/layout/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# HUD Layout Manager - -The Layout Manager provides automatic positioning and stacking for HUD elements to prevent overlapping windows. - -## Overview - -When multiple HUD groups are active (e.g., messages from different wingmen, persistent info panels, chat windows), they can overlap if positioned at similar coordinates. The Layout Manager solves this by: - -1. **Anchor-based positioning**: Windows anchor to screen corners (top-left, top-right, bottom-left, bottom-right) -2. **Automatic stacking**: Windows at the same anchor stack vertically with configurable spacing -3. **Priority ordering**: Higher priority windows are positioned closer to the anchor point -4. **Dynamic reflow**: When window heights change, other windows reposition automatically -5. **Visibility awareness**: Hidden windows don't take up space in the layout - -## Configuration - -### Layout Properties - -These properties can be set when creating or updating a HUD group: - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `layout_mode` | string | `"auto"` | `"auto"`, `"manual"`, or `"hybrid"` | -| `anchor` | string | `"top_left"` | `"top_left"`, `"top_right"`, `"bottom_left"`, `"bottom_right"`, `"center"` | -| `priority` | int | `10` | Stacking priority (higher = closer to anchor) | -| `margin` | int | `20` | Margin from screen edge (pixels) | -| `spacing` | int | `10` | Space between stacked windows (pixels) | - -### Layout Modes - -- **`auto`** (default): Windows are automatically positioned and stacked based on anchor and priority -- **`manual`**: Windows use the `x` and `y` properties directly (no auto-stacking) -- **`hybrid`**: Not yet implemented; reserved for future use with offset adjustments - -### Anchor Points - -``` - ┌───────────────────────────────────────────────────────────┐ - │ │ - │ TOP_LEFT TOP_CENTER TOP_RIGHT │ - │ ↓ ↓ ↓ │ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ │ │ │ │ │ │ - │ └─────┘ └─────┘ └─────┘ │ - │ ↓ ↓ ↓ │ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ │ │ │ │ │ │ - │ └─────┘ └─────┘ └─────┘ │ - │ │ - │ LEFT_CENTER RIGHT_CENTER │ - │ (vertically (vertically │ - │ centered) ┌─────┐ centered) │ - │ ┌─────┐ │ C │ ┌─────┐ │ - │ │ │ └─────┘ │ │ │ - │ └─────┘ └─────┘ │ - │ ┌─────┐ ┌─────┐ │ - │ │ │ │ │ │ - │ └─────┘ └─────┘ │ - │ │ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ │ │ │ │ │ │ - │ └─────┘ └─────┘ └─────┘ │ - │ ↑ ↑ ↑ │ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ │ │ │ │ │ │ - │ └─────┘ └─────┘ └─────┘ │ - │ ↑ ↑ ↑ │ - │ BOTTOM_LEFT BOTTOM_CENTER BOTTOM_RIGHT │ - │ │ - └───────────────────────────────────────────────────────────┘ -``` - -**9 Anchor Points:** - -| Anchor | Position | Stacking Direction | -|--------|----------|-------------------| -| `top_left` | Top-left corner | Downward | -| `top_center` | Top edge, centered | Downward | -| `top_right` | Top-right corner | Downward | -| `left_center` | Left edge, vertically centered | Downward (centered) | -| `center` | Screen center | No stacking | -| `right_center` | Right edge, vertically centered | Downward (centered) | -| `bottom_left` | Bottom-left corner | Upward | -| `bottom_center` | Bottom edge, centered | Upward | -| `bottom_right` | Bottom-right corner | Upward | - -## Usage Examples - -### API Example: Create groups with auto-layout - -```python -import httpx - -# Create a high-priority wingman group (messages appear at top) -httpx.post("http://127.0.0.1:7862/group", json={ - "group_name": "ATC", - "props": { - "anchor": "top_left", - "priority": 20, - "margin": 20, - "spacing": 10, - "layout_mode": "auto" - } -}) - -# Create a lower-priority group (stacks below ATC) -httpx.post("http://127.0.0.1:7862/group", json={ - "group_name": "Navigation", - "props": { - "anchor": "top_left", - "priority": 10, - "margin": 20, - "spacing": 10, - "layout_mode": "auto" - } -}) - -# Create a group on the right side of the screen -httpx.post("http://127.0.0.1:7862/group", json={ - "group_name": "System", - "props": { - "anchor": "top_right", - "priority": 15, - "width": 350 - } -}) -``` - -### Skill Configuration Example - -In a Wingman's config, you can set layout properties: - -```yaml -wingmen: - atc: - name: "ATC" - hud: - anchor: "top_left" - priority: 20 - margin: 20 - spacing: 10 - - computer: - name: "Computer" - hud: - anchor: "top_left" - priority: 15 - margin: 20 - spacing: 10 - - status: - name: "Status Display" - hud: - anchor: "bottom_right" - priority: 10 - width: 300 -``` - -## Behavior Details - -### Priority-based Stacking - -Windows with higher priority values are positioned closer to the anchor point: - -``` -Anchor: TOP_LEFT - -Priority 20: ┌─────────────┐ ← Closest to corner (y=20) - │ ATC Message │ - └─────────────┘ -Priority 15: ┌─────────────┐ ← Stacks below (y=130) - │ Navigation │ - └─────────────┘ -Priority 10: ┌─────────────┐ ← Stacks below (y=240) - │ Persistent │ - └─────────────┘ -``` - -### Dynamic Height Adjustment - -When a window's content changes and its height increases/decreases, windows below it automatically reposition: - -``` -Before (ATC height=100): After (ATC height=200): -┌─────────────┐ y=20 ┌─────────────┐ y=20 -│ ATC Message │ │ │ -└─────────────┘ │ ATC Message │ -┌─────────────┐ y=130 │ │ -│ Navigation │ └─────────────┘ -└─────────────┘ ┌─────────────┐ y=230 ← Moved down - │ Navigation │ - └─────────────┘ -``` - -### Visibility and Layout - -Hidden windows (faded out, no content) don't occupy space: - -``` -All visible: Navigation hidden: -┌─────────────┐ y=20 ┌─────────────┐ y=20 -│ ATC │ │ ATC │ -└─────────────┘ └─────────────┘ -┌─────────────┐ y=130 ┌─────────────┐ y=130 ← Moved up! -│ Navigation │ │ Status │ -└─────────────┘ └─────────────┘ -┌─────────────┐ y=240 -│ Status │ -└─────────────┘ -``` - -## Fallback Behavior - -If the layout manager cannot determine a position (edge cases), the system falls back to using the `x` and `y` properties directly from the group props. - -## Testing - -Run layout manager tests: - -```bash -python -m hud_server.tests.run_tests --layout -``` - -This runs unit tests that verify: -- Basic vertical stacking -- Multiple anchor support -- Visibility handling -- Dynamic height updates -- Manual mode positioning -- Collision detection diff --git a/hud_server/layout/manager.py b/hud_server/layout/manager.py index f6e948014..188baedcd 100644 --- a/hud_server/layout/manager.py +++ b/hud_server/layout/manager.py @@ -3,10 +3,14 @@ This module provides intelligent layout management to prevent HUD element overlap: -1. **Anchor System**: Elements anchor to screen corners (top-left, top-right, bottom-left, bottom-right) +1. **Anchor System**: Elements anchor to screen corners and edges (9 anchor points) 2. **Automatic Stacking**: Elements at the same anchor stack vertically with configurable spacing 3. **Dynamic Reflow**: When element heights change, others reposition automatically 4. **Priority Ordering**: Elements can be ordered by priority within an anchor zone +5. **Visibility Awareness**: Hidden elements don't occupy space + +For complete documentation including visual diagrams and examples, see: + hud_server/README.md - Layout System section Usage: from hud_server.layout import LayoutManager, Anchor @@ -200,7 +204,6 @@ def update_window( Returns True if window exists and was updated. """ - import sys with self._lock: window = self._windows.get(name) if not window: diff --git a/hud_server/models.py b/hud_server/models.py index cfa0c5f3a..98c3d0dc5 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -1,9 +1,11 @@ """ Pydantic Models for HUD Server API. + +Defines all request/response models and configuration schemas for the HUD Server. """ from typing import Optional, Any -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator # ─────────────────────────────── Configuration ─────────────────────────────── # @@ -15,20 +17,28 @@ class HudServerSettings(BaseModel): enabled: bool = False """Whether the HUD server should auto-start with Wingman AI Core.""" - host: str = "127.0.0.1" + host: str = Field(default="127.0.0.1", pattern=r"^(\d{1,3}\.){3}\d{1,3}$|^localhost$|^0\.0\.0\.0$") """The interface to listen on. Use '127.0.0.1' for local only, '0.0.0.0' for LAN access.""" - port: int = 7862 - """The port to listen on.""" + port: int = Field(default=7862, ge=1024, le=65535) + """The port to listen on. Must be between 1024 and 65535.""" + + framerate: int = Field(default=60, ge=1, le=240) + """HUD overlay rendering framerate. Between 1 and 240 FPS.""" - framerate: int = 60 - """HUD overlay rendering framerate. Minimum 1.""" + layout_margin: int = Field(default=20, ge=0, le=200) + """Margin from screen edges in pixels for HUD elements. Between 0 and 200.""" - layout_margin: int = 20 - """Margin from screen edges in pixels for HUD elements.""" + layout_spacing: int = Field(default=15, ge=0, le=100) + """Spacing between stacked HUD windows in pixels. Between 0 and 100.""" - layout_spacing: int = 15 - """Spacing between stacked HUD windows in pixels.""" + @field_validator('host') + @classmethod + def validate_host(cls, v: str) -> str: + """Validate host is a valid IP address or hostname.""" + if v not in ['localhost', '0.0.0.0'] and not all(0 <= int(part) <= 255 for part in v.split('.')): + raise ValueError('Invalid IP address format') + return v # ─────────────────────────────── Group Properties ─────────────────────────────── # @@ -38,43 +48,43 @@ class HudGroupProps(BaseModel): """Properties for a HUD group. All properties are optional when updating.""" # Position & Size - x: int = 20 - y: int = 20 - width: int = 400 - max_height: int = 600 + x: int = Field(default=20, ge=-5000, le=10000) + y: int = Field(default=20, ge=-5000, le=10000) + width: int = Field(default=400, ge=100, le=3840) + max_height: int = Field(default=600, ge=100, le=2160) - # Colors - bg_color: str = "#1e212b" - text_color: str = "#f0f0f0" - accent_color: str = "#00aaff" - title_color: Optional[str] = None + # Colors (hex format) + bg_color: str = Field(default="#1e212b", pattern=r"^#[0-9a-fA-F]{6}$") + text_color: str = Field(default="#f0f0f0", pattern=r"^#[0-9a-fA-F]{6}$") + accent_color: str = Field(default="#00aaff", pattern=r"^#[0-9a-fA-F]{6}$") + title_color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") # Visual - opacity: float = 0.85 - border_radius: int = 12 - font_size: int = 16 + opacity: float = Field(default=0.85, ge=0.0, le=1.0) + border_radius: int = Field(default=12, ge=0, le=50) + font_size: int = Field(default=16, ge=8, le=72) font_family: str = "Segoe UI" - content_padding: int = 16 + content_padding: int = Field(default=16, ge=0, le=100) # Behavior typewriter_effect: bool = True - typewriter_speed: int = 200 + typewriter_speed: int = Field(default=200, ge=1, le=1000) show_loader: bool = True auto_fade: bool = True - fade_delay: float = 8.0 - fade_duration: float = 0.5 + fade_delay: float = Field(default=8.0, ge=0.0, le=300.0) + fade_duration: float = Field(default=0.5, ge=0.1, le=10.0) # Rendering - z_order: int = 0 + z_order: int = Field(default=0, ge=-1000, le=1000) # Layout Management - layout_mode: str = "auto" + layout_mode: str = Field(default="auto", pattern=r"^(auto|manual|hybrid)$") """Layout mode: 'auto' (automatic stacking), 'manual' (fixed x,y), 'hybrid' (auto with offset).""" - anchor: str = "top_left" + anchor: str = Field(default="top_left", pattern=r"^(top_left|top_right|bottom_left|bottom_right|center)$") """Screen anchor for auto layout: 'top_left', 'top_right', 'bottom_left', 'bottom_right', 'center'.""" - priority: int = 10 + priority: int = Field(default=10, ge=0, le=100) """Stacking priority within anchor zone. Higher = closer to anchor point.""" @@ -137,16 +147,16 @@ class UpdateGroupRequest(BaseModel): class MessageRequest(BaseModel): """Request to show a message in a group.""" - group_name: str + group_name: str = Field(..., min_length=1, max_length=100) """Name of the HUD group.""" - title: str + title: str = Field(..., min_length=1, max_length=200) """Message title.""" - content: str + content: str = Field(..., max_length=50000) """Message content (supports Markdown).""" - color: Optional[str] = None + color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") """Optional title/accent color override.""" tools: Optional[list[dict[str, Any]]] = None @@ -155,8 +165,8 @@ class MessageRequest(BaseModel): props: Optional[dict[str, Any]] = None """Optional property overrides for this message.""" - duration: Optional[float] = None - """Optional duration in seconds before auto-hide.""" + duration: Optional[float] = Field(default=None, ge=0.1, le=3600.0) + """Optional duration in seconds before auto-hide (0.1 to 3600).""" class AppendMessageRequest(BaseModel): diff --git a/hud_server/server.py b/hud_server/server.py index ada8d6937..25631a551 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -8,6 +8,7 @@ import asyncio import threading import queue +import time from typing import Optional, Any from contextlib import asynccontextmanager @@ -23,6 +24,7 @@ from api.enums import LogType from services.printr import Printr from hud_server.hud_manager import HudManager +from hud_server import constants as hud_const from hud_server.models import ( CreateGroupRequest, UpdateGroupRequest, @@ -52,7 +54,8 @@ HeadsUpOverlay = _HeadsUpOverlay PIL_AVAILABLE = _PIL_AVAILABLE except ImportError: - pass + _HeadsUpOverlay = None + _PIL_AVAILABLE = False printr = Printr() @@ -68,16 +71,27 @@ class HudServer: VERSION = "1.0.0" + # Default configuration constants (from constants module) + DEFAULT_HOST = hud_const.DEFAULT_HOST + DEFAULT_PORT = hud_const.DEFAULT_PORT + DEFAULT_FRAMERATE = hud_const.DEFAULT_FRAMERATE + DEFAULT_LAYOUT_MARGIN = hud_const.DEFAULT_LAYOUT_MARGIN + DEFAULT_LAYOUT_SPACING = hud_const.DEFAULT_LAYOUT_SPACING + + # Server startup timeout + STARTUP_TIMEOUT_SECONDS = hud_const.SERVER_STARTUP_TIMEOUT + STARTUP_CHECK_INTERVAL = hud_const.SERVER_STARTUP_CHECK_INTERVAL + def __init__(self): self._thread: Optional[threading.Thread] = None self._loop: Optional[asyncio.AbstractEventLoop] = None self._server: Optional[Server] = None self._running = False - self._host = "127.0.0.1" - self._port = 7862 - self._framerate = 60 - self._layout_margin = 20 - self._layout_spacing = 15 + self._host = self.DEFAULT_HOST + self._port = self.DEFAULT_PORT + self._framerate = self.DEFAULT_FRAMERATE + self._layout_margin = self.DEFAULT_LAYOUT_MARGIN + self._layout_spacing = self.DEFAULT_LAYOUT_SPACING # HUD state manager self.manager = HudManager() @@ -421,9 +435,19 @@ async def hide_chat_window(name: str): def _start_overlay(self): """Start the overlay renderer in a background thread (if available).""" if not OVERLAY_AVAILABLE or HeadsUpOverlay is None: + printr.print( + hud_const.LOG_OVERLAY_NOT_AVAILABLE, + color=LogType.WARNING, + server_only=True + ) return if self._overlay_thread and self._overlay_thread.is_alive(): + printr.print( + hud_const.LOG_OVERLAY_ALREADY_RUNNING, + color=LogType.WARNING, + server_only=True + ) return try: @@ -444,28 +468,66 @@ def _start_overlay(self): self._overlay_thread = threading.Thread( target=self._overlay.run, daemon=True, - name="HUDOverlayThread" + name=hud_const.THREAD_NAME_OVERLAY ) self._overlay_thread.start() - except Exception: - pass # Overlay is optional + printr.print( + hud_const.LOG_OVERLAY_STARTED, + color=LogType.INFO, + server_only=True + ) + + except Exception as e: + printr.print( + f"[HUD Server] Failed to start overlay: {type(e).__name__}: {e}", + color=LogType.ERROR, + server_only=True + ) + # Overlay is optional, so we continue without it def _stop_overlay(self): """Stop the overlay renderer.""" - if self._command_queue: - try: - self._command_queue.put({"type": "quit"}) - except Exception: - pass + if not self._command_queue and not self._overlay_thread: + return - if self._overlay_thread: - self._overlay_thread.join(timeout=2.0) - self._overlay_thread = None + try: + if self._command_queue: + try: + self._command_queue.put({"type": "quit"}, timeout=1.0) + except Exception as e: + printr.print( + f"[HUD Server] Failed to send quit command to overlay: {e}", + color=LogType.WARNING, + server_only=True + ) + + if self._overlay_thread: + self._overlay_thread.join(timeout=hud_const.OVERLAY_SHUTDOWN_TIMEOUT) + if self._overlay_thread.is_alive(): + printr.print( + "[HUD Server] Overlay thread did not stop gracefully", + color=LogType.WARNING, + server_only=True + ) + self._overlay_thread = None + + self._overlay = None + self._command_queue = None + self.manager.unregister_command_callback(self._send_to_overlay) - self._overlay = None - self._command_queue = None - self.manager.unregister_command_callback(self._send_to_overlay) + printr.print( + hud_const.LOG_OVERLAY_STOPPED, + color=LogType.INFO, + server_only=True + ) + + except Exception as e: + printr.print( + f"[HUD Server] Error stopping overlay: {type(e).__name__}: {e}", + color=LogType.ERROR, + server_only=True + ) def _send_to_overlay(self, command: dict[str, Any]): """Send a command to the overlay renderer.""" @@ -481,8 +543,8 @@ def _send_to_overlay(self, command: dict[str, Any]): # ─────────────────────────────── Server Lifecycle ─────────────────────────────── # - def start(self, host: str = "127.0.0.1", port: int = 7862, framerate: int = 60, - layout_margin: int = 20, layout_spacing: int = 15) -> bool: + def start(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, framerate: int = DEFAULT_FRAMERATE, + layout_margin: int = DEFAULT_LAYOUT_MARGIN, layout_spacing: int = DEFAULT_LAYOUT_SPACING) -> bool: """ Start the HUD server in a background thread. @@ -497,6 +559,11 @@ def start(self, host: str = "127.0.0.1", port: int = 7862, framerate: int = 60, True if server started successfully """ if self._running: + printr.print( + hud_const.LOG_SERVER_ALREADY_RUNNING, + color=LogType.WARNING, + server_only=True + ) return True self._host = host @@ -508,46 +575,60 @@ def start(self, host: str = "127.0.0.1", port: int = 7862, framerate: int = 60, self._thread = threading.Thread( target=self._run_server, daemon=True, - name="HUDServerThread" + name=hud_const.THREAD_NAME_SERVER ) self._thread.start() - # Wait briefly for server to start - import time - for _ in range(50): # 5 seconds max - time.sleep(0.1) + # Wait for server to start + max_checks = int(self.STARTUP_TIMEOUT_SECONDS / self.STARTUP_CHECK_INTERVAL) + for _ in range(max_checks): + time.sleep(self.STARTUP_CHECK_INTERVAL) if self._running: printr.print( - f"HUD Server started on http://{self._host}:{self._port}", + hud_const.LOG_SERVER_STARTED.format(self._host, self._port), color=LogType.INFO, server_only=True ) return True + printr.print( + hud_const.LOG_SERVER_STARTUP_TIMEOUT.format(self.STARTUP_TIMEOUT_SECONDS), + color=LogType.ERROR, + server_only=True + ) return False def _run_server(self): """Run the server in its own thread with its own event loop.""" - self._loop = asyncio.new_event_loop() - asyncio.set_event_loop(self._loop) - - config = Config( - app=self.app, - host=self._host, - port=self._port, - log_level="warning", - access_log=False, - ) - self._server = Server(config) + try: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + config = Config( + app=self.app, + host=self._host, + port=self._port, + log_level="warning", + access_log=False, + ) + self._server = Server(config) - self._running = True + self._running = True - try: self._loop.run_until_complete(self._server.serve()) - except Exception: - pass + except Exception as e: + printr.print( + f"[HUD Server] Server error: {type(e).__name__}: {e}", + color=LogType.ERROR, + server_only=True + ) finally: self._running = False + printr.print( + "[HUD Server] Server loop exited", + color=LogType.INFO, + server_only=True + ) async def stop(self): """Stop the HUD server.""" @@ -565,14 +646,14 @@ async def stop(self): # Wait for thread to finish if self._thread: - self._thread.join(timeout=5.0) + self._thread.join(timeout=hud_const.SERVER_SHUTDOWN_TIMEOUT) self._thread = None self._server = None self._loop = None printr.print( - "HUD Server stopped", + hud_const.LOG_SERVER_STOPPED, color=LogType.INFO, server_only=True ) diff --git a/hud_server/tests/README.md b/hud_server/tests/README.md deleted file mode 100644 index 440f4814f..000000000 --- a/hud_server/tests/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# HUD Server Tests - -This directory contains test suites for the HUD Server component. - -## Running Tests - -All tests should be executed from the project root directory (`wingman-ai/`) using the module syntax. - -### Quick Integration Test - -To run a quick connectivity and basic functionality check: - -```bash -python -m hud_server.tests.run_tests -``` - -### Running Specific Test Suites - -You can run specific functional test suites using command line arguments: - -```bash -# Run all test suites -python -m hud_server.tests.run_tests --all - -# Run message overlay tests -python -m hud_server.tests.run_tests --messages - -# Run progress bar tests -python -m hud_server.tests.run_tests --progress - -# Run persistent info display tests -python -m hud_server.tests.run_tests --persistent - -# Run chat window tests -python -m hud_server.tests.run_tests --chat - -# Run layout manager unit tests (no server needed) -python -m hud_server.tests.run_tests --layout - -# Run visual layout tests with actual HUD windows -python -m hud_server.tests.run_tests --layout-visual -``` - -## detailed Test Files - -- `run_tests.py`: Main entry point and test runner utility. -- `test_runner.py`: Contains `TestContext` manager for handling test sessions. -- `test_messages.py`: Tests for transient overlay messages (titles, content). -- `test_progress.py`: Tests for progress bar creation, updates, and removal. -- `test_persistent.py`: Tests for persistent info boxes (key-value pairs). -- `test_chat.py`: Tests for chat window visibility and content updates. -- `test_session.py`: Tests for session management (connection/disconnection). -- `test_multiuser.py`: Tests for handling multiple client connections. -- `test_layout.py`: Unit tests for the layout manager (automatic stacking and collision prevention). -- `test_layout_visual.py`: Visual integration tests that display actual HUD windows to verify layout. From 66b68dc763e14a6bdffa8fa6e6646d87f2d79e50 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 5 Feb 2026 14:15:04 +0100 Subject: [PATCH 05/27] Enhance layout manager with improved window positioning logic and updated documentation --- hud_server/layout/manager.py | 106 ++++++++++++++++------------------- 1 file changed, 48 insertions(+), 58 deletions(-) diff --git a/hud_server/layout/manager.py b/hud_server/layout/manager.py index 188baedcd..f57d41089 100644 --- a/hud_server/layout/manager.py +++ b/hud_server/layout/manager.py @@ -54,12 +54,16 @@ class LayoutMode(Enum): """Layout modes for window positioning.""" AUTO = "auto" # Automatic stacking based on anchor MANUAL = "manual" # User-specified x, y (no auto-adjustment) - HYBRID = "hybrid" # Auto-stack but allow offset adjustments + HYBRID = "hybrid" # Reserved for future use (currently behaves like AUTO) @dataclass class WindowInfo: - """Information about a window for layout calculations.""" + """ + Information about a window for layout calculations. + + Note: The 'group' field is reserved for future collision grouping features. + """ name: str anchor: Anchor = Anchor.TOP_LEFT mode: LayoutMode = LayoutMode.AUTO @@ -70,7 +74,7 @@ class WindowInfo: margin_y: int = 20 spacing: int = 10 # Spacing between stacked windows visible: bool = True - group: Optional[str] = None # Group name for collision grouping + group: Optional[str] = None # Reserved for future use # For manual/hybrid mode - user-specified offsets manual_x: Optional[int] = None @@ -299,108 +303,89 @@ def _compute_anchor_positions( if anchor == Anchor.CENTER: # Center mode: each window is centered, no stacking for window in windows: - if window.mode == LayoutMode.MANUAL: - x = window.manual_x if window.manual_x is not None else self._screen_width // 2 - window.width // 2 - y = window.manual_y if window.manual_y is not None else self._screen_height // 2 - window.height // 2 + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + x, y = window.manual_x, window.manual_y else: x = self._screen_width // 2 - window.width // 2 y = self._screen_height // 2 - window.height // 2 positions[window.name] = (x, y) return positions + # Helper to handle manual positioning + def get_position(window: WindowInfo, auto_x: int, auto_y: int) -> Tuple[int, int]: + if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: + return (window.manual_x, window.manual_y) + return (auto_x, auto_y) + # Calculate starting position based on anchor if anchor == Anchor.TOP_LEFT: # Stack downward from top-left current_y = windows[0].margin_y if windows else self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = window.margin_x - positions[window.name] = (x, current_y) - current_y += window.height + window.spacing + x = window.margin_x + positions[window.name] = get_position(window, x, current_y) + current_y += window.height + window.spacing elif anchor == Anchor.TOP_RIGHT: # Stack downward from top-right current_y = windows[0].margin_y if windows else self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = self._screen_width - window.width - window.margin_x - positions[window.name] = (x, current_y) - current_y += window.height + window.spacing + x = self._screen_width - window.width - window.margin_x + positions[window.name] = get_position(window, x, current_y) + current_y += window.height + window.spacing elif anchor == Anchor.BOTTOM_LEFT: # Stack upward from bottom-left current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = window.margin_x - y = current_y - window.height - positions[window.name] = (x, y) - current_y = y - window.spacing + x = window.margin_x + y = current_y - window.height + positions[window.name] = get_position(window, x, y) + current_y = y - window.spacing elif anchor == Anchor.BOTTOM_RIGHT: # Stack upward from bottom-right current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = self._screen_width - window.width - window.margin_x - y = current_y - window.height - positions[window.name] = (x, y) - current_y = y - window.spacing + x = self._screen_width - window.width - window.margin_x + y = current_y - window.height + positions[window.name] = get_position(window, x, y) + current_y = y - window.spacing elif anchor == Anchor.TOP_CENTER: # Stack downward from top-center current_y = windows[0].margin_y if windows else self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = self._screen_width // 2 - window.width // 2 - positions[window.name] = (x, current_y) - current_y += window.height + window.spacing + x = self._screen_width // 2 - window.width // 2 + positions[window.name] = get_position(window, x, current_y) + current_y += window.height + window.spacing elif anchor == Anchor.BOTTOM_CENTER: # Stack upward from bottom-center current_y = self._screen_height - windows[0].margin_y if windows else self._screen_height - self._default_margin for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = self._screen_width // 2 - window.width // 2 - y = current_y - window.height - positions[window.name] = (x, y) - current_y = y - window.spacing + x = self._screen_width // 2 - window.width // 2 + y = current_y - window.height + positions[window.name] = get_position(window, x, y) + current_y = y - window.spacing elif anchor == Anchor.LEFT_CENTER: # Stack downward from left-center (starting at vertical middle) total_height = sum(w.height + w.spacing for w in windows) - (windows[-1].spacing if windows else 0) current_y = (self._screen_height - total_height) // 2 for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = window.margin_x - positions[window.name] = (x, current_y) - current_y += window.height + window.spacing + x = window.margin_x + positions[window.name] = get_position(window, x, current_y) + current_y += window.height + window.spacing elif anchor == Anchor.RIGHT_CENTER: # Stack downward from right-center (starting at vertical middle) total_height = sum(w.height + w.spacing for w in windows) - (windows[-1].spacing if windows else 0) current_y = (self._screen_height - total_height) // 2 for window in windows: - if window.mode == LayoutMode.MANUAL and window.manual_x is not None and window.manual_y is not None: - positions[window.name] = (window.manual_x, window.manual_y) - else: - x = self._screen_width - window.width - window.margin_x - positions[window.name] = (x, current_y) - current_y += window.height + window.spacing + x = self._screen_width - window.width - window.margin_x + positions[window.name] = get_position(window, x, current_y) + current_y += window.height + window.spacing return positions @@ -477,7 +462,12 @@ def to_dict(self) -> Dict[str, dict]: return result @classmethod - def from_dict(cls, data: Dict[str, dict], screen_width: int = 1920, screen_height: int = 1080) -> "LayoutManager": + def from_dict( + cls, + data: Dict[str, dict], + screen_width: int = 1920, + screen_height: int = 1080 + ) -> "LayoutManager": """Create layout manager from dictionary.""" manager = cls(screen_width=screen_width, screen_height=screen_height) for name, window_data in data.items(): From f2f8f5cf799bd37f23088db97cb903fe0b9ef34a Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 5 Feb 2026 15:41:19 +0100 Subject: [PATCH 06/27] Add fixed height option for HUD cells and implement interactive Snake game test --- hud_server/README.md | 1 + hud_server/models.py | 6 +- hud_server/overlay/overlay.py | 8 +- hud_server/tests/run_tests.py | 5 + hud_server/tests/test_chat.py | 38 ++- hud_server/tests/test_snake.py | 551 +++++++++++++++++++++++++++++++++ 6 files changed, 596 insertions(+), 13 deletions(-) create mode 100644 hud_server/tests/test_snake.py diff --git a/hud_server/README.md b/hud_server/README.md index 4dd479ea1..19e451ed0 100644 --- a/hud_server/README.md +++ b/hud_server/README.md @@ -491,6 +491,7 @@ python -m hud_server.tests.run_tests --chat # Run chat tests python -m hud_server.tests.run_tests --unicode # Run Unicode/emoji stress tests python -m hud_server.tests.run_tests --layout # Run layout manager unit tests (no server needed) python -m hud_server.tests.run_tests --layout-visual # Run visual layout tests with actual HUD windows +python -m hud_server.tests.run_tests --snake # You know this one ... ``` ## Troubleshooting diff --git a/hud_server/models.py b/hud_server/models.py index 98c3d0dc5..1cfb91ec5 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -50,8 +50,10 @@ class HudGroupProps(BaseModel): # Position & Size x: int = Field(default=20, ge=-5000, le=10000) y: int = Field(default=20, ge=-5000, le=10000) - width: int = Field(default=400, ge=100, le=3840) - max_height: int = Field(default=600, ge=100, le=2160) + width: int = Field(default=400, ge=10, le=3840) + height: Optional[int] = Field(default=None, ge=10, le=2160) + """Fixed height in pixels. If set, overrides dynamic height calculation.""" + max_height: int = Field(default=600, ge=10, le=2160) # Colors (hex format) bg_color: str = Field(default="#1e212b", pattern=r"^#[0-9a-fA-F]{6}$") diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index d818e4421..f6fa36d04 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -843,9 +843,13 @@ def _draw_message_window(self, name: str, win: Dict): self._draw_loading(draw, temp, padding, y, width - padding * 2, loading_color) y += 24 - # Calculate final height + # Calculate final height - use fixed height if specified + fixed_height = props.get('height') bottom_padding = padding - 4 - final_h = min(max(60, y + bottom_padding), max_height) + if fixed_height is not None: + final_h = int(fixed_height) + else: + final_h = min(max(60, y + bottom_padding), max_height) # Create final canvas - ALWAYS create fresh to prevent ghosting old_canvas = win.get('canvas') diff --git a/hud_server/tests/run_tests.py b/hud_server/tests/run_tests.py index 5e4cc89c2..8f8f50db2 100644 --- a/hud_server/tests/run_tests.py +++ b/hud_server/tests/run_tests.py @@ -11,6 +11,7 @@ python -m hud_server.tests.run_tests --unicode # Run Unicode/emoji stress tests python -m hud_server.tests.run_tests --layout # Run layout manager unit tests (no server needed) python -m hud_server.tests.run_tests --layout-visual # Run visual layout tests with actual HUD windows + python -m hud_server.tests.run_tests --snake # Run the Snake game (interactive, 2 min) """ import sys import asyncio @@ -134,6 +135,10 @@ def main(): # Visual layout tests need the full server from hud_server.tests.test_layout_visual import main as layout_visual_main asyncio.run(layout_visual_main()) + elif arg == "snake": + # Snake game - interactive fun test + from hud_server.tests.test_snake import run_snake_test + asyncio.run(run_snake_test()) elif arg in ["messages", "progress", "persistent", "chat", "unicode", "all"]: asyncio.run(run_test_suite(arg)) elif arg == "help": diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index a4644adbc..9a41e0964 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -383,17 +383,37 @@ async def test_chat_game(session: TestSession): # Run All Tests # ============================================================================= +async def cleanup_all_chats(session: TestSession): + """Clean up all chat windows created during tests.""" + chat_names = [ + f"chat_{session.session_id}", + f"md_chat_{session.session_id}", + f"conv_{session.session_id}", + f"autohide_{session.session_id}", + f"overflow_{session.session_id}", + ] + for chat_name in chat_names: + try: + await session.delete_chat_window(chat_name) + except Exception: + pass # Ignore errors - window may not exist + + async def run_all_chat_tests(session: TestSession): """Run all chat tests.""" - await test_chat_basic(session) - await asyncio.sleep(1) - await test_chat_markdown(session) - await asyncio.sleep(1) - await test_chat_auto_hide(session) - await asyncio.sleep(1) - await test_chat_overflow(session) - await asyncio.sleep(1) - await test_chat_wingman(session) + try: + await test_chat_basic(session) + await asyncio.sleep(1) + await test_chat_markdown(session) + await asyncio.sleep(1) + await test_chat_auto_hide(session) + await asyncio.sleep(1) + await test_chat_overflow(session) + await asyncio.sleep(1) + await test_chat_wingman(session) + finally: + # Always clean up chats when finished + await cleanup_all_chats(session) if __name__ == "__main__": diff --git a/hud_server/tests/test_snake.py b/hud_server/tests/test_snake.py new file mode 100644 index 000000000..ed396900b --- /dev/null +++ b/hud_server/tests/test_snake.py @@ -0,0 +1,551 @@ +# -*- coding: utf-8 -*- +""" +Test Snake - Interactive Snake game using the HUD Server. + +A fun Snake game implementation that uses: +- Each grid cell is its own HUD window positioned across the screen +- HUDs are created on-demand (only for snake and food, not empty cells) +- Manual window placement to create a full-screen grid +- Keyboard controls (arrow keys) +- HUD messages for start/game over screens and stats +- Auto-ends after 2 minutes + +Usage: + python -m hud_server.tests.test_snake +""" + +import asyncio +import time +import random +from enum import Enum +from hud_server.tests.test_session import TestSession + +try: + import keyboard.keyboard as keyboard +except ImportError: + import keyboard + + +# ============================================================================= +# Game Constants +# ============================================================================= + +# Screen configuration (assumed 1920x1080, adjust if needed) +SCREEN_WIDTH = 1920 +SCREEN_HEIGHT = 1080 + +# Cell configuration +CELL_SIZE = 32 # Size of each HUD window in pixels +CELL_PADDING = 2 # Padding between cells + +# Calculate grid size to fit screen (leaving margins for stats panel) +MARGIN_TOP = 80 # Space for stats +MARGIN_BOTTOM = 50 +MARGIN_LEFT = 50 +MARGIN_RIGHT = 200 # Space for stats panel on right + +# Calculate playable area +PLAYABLE_WIDTH = SCREEN_WIDTH - MARGIN_LEFT - MARGIN_RIGHT +PLAYABLE_HEIGHT = SCREEN_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM + +# Grid dimensions (auto-calculated) +GRID_WIDTH = PLAYABLE_WIDTH // (CELL_SIZE + CELL_PADDING) +GRID_HEIGHT = PLAYABLE_HEIGHT // (CELL_SIZE + CELL_PADDING) + +# Screen offset (top-left of play area) +SCREEN_OFFSET_X = MARGIN_LEFT +SCREEN_OFFSET_Y = MARGIN_TOP + +# Game timing +GAME_DURATION = 120 # 2 minutes +INITIAL_SPEED = 0.15 # seconds between moves +SPEED_INCREMENT = 0.005 # speed increase per food eaten +MIN_SPEED = 0.05 # fastest possible speed + +# Cell types for display +CELL_EMPTY = "empty" +CELL_SNAKE_HEAD = "snake_head" +CELL_SNAKE_BODY = "snake_body" +CELL_FOOD = "food" +CELL_BORDER = "border" + +# Colors for different cell types +COLORS = { + CELL_EMPTY: "#1a1a2e", + CELL_SNAKE_HEAD: "#00ff00", + CELL_SNAKE_BODY: "#00aa00", + CELL_FOOD: "#ff3333", + CELL_BORDER: "#0066cc", +} + +# Colors +COLOR_GAME = "#00ff00" +COLOR_GAME_OVER = "#ff0000" + + +# ============================================================================= +# Game Logic +# ============================================================================= + +class Direction(Enum): + UP = (0, -1) + DOWN = (0, 1) + LEFT = (-1, 0) + RIGHT = (1, 0) + + +class SnakeGame: + """Snake game logic.""" + + def __init__(self, width: int = GRID_WIDTH, height: int = GRID_HEIGHT): + self.width = width + self.height = height + self.reset() + + def reset(self): + """Reset the game state.""" + start_x = self.width // 2 + start_y = self.height // 2 + self.snake = [(start_x, start_y), (start_x - 1, start_y), (start_x - 2, start_y)] + self.direction = Direction.RIGHT + self.next_direction = Direction.RIGHT + self.food = self._spawn_food() + self.score = 0 + self.game_over = False + self.game_over_reason = "" + + def _spawn_food(self) -> tuple[int, int]: + """Spawn food at a random empty location.""" + while True: + x = random.randint(0, self.width - 1) + y = random.randint(0, self.height - 1) + if (x, y) not in self.snake: + return (x, y) + + def set_direction(self, direction: Direction): + """Set the next direction (will be applied on next update).""" + current = self.direction + if (direction == Direction.UP and current != Direction.DOWN) or \ + (direction == Direction.DOWN and current != Direction.UP) or \ + (direction == Direction.LEFT and current != Direction.RIGHT) or \ + (direction == Direction.RIGHT and current != Direction.LEFT): + self.next_direction = direction + + def update(self): + """Update the game state (move snake, check collisions, etc.).""" + if self.game_over: + return + + self.direction = self.next_direction + head_x, head_y = self.snake[0] + dx, dy = self.direction.value + new_head = (head_x + dx, head_y + dy) + + # Check wall collision + if new_head[0] < 0 or new_head[0] >= self.width or \ + new_head[1] < 0 or new_head[1] >= self.height: + self.game_over = True + self.game_over_reason = "Hit the wall!" + return + + # Check self collision + if new_head in self.snake: + self.game_over = True + self.game_over_reason = "Bit yourself!" + return + + self.snake.insert(0, new_head) + + if new_head == self.food: + self.score += 1 + self.food = self._spawn_food() + else: + self.snake.pop() + + +# ============================================================================= +# HUD Cell Management - On-demand creation +# ============================================================================= + +def get_cell_position(x: int, y: int) -> tuple[int, int]: + """Calculate screen position for a grid cell. Supports negative coords for borders.""" + screen_x = SCREEN_OFFSET_X + (x * (CELL_SIZE + CELL_PADDING)) + screen_y = SCREEN_OFFSET_Y + (y * (CELL_SIZE + CELL_PADDING)) + return (screen_x, screen_y) + + +def get_cell_group_name(x: int, y: int) -> str: + """Get the HUD group name for a cell. Handles negative coords for borders.""" + # Use 'n' prefix for negative numbers to avoid invalid group names + x_str = f"n{abs(x)}" if x < 0 else str(x) + y_str = f"n{abs(y)}" if y < 0 else str(y) + return f"snake_cell_{x_str}_{y_str}" + + +# Track which cells currently have HUDs +_active_cell_huds: set = set() + + +async def show_cell(session: TestSession, x: int, y: int, cell_type: str): + """Show or update a cell HUD. Creates it if it doesn't exist.""" + if not session._client: + return + + group_name = get_cell_group_name(x, y) + screen_x, screen_y = get_cell_position(x, y) + + await session._client.show_message( + group_name=group_name, + title=" ", + content=" ", # Need non-empty content to keep HUD visible + color=COLORS[cell_type], + props={ + "layout_mode": "manual", + "x": screen_x, + "y": screen_y, + "width": CELL_SIZE, + "height": CELL_SIZE, + "bg_color": COLORS[cell_type], + "opacity": 1.0, + "border_radius": 4, + "font_size": 1, + "content_padding": 0, + "disable_animations": True, + "disable_transitions": True, + "duration": 120, # 2 minutes - same as game duration + } + ) + _active_cell_huds.add((x, y)) + + +async def hide_cell(session: TestSession, x: int, y: int): + """Hide/delete a cell HUD.""" + if not session._client: + return + + if (x, y) in _active_cell_huds: + group_name = get_cell_group_name(x, y) + await session._client.delete_group(group_name) + _active_cell_huds.discard((x, y)) + + +async def cleanup_all_cells(session: TestSession): + """Remove all active cell HUDs.""" + if not session._client: + return + + for (x, y) in list(_active_cell_huds): + group_name = get_cell_group_name(x, y) + await session._client.delete_group(group_name) + + _active_cell_huds.clear() + + # Also clean up stats + await session._client.delete_group("snake_stats") + + +async def render_initial_state(session: TestSession, game: SnakeGame): + """Render the initial game state - borders, snake and food.""" + # Show borders first + await render_borders(session, game) + + # Show snake head + await show_cell(session, game.snake[0][0], game.snake[0][1], CELL_SNAKE_HEAD) + + # Show snake body + for pos in game.snake[1:]: + await show_cell(session, pos[0], pos[1], CELL_SNAKE_BODY) + + # Show food + await show_cell(session, game.food[0], game.food[1], CELL_FOOD) + + +async def render_borders(session: TestSession, game: SnakeGame): + """Render the border cells around the playable area.""" + # Top border (row -1) + for x in range(-1, game.width + 1): + await show_cell(session, x, -1, CELL_BORDER) + + # Bottom border (row height) + for x in range(-1, game.width + 1): + await show_cell(session, x, game.height, CELL_BORDER) + + # Left border (column -1) + for y in range(game.height): + await show_cell(session, -1, y, CELL_BORDER) + + # Right border (column width) + for y in range(game.height): + await show_cell(session, game.width, y, CELL_BORDER) + + +async def update_display(session: TestSession, old_states: dict, new_states: dict): + """Update only the cells that changed.""" + all_positions = set(old_states.keys()) | set(new_states.keys()) + + for pos in all_positions: + old_type = old_states.get(pos) + new_type = new_states.get(pos) + + if old_type != new_type: + if new_type is None: + # Cell became empty - hide it + await hide_cell(session, pos[0], pos[1]) + else: + # Cell has content - show/update it + await show_cell(session, pos[0], pos[1], new_type) + + +def get_game_state(game: SnakeGame) -> dict: + """Get current state of all non-empty cells.""" + states = {} + if game.snake: + states[game.snake[0]] = CELL_SNAKE_HEAD + for pos in game.snake[1:]: + states[pos] = CELL_SNAKE_BODY + states[game.food] = CELL_FOOD + return states + + +# ============================================================================= +# Game Screens +# ============================================================================= + +async def show_start_screen(session: TestSession): + """Display the game start screen.""" + start_message = f"""# 🐍 FULL-SCREEN SNAKE GAME 🐍 + +## How to Play +- Use **Arrow Keys** to control the snake +- Eat 🍎 to grow longer and score points +- Avoid hitting the blue borders and yourself +- Game lasts **2 minutes** + +## Controls +- **↑ ↓ ← →** : Move snake +- **SPACE** : Start game + +## Grid Size: {GRID_WIDTH} x {GRID_HEIGHT} + +**Press SPACE to begin!**""" + + await session.draw_assistant_message(start_message) + + +async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, speed: float, force: bool = False): + """Display stats overlay - only updates if changed.""" + if not session._client: + return + + time_left = int(GAME_DURATION - elapsed) + + stats_message = f"""**Score:** {game.score} | **Length:** {len(game.snake)} | **Time:** {time_left}s""" + + await session._client.show_message( + group_name="snake_stats", + title="🎮 Snake", + content=stats_message, + color=COLOR_GAME, + props={ + "anchor": "top_right", + "priority": 100, + "layout_mode": "auto", + "width": 350, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "opacity": 0.95, + "border_radius": 8, + "font_size": 14, + "content_padding": 12, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 120, # 2 minutes - same as game duration + } + ) + + +async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: float): + """Display the game over screen.""" + if game.score >= 30: + result_emoji, rating = "🏆", "LEGENDARY!" + elif game.score >= 20: + result_emoji, rating = "🌟", "AMAZING!" + elif game.score >= 10: + result_emoji, rating = "🎉", "GREAT!" + elif game.score >= 5: + result_emoji, rating = "👍", "GOOD!" + else: + result_emoji, rating = "😅", "NICE TRY!" + + game_over_message = f"""# {result_emoji} GAME OVER {result_emoji} + +## {rating} + +### Final Stats +- **Score:** {game.score} +- **Final Length:** {len(game.snake)} +- **Time Played:** {int(elapsed)}s / {GAME_DURATION}s +- **Reason:** {game.game_over_reason} + +--- + +*Press any key to exit*""" + + await session.draw_assistant_message(game_over_message) + + +# ============================================================================= +# Main Game Loop +# ============================================================================= + +async def test_snake_game(session: TestSession): + """Run the interactive Snake game.""" + print(f"[{session.name}] Starting Full-Screen Snake Game...") + + game = SnakeGame() + + # Show start screen and wait for SPACE + await show_start_screen(session) + print(f"[{session.name}] Press SPACE to start...") + + while not keyboard.is_pressed('space'): + await asyncio.sleep(0.1) + + print(f"[{session.name}] Game started!") + + # Hide start screen + await session.hide() + + # Render initial game state (just snake + food) + await render_initial_state(session, game) + + # Show initial stats + await show_stats(session, game, 0, INITIAL_SPEED) + + # Set up keyboard handlers + game_running = True + + def on_arrow_up(e): + if game_running: + game.set_direction(Direction.UP) + + def on_arrow_down(e): + if game_running: + game.set_direction(Direction.DOWN) + + def on_arrow_left(e): + if game_running: + game.set_direction(Direction.LEFT) + + def on_arrow_right(e): + if game_running: + game.set_direction(Direction.RIGHT) + + keyboard.on_press_key('up', on_arrow_up) + keyboard.on_press_key('down', on_arrow_down) + keyboard.on_press_key('left', on_arrow_left) + keyboard.on_press_key('right', on_arrow_right) + + start_time = time.time() + current_speed = INITIAL_SPEED + last_update = start_time + last_stats = {"score": -1, "time": -1} + elapsed = 0.0 + + try: + while game_running: + current_time = time.time() + elapsed = current_time - start_time + + # Check time limit + if elapsed >= GAME_DURATION: + game.game_over = True + game.game_over_reason = "Time's up!" + break + + # Update game at current speed + if current_time - last_update >= current_speed: + old_states = get_game_state(game) + old_score = game.score + + game.update() + last_update = current_time + + if game.game_over: + game_running = False + break + + new_states = get_game_state(game) + + # Speed up on food eaten + if game.score > old_score: + current_speed = max(MIN_SPEED, INITIAL_SPEED - (game.score * SPEED_INCREMENT)) + + # Update only changed cells + await update_display(session, old_states, new_states) + + # Update stats only when changed + current_stats = {"score": game.score, "time": int(GAME_DURATION - elapsed)} + if current_stats != last_stats: + await show_stats(session, game, elapsed, current_speed) + last_stats = current_stats.copy() + + await asyncio.sleep(0.01) + + # Cleanup and show game over + await cleanup_all_cells(session) + await show_game_over_screen(session, game, elapsed) + await asyncio.sleep(5) + + finally: + keyboard.unhook_all() + await session.hide() + print(f"[{session.name}] Snake game ended. Final score: {game.score}") + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +async def run_snake_test(): + """Run the Snake game test.""" + from hud_server.tests.test_runner import TestContext + + print("=" * 60) + print("SNAKE GAME TEST") + print("=" * 60) + + session_config = { + "name": "Snake", + "anchor": "top_left", + "priority": 50, + "persistent_anchor": "top_left", + "persistent_priority": 40, + "layout_mode": "auto", + "hud_width": 500, + "persistent_width": 500, + "hud_max_height": 900, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "user_color": "#4cd964", + "opacity": 0.95, + "border_radius": 16, + "font_size": 14, + "content_padding": 20, + "typewriter_effect": False, + } + + async with TestContext(session_ids=[1]) as ctx: + session = ctx.sessions[0] + session.config = session_config + session.name = "Snake" + + print("HUD Server started. Get ready to play Snake! 🐍\n") + await test_snake_game(session) + + +if __name__ == "__main__": + asyncio.run(run_snake_test()) From 06516cd40d2f35f314a22e65dd509d26b7b09d78 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:21:55 +0100 Subject: [PATCH 07/27] HUD: Performance caching, legacy cleanup, fade-out layout fix, alpha channel support (#10) * Initial plan * Implement render caching for HUD performance optimization Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Address code review feedback for HUD caching Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Mark deprecated legacy methods in overlay.py, document unified window system Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Add deprecation warnings to legacy methods in overlay.py Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Improve deprecated methods documentation clarity Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Optimize HUD rendering by removing redundant z-order updates for windows * Remove legacy HUD code and fix cache issues causing ghosting Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Add docstring to _init_fonts and complete code review Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Fix layout manager releasing slot before fade-out completes Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Refactor chat window handling and improve HUD rendering performance * Remove unused legacy chat window code and fix chat window integration Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Enhance Snake game with gradient snake body, combo system, and golden apples * Enable alpha channel support in HUD background colors Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Address code review feedback for alpha channel support Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Add emoji support and scroll fade effect to chat overlay rendering --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> Co-authored-by: Jan Winkler --- hud_server/constants.py | 7 + hud_server/models.py | 10 +- hud_server/overlay/overlay.py | 2168 ++++++++++-------------------- hud_server/rendering/markdown.py | 219 ++- hud_server/tests/test_chat.py | 39 +- hud_server/tests/test_runner.py | 71 +- hud_server/tests/test_snake.py | 821 +++++++++-- skills/hud/default_config.yaml | 2 +- skills/hud/main.py | 4 +- 9 files changed, 1757 insertions(+), 1584 deletions(-) diff --git a/hud_server/constants.py b/hud_server/constants.py index 6f859fdfb..101390fc6 100644 --- a/hud_server/constants.py +++ b/hud_server/constants.py @@ -38,6 +38,13 @@ # Cache Limits MAX_IMAGE_CACHE_SIZE = 20 MAX_FONT_CACHE_SIZE = 10 +MAX_PROGRESS_TRACK_CACHE_SIZE = 20 +MAX_PROGRESS_GRADIENT_CACHE_SIZE = 20 +MAX_CORNER_CACHE_SIZE = 30 +MAX_LOADING_BAR_CACHE_SIZE = 10 +MAX_INLINE_TOKEN_CACHE_SIZE = 100 +MAX_TEXT_WRAP_CACHE_SIZE = 200 +MAX_TEXT_SIZE_CACHE_SIZE = 2000 # Colors (hex format) DEFAULT_BG_COLOR = "#1e212b" diff --git a/hud_server/models.py b/hud_server/models.py index 1cfb91ec5..d48e31250 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -55,11 +55,11 @@ class HudGroupProps(BaseModel): """Fixed height in pixels. If set, overrides dynamic height calculation.""" max_height: int = Field(default=600, ge=10, le=2160) - # Colors (hex format) - bg_color: str = Field(default="#1e212b", pattern=r"^#[0-9a-fA-F]{6}$") - text_color: str = Field(default="#f0f0f0", pattern=r"^#[0-9a-fA-F]{6}$") - accent_color: str = Field(default="#00aaff", pattern=r"^#[0-9a-fA-F]{6}$") - title_color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") + # Colors (hex format - supports #RRGGBB or #RRGGBBAA with alpha channel) + bg_color: str = Field(default="#1e212b", pattern=r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$") + text_color: str = Field(default="#f0f0f0", pattern=r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$") + accent_color: str = Field(default="#00aaff", pattern=r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$") + title_color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$") # Visual opacity: float = Field(default=0.85, ge=0.0, le=1.0) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index f6fa36d04..02e6dfa01 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -15,12 +15,8 @@ import math import re import ctypes -from ctypes import wintypes -from typing import Tuple, Dict, List, Optional +from typing import Tuple, Dict, Optional import traceback -import io -import urllib.request -import urllib.error # PIL for rendering try: @@ -34,17 +30,22 @@ ImageChops = None from hud_server.rendering.markdown import MarkdownRenderer -from hud_server.platform import win32 from hud_server.platform.win32 import ( user32, gdi32, kernel32, - WNDCLASSEXW, BITMAPINFOHEADER, BITMAPINFO, MSG, POINT, - GWL_EXSTYLE, WS_POPUP, WS_EX_LAYERED, WS_EX_TRANSPARENT, WS_EX_TOPMOST, WS_EX_TOOLWINDOW, - WS_EX_NOACTIVATE, LWA_ALPHA, LWA_COLORKEY, SWP_NOSIZE, SWP_NOMOVE, SWP_SHOWWINDOW, - SWP_NOACTIVATE, SWP_ASYNCWINDOWPOS, SRCCOPY, DIB_RGB_COLORS, BI_RGB, + BITMAPINFOHEADER, BITMAPINFO, MSG, + WS_POPUP, WS_EX_LAYERED, WS_EX_TRANSPARENT, WS_EX_TOPMOST, WS_EX_TOOLWINDOW, + WS_EX_NOACTIVATE, LWA_ALPHA, LWA_COLORKEY, SWP_SHOWWINDOW, + SWP_NOACTIVATE, SRCCOPY, DIB_RGB_COLORS, BI_RGB, SW_SHOWNOACTIVATE, HWND_TOPMOST, PM_REMOVE, _ensure_window_class, _class_name ) from hud_server.layout import LayoutManager, Anchor, LayoutMode +from hud_server.constants import ( + MAX_PROGRESS_TRACK_CACHE_SIZE, + MAX_PROGRESS_GRADIENT_CACHE_SIZE, + MAX_CORNER_CACHE_SIZE, + MAX_LOADING_BAR_CACHE_SIZE, +) class HeadsUpOverlay: """HUD Overlay with sophisticated Markdown rendering. @@ -134,69 +135,53 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, self._progress_transition_duration = 0.5 # ===================================================================== - # LEGACY COMPATIBILITY LAYER + # SHARED RESOURCES (used by unified window system) # ===================================================================== - # These are kept for backward compatibility with code that doesn't use groups. - # They point to the "_default" group windows. - self.is_loading = False - self.loading_color = (0, 170, 255) - self.current_message = None - self.display_props = dict(self._default_props) - self.target_opacity = 216 - self.current_opacity = 0 - self.fade_state = 0 - self.min_display_time = 0 - self.typewriter_active = False - self.typewriter_char_count = 0 - self.last_typewriter_update = 0 - - # Legacy persistent infos (global, merged from all groups for backward compat) - self.persistent_infos = {} - self.persistent_fade_state = 0 - self.persistent_opacity = 0 - self._progress_animations = {} - self._persistent_render_time = 0.0 - - # Legacy Win32 resources (for _default group, created in run()) - self.hwnd = None - self.window_dc = None - self.mem_dc = None - self.dib_bitmap = None - self.dib_bits = None - self.old_bitmap = None - self.dib_width = 0 - self.dib_height = 0 - - self.hwnd_persistent = None - self.window_dc_persistent = None - self.mem_dc_persistent = None - self.dib_bitmap_persistent = None - self.dib_bits_persistent = None - self.old_bitmap_persistent = None - self.dib_width_persistent = 0 - self.dib_height_persistent = 0 - - # Legacy PIL resources - self.canvas = None - self.canvas_persistent = None - self.temp_image = None - self.temp_draw = None self.fonts = {} self.image_cache = {} self.md_renderer = None - self.last_render_state = None - self.last_render_state_persistent = None - self.current_blocks = None - self.canvas_dirty = False - self.canvas_persistent_dirty = False - - # Legacy chat window state (will be migrated to unified system) - self._chat_windows: Dict[str, Dict] = {} - self._chat_window_dirty: Dict[str, bool] = {} - self._chat_canvases: Dict[str, Image.Image] = {} - self._chat_hwnds: Dict[str, int] = {} - self._chat_window_dcs: Dict[str, tuple] = {} - self._chat_last_render_state: Dict[str, tuple] = {} + + # ===================================================================== + # RENDER CACHING SYSTEM + # ===================================================================== + # Cache for pre-rendered components to reduce CPU load + # Each cache entry contains: {'image': PIL.Image, 'params': tuple} + # + # Progress bar track cache: stores empty progress bar backgrounds + # Key: (width, height, bg_color) -> cached track image + self._progress_track_cache: Dict[tuple, Image.Image] = {} + # Max cache entries for progress tracks + self._max_progress_track_cache = MAX_PROGRESS_TRACK_CACHE_SIZE + + # Progress bar fill gradient cache: stores gradient overlays + # Key: (width, height, fill_color) -> cached gradient overlay + self._progress_gradient_cache: Dict[tuple, Image.Image] = {} + # Max cache entries for gradients + self._max_progress_gradient_cache = MAX_PROGRESS_GRADIENT_CACHE_SIZE + + # Rounded rectangle corner cache: stores pre-rendered corners at various radii + # Key: (radius, scale, bg_color) -> cached corner images + self._corner_cache: Dict[tuple, Dict[str, Image.Image]] = {} + # Max cache entries for corners + self._max_corner_cache = MAX_CORNER_CACHE_SIZE + + # Loading bar element cache: stores pre-rendered loading bar elements + # Key: (bar_width, max_height, color) -> cached bar surface + self._loading_bar_cache: Dict[tuple, Image.Image] = {} + # Max cache entries for loading bars + self._max_loading_bar_cache = MAX_LOADING_BAR_CACHE_SIZE + + # Render statistics for monitoring (optional debugging) + self._render_stats = { + 'track_cache_hits': 0, + 'track_cache_misses': 0, + 'gradient_cache_hits': 0, + 'gradient_cache_misses': 0, + 'corner_cache_hits': 0, + 'corner_cache_misses': 0, + 'loading_cache_hits': 0, + 'loading_cache_misses': 0, + } # ===================================================================== # LAYOUT MANAGER @@ -209,6 +194,45 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, default_spacing=self._layout_spacing, ) + # ========================================================================= + # RENDER CACHE MANAGEMENT + # ========================================================================= + + def get_render_cache_stats(self) -> Dict[str, int]: + """Get render cache statistics for monitoring performance. + + Returns a dictionary with cache hit/miss counts for each cache type. + Useful for debugging and performance monitoring. + """ + return dict(self._render_stats) + + def clear_render_caches(self): + """Clear all render caches. + + Call this when memory pressure is high or when visual styles change + significantly. Normally caches auto-evict when full. + """ + self._progress_track_cache.clear() + self._progress_gradient_cache.clear() + self._corner_cache.clear() + self._loading_bar_cache.clear() + + # Reset statistics + for key in self._render_stats: + self._render_stats[key] = 0 + + def get_render_cache_sizes(self) -> Dict[str, int]: + """Get current sizes of render caches. + + Returns a dictionary with the number of entries in each cache. + """ + return { + 'progress_track_cache': len(self._progress_track_cache), + 'progress_gradient_cache': len(self._progress_gradient_cache), + 'corner_cache': len(self._corner_cache), + 'loading_bar_cache': len(self._loading_bar_cache), + } + # ========================================================================= # UNIFIED WINDOW MANAGEMENT # ========================================================================= @@ -432,6 +456,8 @@ def _destroy_window(self, name: str): def _destroy_group_windows(self, group: str): """Destroy all windows for a group.""" + # Handle all unified windows (message, persistent, chat) + # Chat windows use the group name as the chat name names_to_destroy = [ name for name in self._windows if self._windows[name].get('group') == group @@ -466,7 +492,10 @@ def _update_all_windows(self): self._draw_persistent_window(name, win) # Don't blit yet - wait for collision check - # Note: Chat windows use the legacy system for now + elif win_type == self.WINDOW_TYPE_CHAT: + self._update_chat_window(name, win) + self._draw_chat_window(name, win) + self._blit_window(name, win) except Exception as e: self._report_exception(f"update_window_{name}", e) @@ -553,9 +582,8 @@ def _update_message_window(self, name: str, win: Dict): win['fade_state'] = 3 # Clear message so has_content becomes False and fade-out can proceed win['current_message'] = None - # Notify layout manager immediately - window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, win.get('group', 'default')) - self._layout_manager.set_window_visible(window_name, False) + # Note: Don't release layout slot yet - window still visible during fade-out + # Layout slot will be released when fade completes (opacity reaches 0) def _update_persistent_window(self, name: str, win: Dict): """Update persistent window state (progress animations, expiry, etc.).""" @@ -645,9 +673,10 @@ def _update_window_fade(self, win: Dict, has_content: bool): # Update layout manager visibility when fade state changes window_name = self._get_window_name(win.get('type', 'message'), win.get('group', 'default')) if old_fade_state != win['fade_state']: - # Window is visible for layout purposes only when fading in (1) or fully visible (2) - # When fading out (3) or hidden (0), it should NOT take up layout space - is_visible = win['fade_state'] in (1, 2) + # Window is visible for layout purposes when fading in (1), fully visible (2), OR fading out (3) + # This prevents new windows from taking a slot while fade-out animation is in progress + # Slot is only released when fully hidden (0) + is_visible = win['fade_state'] in (1, 2, 3) self._layout_manager.set_window_visible(window_name, is_visible) if win['fade_state'] == 1: # Fade in @@ -680,10 +709,18 @@ def _draw_message_window(self, name: str, win: Dict): is_loading = win.get('is_loading', False) if not current_message and not is_loading: + # Clear render state and canvas when there's no content + # This ensures old content doesn't persist + if win.get('last_render_state') is not None: + win['last_render_state'] = None + win['canvas'] = None + win['canvas_dirty'] = False return props = win.get('props', {}) - bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + bg_rgba = self._parse_hex_color_with_alpha(props.get('bg_color', '#1e212b')) + bg = bg_rgba[:3] # RGB portion for compatibility + bg_alpha = bg_rgba[3] # Alpha channel from hex color text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) @@ -751,7 +788,8 @@ def _draw_message_window(self, name: str, win: Dict): title = self._strip_emotions(title) font_bold = self.fonts.get('bold', self.fonts.get('normal', self.fonts.get('regular'))) if font_bold: - draw.text((padding, y), title, fill=accent + (255,), font=font_bold) + # Use emoji-aware rendering for title + self._render_text_with_emoji(draw, title, padding, y, accent + (255,), font_bold, emoji_y_offset=3) try: bbox = font_bold.getbbox(title) y += bbox[3] - bbox[1] + 12 @@ -865,9 +903,9 @@ def _draw_message_window(self, name: str, win: Dict): final_draw = ImageDraw.Draw(canvas) # Draw solid background first (covers everything) final_draw.rectangle([0, 0, width, final_h], fill=(255, 0, 255, 255)) - # Then draw the rounded rectangle on top + # Then draw the rounded rectangle on top with user-specified alpha final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, - fill=bg + (255,), outline=(55, 62, 74)) + fill=bg + (bg_alpha,), outline=(55, 62, 74)) crop_height = min(final_h, temp.height) crop = temp.crop((0, 0, width, crop_height)) @@ -898,10 +936,18 @@ def _draw_persistent_window(self, name: str, win: Dict): """Draw content for a persistent window.""" items = win.get('items', {}) if not items: + # Clear render state and canvas when there are no items + # This ensures old content doesn't persist + if win.get('last_render_state') is not None: + win['last_render_state'] = None + win['canvas'] = None + win['canvas_dirty'] = False return props = win.get('props', {}) - bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) + bg_rgba = self._parse_hex_color_with_alpha(props.get('bg_color', '#1e212b')) + bg = bg_rgba[:3] # RGB portion for compatibility + bg_alpha = bg_rgba[3] # Alpha channel from hex color text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) @@ -923,7 +969,7 @@ def _draw_persistent_window(self, name: str, win: Dict): # Include visual props in state hash for real-time config updates visual_props_hash = ( width, radius, padding, - bg, text_color, accent, + bg, bg_alpha, text_color, accent, props.get('opacity', 0.85), props.get('font_size', 16), props.get('font_family', ''), @@ -1016,7 +1062,7 @@ def _draw_persistent_window(self, name: str, win: Dict): title_text = info.get('title', title) max_title_w = width - (padding * 2) - timer_w if font_bold: - self._render_text_with_emoji(draw, title_text, padding, y, accent + (255,), font_bold) + self._render_text_with_emoji(draw, title_text, padding, y, accent + (255,), font_bold, emoji_y_offset=3) y += 22 # Progress bar @@ -1081,9 +1127,9 @@ def _draw_persistent_window(self, name: str, win: Dict): final_draw = ImageDraw.Draw(canvas) # Draw solid background first (covers everything) final_draw.rectangle([0, 0, width, final_h], fill=(255, 0, 255, 255)) - # Then draw the rounded rectangle on top + # Then draw the rounded rectangle on top with user-specified alpha final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, - fill=bg + (255,), outline=(55, 62, 74)) + fill=bg + (bg_alpha,), outline=(55, 62, 74)) crop = temp.crop((0, 0, width, final_h)) # Composite the content onto the background properly @@ -1107,6 +1153,233 @@ def _draw_persistent_window(self, name: str, win: Dict): y_pos = int(props.get('y', 20)) user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) + def _update_chat_window(self, name: str, win: Dict): + """Update chat window state (fade logic and auto-hide).""" + now = time.time() + props = win.get('props', {}) + auto_hide = props.get('auto_hide', False) + auto_hide_delay = props.get('auto_hide_delay', 10.0) + + # Check auto-hide + if auto_hide and win.get('messages') and win['fade_state'] == 2: + if now - win.get('last_message_time', 0) > auto_hide_delay: + win['fade_state'] = 3 # Start fade out + + # Use common fade logic with messages as content indicator + has_content = bool(win.get('messages')) or win.get('visible', False) + self._update_window_fade(win, has_content=has_content) + + def _draw_chat_window(self, name: str, win: Dict): + """Draw content for a chat window.""" + messages = win.get('messages', []) + if not messages: + # Clear render state and canvas when there are no messages + if win.get('last_render_state') is not None: + win['last_render_state'] = None + win['canvas'] = None + win['canvas_dirty'] = False + return + + props = win.get('props', {}) + bg_rgba = self._parse_hex_color_with_alpha(props.get('bg_color', '#1e212b')) + bg = bg_rgba[:3] # RGB portion for compatibility + bg_alpha = bg_rgba[3] # Alpha channel from hex color + text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) + accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) + + width = int(props.get('width', 400)) + max_height = int(props.get('max_height', 400)) + radius = int(props.get('border_radius', 12)) + padding = int(props.get('content_padding', 12)) + message_spacing = int(props.get('message_spacing', 8)) + fade_old = props.get('fade_old_messages', True) + sender_colors = props.get('sender_colors', {}) + scroll_fade_height = int(props.get('scroll_fade_height', 40)) + color_emojis = props.get('color_emojis', True) + + # Build state hash for caching + msg_state = tuple((m['sender'], m['text'], m.get('color')) for m in messages[-50:]) + props_hash = ( + width, max_height, radius, padding, + bg, bg_alpha, text_color, accent, + props.get('opacity', 0.85), + props.get('font_size', 14), + message_spacing, fade_old, + scroll_fade_height, color_emojis, + ) + current_state = (msg_state, win.get('opacity', 0), props_hash) + + if win.get('last_render_state') == current_state and win.get('canvas'): + return + + win['last_render_state'] = current_state + win['canvas_dirty'] = True + + # Get fonts + font_bold = self.fonts.get('bold', self.fonts.get('normal', self.fonts.get('regular'))) + font_normal = self.fonts.get('normal', self.fonts.get('regular')) + + # Update markdown renderer colors + if self.md_renderer: + self.md_renderer.set_colors(text_color, accent, bg) + + # Render messages to temp canvas + temp_h = max(2000, max_height * 3) + temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(temp) + + content_width = width - (padding * 2) + y = padding + + # Render each message + for i, msg in enumerate(messages): + # Apply fade to older messages + if fade_old and i < len(messages) - 3: + # Fade factor: older = more faded + position_from_end = len(messages) - i + fade_factor = max(0.3, 1.0 - (position_from_end * 0.05)) + msg_alpha = int(255 * fade_factor) + else: + msg_alpha = 255 + + sender = msg.get('sender', '') + text = msg.get('text', '') + msg_color = msg.get('color') + + # Determine sender color + if msg_color: + sender_color = self._hex_to_rgb(msg_color) if isinstance(msg_color, str) else msg_color + elif sender in sender_colors: + sender_color = self._hex_to_rgb(sender_colors[sender]) if isinstance(sender_colors[sender], str) else sender_colors[sender] + else: + sender_color = accent + + # Draw sender name with emoji support + if sender: + if font_bold: + sender_text = sender + ":" + if color_emojis and self.md_renderer: + # Use emoji-aware rendering for proper emoji display + self._render_text_with_emoji(draw, sender_text, padding, y, sender_color + (msg_alpha,), font_bold, emoji_y_offset=0) + else: + draw.text((padding, y), sender_text, fill=sender_color + (msg_alpha,), font=font_bold) + try: + bbox = font_bold.getbbox(sender_text) + y += bbox[3] - bbox[1] + 4 + except: + y += 20 + + # Draw message text with markdown + if text and self.md_renderer: + y = self.md_renderer.render(draw, temp, text, padding, y, content_width, max_chars=None) + elif text and font_normal: + # Fallback simple text rendering with emoji support + if color_emojis and self.md_renderer: + self._render_text_with_emoji(draw, text, padding, y, text_color + (msg_alpha,), font_normal, emoji_y_offset=0) + else: + draw.text((padding, y), text, fill=text_color + (msg_alpha,), font=font_normal) + try: + lines = text.split('\n') + for line in lines: + bbox = font_normal.getbbox(line) + y += bbox[3] - bbox[1] + 4 + except: + y += len(text.split('\n')) * 20 + + y += message_spacing + + # Calculate final height + bottom_padding = padding - 4 + total_content_height = y + bottom_padding + final_h = min(max(60, total_content_height), max_height) + + # Determine if content is clipped (needs scroll) + content_clipped = total_content_height > max_height + + # Create final canvas + old_canvas = win.get('canvas') + if old_canvas is None or old_canvas.width != width or old_canvas.height != final_h: + canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) + win['canvas'] = canvas + else: + canvas = old_canvas + canvas.paste(Image.new('RGBA', (width, final_h), (255, 0, 255, 255)), (0, 0)) + + final_draw = ImageDraw.Draw(canvas) + final_draw.rectangle([0, 0, width, final_h], fill=(255, 0, 255, 255)) + final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, + fill=bg + (bg_alpha,), outline=(55, 62, 74)) + + # Composite content - scroll to bottom (show newest messages) + if content_clipped: + # Content is taller than max_height, crop from bottom to show newest messages + crop_top = total_content_height - final_h + crop = temp.crop((0, crop_top, width, crop_top + final_h)) + else: + # Content fits, crop from top + crop = temp.crop((0, 0, width, min(final_h, temp.height))) + + canvas_region = canvas.crop((0, 0, width, min(final_h, canvas.height))) + composited = Image.alpha_composite(canvas_region, crop) + canvas.paste(composited, (0, 0)) + + # Apply fade gradient at top when content is clipped to indicate more content above + if content_clipped: + fade_height = int(props.get('scroll_fade_height', 40)) + if fade_height > 0: + # Get the top portion of the canvas before applying fade + top_region = canvas.crop((0, 0, width, fade_height)) + + # Create a mask identifying magenta (color key) pixels to preserve rounded corners + # Magenta = (255, 0, 255) is used as transparency color key + top_data = top_region.load() + corner_mask = Image.new('L', (width, fade_height), 0) + corner_mask_data = corner_mask.load() + for py in range(fade_height): + for px in range(width): + r, g, b, a = top_data[px, py] + # Check if pixel is magenta (color key for transparency) + if r == 255 and g == 0 and b == 255: + corner_mask_data[px, py] = 255 # Mark as corner pixel + + # Create a gradient mask that fades from opaque bg at top to transparent at bottom + gradient = Image.new('L', (width, fade_height), 0) + for fade_y in range(fade_height): + # Fade: 255 (full bg) at top, 0 (no bg) at bottom + alpha = int(255 * (1.0 - fade_y / fade_height)) + ImageDraw.Draw(gradient).line([(0, fade_y), (width, fade_y)], fill=alpha) + + # Create background layer for fade + bg_layer = Image.new('RGBA', (width, fade_height), bg + (255,)) + + # Apply gradient as alpha to bg layer + bg_layer.putalpha(gradient) + + # Composite fade over content + faded_top = Image.alpha_composite(top_region, bg_layer) + + # Restore magenta pixels for corners (color key transparency) + faded_data = faded_top.load() + for py in range(fade_height): + for px in range(width): + if corner_mask_data[px, py] == 255: + faded_data[px, py] = (255, 0, 255, 255) + + canvas.paste(faded_top, (0, 0)) + + # Update layout manager and position + self._layout_manager.update_window_height(name, final_h) + pos = self._layout_manager.get_position(name) + + hwnd = win.get('hwnd') + if hwnd: + if pos: + x, y_pos = pos + else: + x = int(props.get('x', 20)) + y_pos = int(props.get('y', 20)) + user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) + def _blit_window(self, name: str, win: Dict): """Blit a window's canvas to its Win32 window.""" if win.get('opacity', 0) <= 0: @@ -1167,13 +1440,48 @@ def _blit_window(self, name: str, win: Dict): pass def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]: - try: - hex_color = hex_color.lstrip('#') - if len(hex_color) == 3: - hex_color = ''.join([c*2 for c in hex_color]) - return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - except: - return (0, 170, 255) + """Parse hex color string to RGB tuple. Supports #RGB, #RRGGBB, #RRGGBBAA formats.""" + result = self._parse_hex_color_with_alpha(hex_color) + return result[:3] # Return only RGB portion + + def _parse_hex_color_with_alpha(self, color_str: str) -> Tuple[int, int, int, int]: + """Parse hex color string to RGBA tuple. Alpha defaults to 255 if not specified. + + Supports formats: #RGB, #RRGGBB, #RRGGBBAA + Returns: (red, green, blue, alpha) tuple with values 0-255 + """ + fallback_color = (0, 170, 255, 255) + if not color_str or not isinstance(color_str, str): + return fallback_color + + clean = color_str.strip().lstrip('#') + char_count = len(clean) + + # Expand shorthand #RGB to #RRGGBB using same pattern as _hex_to_rgb + if char_count == 3: + clean = ''.join([ch * 2 for ch in clean]) + char_count = 6 + + # Validate length - must be 6 (RRGGBB) or 8 (RRGGBBAA) + if char_count not in (6, 8): + return fallback_color + + # Parse each component + components = [] + for offset in range(0, char_count, 2): + segment = clean[offset:offset + 2] + try: + val = int(segment, 16) + components.append(val) + except (ValueError, TypeError): + return fallback_color + + # Ensure we always return exactly 4 components (RGBA) + while len(components) < 4: + components.append(255) # Default alpha to full opacity + + # Return exactly 4 values as a tuple + return (components[0], components[1], components[2], components[3]) def _strip_emotions(self, text: str) -> str: """Remove emotion tags like [happy], [sad], [breathe] but preserve markdown links and checkboxes.""" @@ -1220,9 +1528,15 @@ def save_checkbox(m): return text - def _init_fonts(self): - size = int(self.display_props.get('font_size', 16)) - font_family = self.display_props.get('font_family', 'Segoe UI') + def _init_fonts(self, font_size: int = None, font_family: str = None): + """Initialize fonts for rendering. + + Args: + font_size: Font size in pixels. Defaults to value from _default_props (16). + font_family: Font family name. Defaults to value from _default_props ('Segoe UI'). + """ + size = font_size if font_size is not None else int(self._default_props.get('font_size', 16)) + family = font_family if font_family is not None else self._default_props.get('font_family', 'Segoe UI') # Map font family names to Windows font files font_map = { @@ -1238,7 +1552,7 @@ def _init_fonts(self): } # Get font files for the specified family (case-insensitive) - family_lower = font_family.lower() + family_lower = family.lower() font_files = font_map.get(family_lower, font_map['segoe ui']) fonts_dir = "C:/Windows/Fonts/" @@ -1310,19 +1624,19 @@ def _init_fonts(self): # Fallback: try loading font by name directly (for custom fonts) try: self.fonts = { - 'normal': ImageFont.truetype(font_family, pil_size), - 'bold': ImageFont.truetype(font_family, pil_size), - 'italic': ImageFont.truetype(font_family, pil_size), - 'bold_italic': ImageFont.truetype(font_family, pil_size), + 'normal': ImageFont.truetype(family, pil_size), + 'bold': ImageFont.truetype(family, pil_size), + 'italic': ImageFont.truetype(family, pil_size), + 'bold_italic': ImageFont.truetype(family, pil_size), 'code': ImageFont.truetype("consola.ttf", pil_code_size), - 'h1': ImageFont.truetype(font_family, pil_size + 10), - 'h2': ImageFont.truetype(font_family, pil_size + 6), - 'h3': ImageFont.truetype(font_family, pil_size + 3), - 'h4': ImageFont.truetype(font_family, pil_size + 1), - 'h5': ImageFont.truetype(font_family, pil_size), - 'h6': ImageFont.truetype(font_family, pil_size - 1), - 'header': ImageFont.truetype(font_family, pil_size + 4), - 'emoji': emoji_font if emoji_font else ImageFont.truetype(font_family, pil_size), + 'h1': ImageFont.truetype(family, pil_size + 10), + 'h2': ImageFont.truetype(family, pil_size + 6), + 'h3': ImageFont.truetype(family, pil_size + 3), + 'h4': ImageFont.truetype(family, pil_size + 1), + 'h5': ImageFont.truetype(family, pil_size), + 'h6': ImageFont.truetype(family, pil_size - 1), + 'header': ImageFont.truetype(family, pil_size + 4), + 'emoji': emoji_font if emoji_font else ImageFont.truetype(family, pil_size), } except: # Final fallback to default @@ -1330,11 +1644,11 @@ def _init_fonts(self): self.fonts = {k: default for k in ['normal', 'bold', 'italic', 'bold_italic', 'code', 'header', 'emoji']} colors = { - 'text': self._hex_to_rgb(self.display_props.get('text_color', '#f0f0f0')), - 'accent': self._hex_to_rgb(self.display_props.get('accent_color', '#00aaff')), - 'bg': self._hex_to_rgb(self.display_props.get('bg_color', '#1e212b')) + 'text': self._hex_to_rgb(self._default_props.get('text_color', '#f0f0f0')), + 'accent': self._hex_to_rgb(self._default_props.get('accent_color', '#00aaff')), + 'bg': self._hex_to_rgb(self._default_props.get('bg_color', '#1e212b')) } - color_emojis = self.display_props.get('color_emojis', True) + color_emojis = self._default_props.get('color_emojis', True) self.md_renderer = MarkdownRenderer(self.fonts, colors, color_emojis) def _get_text_size(self, text: str, font) -> Tuple[int, int]: @@ -1347,6 +1661,8 @@ def _get_text_size(self, text: str, font) -> Tuple[int, int]: def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, font, emoji_y_offset: int = 5): """Render text with inline emoji support for titles and labels. + Automatically adds a space after emojis if not already present. + Args: draw: ImageDraw object text: Text to render (may contain emojis) @@ -1362,6 +1678,7 @@ def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, current_x = x i = 0 emoji_font = self.fonts.get('emoji', font) + space_w, _ = self._get_text_size(' ', font) while i < len(text): # Check for emoji at current position @@ -1381,6 +1698,10 @@ def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, else: current_x += int(emoji_w * 0.85) i += emoji_len + + # Add automatic space after emoji if next character is not a space or end of text + if i < len(text) and text[i] != ' ': + current_x += space_w else: # Find the next emoji or end of text text_start = i @@ -1395,8 +1716,48 @@ def _render_text_with_emoji(self, draw, text: str, x: int, y: int, color: Tuple, text_w, _ = self._get_text_size(text_segment, font) current_x += text_w + def _get_cached_loading_bar(self, bar_w: int, bar_h: int, color: Tuple) -> Image.Image: + """Get or create a cached loading bar element. + + Caches pre-rendered loading bar pill shapes to avoid recreating + them every frame for each bar in the loading animation. + + Note: bar_h is already guaranteed >= 1 by the caller. + """ + # Ensure color is just RGB for cache key (ignore alpha variations) + color_key = color[:3] + cache_key = (bar_w, bar_h, color_key) + + if cache_key in self._loading_bar_cache: + self._render_stats['loading_cache_hits'] += 1 + return self._loading_bar_cache[cache_key] + + self._render_stats['loading_cache_misses'] += 1 + + # Create the bar surface (bar_h >= 1 guaranteed by caller) + bar_surf = Image.new('RGBA', (bar_w, bar_h), (0, 0, 0, 0)) + bar_draw = ImageDraw.Draw(bar_surf) + + radius = min(bar_w // 2, bar_h // 2) + if radius < 1: + radius = 1 + + bar_color = color_key + (255,) + bar_draw.rounded_rectangle([0, 0, bar_w - 1, bar_h - 1], radius=radius, fill=bar_color) + + # Limit cache size + if len(self._loading_bar_cache) >= self._max_loading_bar_cache: + oldest_key = next(iter(self._loading_bar_cache)) + del self._loading_bar_cache[oldest_key] + + self._loading_bar_cache[cache_key] = bar_surf + return bar_surf + def _draw_loading(self, draw, canvas, x: int, y: int, width: int, color: Tuple): - """Modern animated loading bars with full width wave.""" + """Modern animated loading bars with full width wave. + + OPTIMIZED: Uses caching for bar element surfaces. + """ # Initialize loading phase if not exists if not hasattr(self, '_loading_phase'): self._loading_phase = 0.0 @@ -1437,638 +1798,115 @@ def _draw_loading(self, draw, canvas, x: int, y: int, width: int, color: Tuple): bar_x = start_x + i * (bar_w + spacing) bar_y = int(center_y - (h / 2)) - # Solid color without alpha - bar_color = color[:3] + (255,) + # Get cached bar surface (or create if height not cached) + bar_surf = self._get_cached_loading_bar(bar_w, h, color) + canvas.paste(bar_surf, (bar_x, bar_y), bar_surf) - # Draw rounded bar (pill shape) - radius = min(bar_w // 2, h // 2) - if radius < 1: - radius = 1 + def _cleanup_chat_window(self, chat_name: str): + """Clean up resources for a specific chat window.""" + window_name = f"chat_{chat_name}" - # Create small surface for the bar - bar_surf = Image.new('RGBA', (bar_w, max(1, h)), (0, 0, 0, 0)) - bar_draw = ImageDraw.Draw(bar_surf) - bar_draw.rounded_rectangle([0, 0, bar_w - 1, h - 1], radius=radius, fill=bar_color) - canvas.paste(bar_surf, (bar_x, bar_y), bar_surf) + # Unregister from layout manager + self._layout_manager.unregister_window(window_name) - def _draw_main_frame(self): - if not self.current_message and not self.is_loading: - return + # Clean up unified window if it exists + if window_name in self._windows: + self._destroy_window(window_name) - # Check if redraw is needed - # We include display_props in state because it affects rendering (colors, size) and window position - # We convert display_props to a tuple of items for hashing + def _safe_report(self, payload): + if not self.error_queue: + return try: - props_hash = tuple(sorted((k, v) for k, v in self.display_props.items() if isinstance(v, (str, int, float, bool, tuple)))) - except: - props_hash = str(self.display_props) + self.error_queue.put_nowait(payload) + except Exception: + pass - current_msg_id = self.current_message.get('id') if self.current_message else None - current_msg_content = self.current_message.get('message') if self.current_message else None + def _emit_heartbeat(self): + now = time.time() + if now >= self._next_heartbeat: + self._next_heartbeat = now + 1.0 + self._safe_report({"type": "heartbeat", "ts": now}) - # Quantize typewriter position to whole characters for state comparison - # This prevents unnecessary redraws for sub-character movements - typewriter_state = int(self.typewriter_char_count) if self.typewriter_active else -1 + def _report_exception(self, context: str, exc: Exception): + self._safe_report({ + "type": "error", + "context": context, + "error": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc(), + "ts": time.time(), + }) - # For loading animation, use current time to ensure redraw every frame - # This keeps the animation smooth at configured FPS - loading_frame = time.time() if self.is_loading else -1 + def _check_window_collision(self, msg_win: Optional[Dict], pers_win: Dict) -> bool: + """Check if message window overlaps with persistent window (unified system).""" + # No collision if no message window or message not visible + if not msg_win: + return False + if not msg_win.get('current_message') and not msg_win.get('is_loading'): + return False + if msg_win.get('fade_state', 0) in (0, 3): # Hidden or fading out + return False - current_state = ( - current_msg_id, - current_msg_content, - typewriter_state, - loading_frame, - props_hash - ) + # No collision if persistent window has no items + if not pers_win.get('items'): + return False - # Skip render if state unchanged - if self.last_render_state == current_state and self.canvas: - return + # Get message window rect + msg_props = msg_win.get('props', {}) + msg_x = int(msg_props.get('x', 20)) + msg_y = int(msg_props.get('y', 20)) + msg_w = int(msg_props.get('width', 400)) + msg_canvas = msg_win.get('canvas') + msg_h = msg_canvas.height if msg_canvas else 200 - self.last_render_state = current_state - self.canvas_dirty = True # Mark canvas as needing blit + # Get persistent window rect + pers_props = pers_win.get('props', {}) + pers_x = int(pers_props.get('x', pers_props.get('persistent_x', 20))) + pers_y = int(pers_props.get('y', pers_props.get('persistent_y', 300))) + pers_w = int(pers_props.get('width', pers_props.get('persistent_width', 300))) + pers_canvas = pers_win.get('canvas') + pers_h = pers_canvas.height if pers_canvas else 200 - props = self.display_props - bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) - text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) - accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) - width = int(props.get('width', 400)) - radius = int(props.get('border_radius', 12)) - padding = int(props.get('content_padding', 16)) - max_height = int(props.get('max_height', 600)) + # Check intersection (AABB test) + return not (msg_x + msg_w <= pers_x or + pers_x + pers_w <= msg_x or + msg_y + msg_h <= pers_y or + pers_y + pers_h <= msg_y) - # Update renderer colors - if self.md_renderer: - self.md_renderer.set_colors(text_color, accent, bg) + def _update_persistent_fade(self, win: Dict, collision_detected: bool = False): + """Update persistent window fade based on content and collision.""" + items = win.get('items', {}) + has_content = bool(items) - # Reuse temp canvas if possible - temp_h = 2000 - if self.temp_image is None or self.temp_image.width != width or self.temp_image.height < temp_h: - self.temp_image = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) - self.temp_draw = ImageDraw.Draw(self.temp_image) - else: - # Clear existing canvas - self.temp_draw.rectangle([(0, 0), (width, temp_h)], fill=(0, 0, 0, 0)) + # If collision detected, force hide + should_show = has_content and not collision_detected - temp = self.temp_image - draw = self.temp_draw + fade_state = win.get('fade_state', 0) - y = padding + if should_show and fade_state in (0, 3): + win['fade_state'] = 1 # Start fade in + elif not should_show and fade_state in (1, 2): + win['fade_state'] = 3 # Start fade out - # Determine if we should draw message components - should_draw_message = self.current_message is not None + def run(self): + try: + if not PIL_AVAILABLE: + self._report_exception("init", ImportError("PIL not available")) + return - # Title pill - if should_draw_message: - title = self.current_message.get('title', '') - if title: - title_color = self._hex_to_rgb(self.current_message.get('color', '#00aaff')) - font = self.fonts.get('bold', self.fonts['normal']) - - # Get text bounding box for accurate sizing - bbox = font.getbbox(title) - tw = bbox[2] - bbox[0] # width - th = bbox[3] - bbox[1] # height - - # Pill dimensions - fixed height for consistency - pill_padding_x = 14 - pill_h = 28 # Fixed pill height for consistent look - pill_w = tw + pill_padding_x * 2 - - # Modern pill with subtle shadow - shadow_offset = 2 - draw.rounded_rectangle([padding + shadow_offset, y + shadow_offset, - padding + pill_w + shadow_offset, y + pill_h + shadow_offset], - radius=pill_h//2, fill=(0, 0, 0, 40)) - draw.rounded_rectangle([padding, y, padding + pill_w, y + pill_h], - radius=pill_h//2, fill=title_color) - - # Center text using anchor='mm' (middle-middle) for true centering - center_x = padding + pill_w // 2 - center_y = y + pill_h // 2 - draw.text((center_x, center_y), title, fill=bg, font=font, anchor='mm') - y += pill_h + 10 # Spacing after title pill - - # Message content with Markdown - msg = self.current_message.get('message', '') - if msg: - msg = self._strip_emotions(msg) - max_chars = self.typewriter_char_count if self.typewriter_active else None + if self.use_stdin: + threading.Thread(target=self._read_stdin, daemon=True).start() - # Use cached blocks if available and message hasn't changed - if self.current_blocks is None or self.current_blocks.get('msg') != msg: - self.current_blocks = { - 'msg': msg, - 'blocks': self.md_renderer.parse_blocks(msg) - } + if not _ensure_window_class(): + self._report_exception("init", RuntimeError("Failed to register window class")) + return - y = self.md_renderer.render( - draw, temp, msg, padding, y, width - padding * 2, max_chars, - pre_parsed_blocks=self.current_blocks['blocks'] - ) + self._init_fonts() - # Tool chips - tools = self.current_message.get('tools', []) - if tools: - y += 10 - tx = padding - th = 30 + # Note: Removed last_z tracking since we no longer repeatedly bring windows to front + self.last_update_time = time.time() - # Group by source - counts = {} - for t in tools: - key = (t.get('source', 'System'), t.get('icon')) - counts[key] = counts.get(key, 0) + 1 - - for (src, icon), cnt in counts.items(): - font = self.fonts.get('code', self.fonts['normal']) - sw, sh = self._get_text_size(src, font) - icon_w = 24 if icon and os.path.exists(str(icon)) else 0 - badge_w = 26 if cnt > 1 else 0 - chip_w = sw + icon_w + badge_w + 26 - - if tx + chip_w > width - padding: - tx = padding - y += th + 10 - - # Modern chip with gradient-like effect - chip_bg = (42, 48, 60, 235) - draw.rounded_rectangle([tx, y, tx + chip_w, y + th], radius=th//2, - fill=chip_bg, outline=accent) - - ix = tx + 12 - if icon and os.path.exists(str(icon)): - try: - if icon not in self.image_cache: - img = Image.open(icon).convert('RGBA').resize((18, 18), Image.Resampling.LANCZOS) - self.image_cache[icon] = img - temp.paste(self.image_cache[icon], (ix, y + 6), self.image_cache[icon]) - ix += 22 - except: - pass - - draw.text((ix, y + (th - sh) // 2), src, fill=text_color, font=font) - - if cnt > 1: - bw, bh = self._get_text_size(str(cnt), font) - bx = tx + chip_w - bw - 16 - draw.ellipse([bx - 4, y + 5, bx + bw + 8, y + th - 5], fill=accent) - draw.text((bx + 2, y + 7), str(cnt), fill=bg, font=font) - - tx += chip_w + 10 - - y += th + 10 - - # Loading animation - if self.is_loading: - y += 6 - self._draw_loading(draw, temp, padding, y, width - padding * 2, self.loading_color) - y += 24 - - # Calculate final height with configured bottom padding - # Add extra padding at bottom to match visual balance with top/sides - bottom_padding = padding - 4 - final_h = min(max(60, y + bottom_padding), max_height) - - # Create final canvas with background (reuse if possible) - if self.canvas is None or self.canvas.width != width or self.canvas.height != final_h: - self.canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) - else: - # Clear canvas (fill with transparent or background color) - # Actually we draw a full rounded rectangle over it so clearing might not be strictly needed - # if the rounded rect covers everything, but for safety (corners): - self.canvas.paste((255, 0, 255, 255), (0, 0, width, final_h)) - - final_draw = ImageDraw.Draw(self.canvas) - - # Modern background with subtle gradient feel - final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, - fill=bg + (255,), outline=(55, 62, 74)) - - # Composite content - content_height = y + bottom_padding - crop_height = min(final_h, temp.height) - crop = temp.crop((0, 0, width, crop_height)) - - # Apply fade out if content exceeds max height - if content_height > max_height: - fade_height = 60 - if crop_height > fade_height: - # Create alpha mask - mask = Image.new('L', (width, crop_height), 255) - mask_draw = ImageDraw.Draw(mask) - - # Draw gradient at the bottom - for i in range(fade_height): - alpha = int(255 * (1 - (i / fade_height))) - line_y = crop_height - fade_height + i - mask_draw.line([(0, line_y), (width, line_y)], fill=alpha) - - # Apply mask to crop's alpha channel - r, g, b, a = crop.split() - new_alpha = ImageChops.multiply(a, mask) - crop.putalpha(new_alpha) - - self.canvas.paste(crop, (0, 0), crop) - - # Update window - if self.hwnd: - user32.MoveWindow(self.hwnd, int(props.get('x', 20)), int(props.get('y', 20)), width, final_h, True) - user32.SetLayeredWindowAttributes(self.hwnd, 0x00FF00FF, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) - - def _blit_to_window(self, hwnd, canvas, wdc, mdc, is_persistent=False): - if not hwnd or not canvas or not wdc or not mdc: - return - - w, h = canvas.size - - # Check if DIB needs resize - if is_persistent: - if w != self.dib_width_persistent or h != self.dib_height_persistent: - # Cleanup old - if self.old_bitmap_persistent: gdi32.SelectObject(mdc, self.old_bitmap_persistent) - if self.dib_bitmap_persistent: gdi32.DeleteObject(self.dib_bitmap_persistent) - # Create new - self.dib_width_persistent = w - self.dib_height_persistent = h - bmi = BITMAPINFO(); bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h - bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32 - bmi.bmiHeader.biCompression = BI_RGB - self.dib_bits_persistent = ctypes.c_void_p() - self.dib_bitmap_persistent = gdi32.CreateDIBSection(mdc, ctypes.byref(bmi), DIB_RGB_COLORS, - ctypes.byref(self.dib_bits_persistent), None, 0) - if self.dib_bitmap_persistent: self.old_bitmap_persistent = gdi32.SelectObject(mdc, self.dib_bitmap_persistent) - - dib_bits = self.dib_bits_persistent - else: - if w != self.dib_width or h != self.dib_height: - if self.old_bitmap: gdi32.SelectObject(mdc, self.old_bitmap) - if self.dib_bitmap: gdi32.DeleteObject(self.dib_bitmap) - self.dib_width = w - self.dib_height = h - bmi = BITMAPINFO(); bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biWidth = w; bmi.bmiHeader.biHeight = -h - bmi.bmiHeader.biPlanes = 1; bmi.bmiHeader.biBitCount = 32 - bmi.bmiHeader.biCompression = BI_RGB - self.dib_bits = ctypes.c_void_p() - self.dib_bitmap = gdi32.CreateDIBSection(mdc, ctypes.byref(bmi), DIB_RGB_COLORS, - ctypes.byref(self.dib_bits), None, 0) - if self.dib_bitmap: self.old_bitmap = gdi32.SelectObject(mdc, self.dib_bitmap) - - dib_bits = self.dib_bits - - if not dib_bits: - return - - try: - rgba = canvas.tobytes('raw', 'BGRA') - ctypes.memmove(dib_bits, rgba, len(rgba)) - gdi32.BitBlt(wdc, 0, 0, w, h, mdc, 0, 0, SRCCOPY) - except: - pass - - def _create_dib(self, w, h): - self.dib_width = w - self.dib_height = h - bmi = BITMAPINFO() - bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biWidth = w - bmi.bmiHeader.biHeight = -h - bmi.bmiHeader.biPlanes = 1 - bmi.bmiHeader.biBitCount = 32 - bmi.bmiHeader.biCompression = BI_RGB - self.dib_bits = ctypes.c_void_p() - self.dib_bitmap = gdi32.CreateDIBSection(self.mem_dc, ctypes.byref(bmi), DIB_RGB_COLORS, - ctypes.byref(self.dib_bits), None, 0) - if self.dib_bitmap: - self.old_bitmap = gdi32.SelectObject(self.mem_dc, self.dib_bitmap) - - def _cleanup_dib(self): - if self.old_bitmap: - gdi32.SelectObject(self.mem_dc, self.old_bitmap) - self.old_bitmap = None - if self.dib_bitmap: - gdi32.DeleteObject(self.dib_bitmap) - self.dib_bitmap = None - self.dib_bits = None - self.dib_width = self.dib_height = 0 - - def _cleanup_gdi(self): - self._cleanup_dib() - - # Cleanup persistent DIB - if self.old_bitmap_persistent and self.mem_dc_persistent: - gdi32.SelectObject(self.mem_dc_persistent, self.old_bitmap_persistent) - self.old_bitmap_persistent = None - if self.dib_bitmap_persistent: - gdi32.DeleteObject(self.dib_bitmap_persistent) - self.dib_bitmap_persistent = None - - if self.mem_dc: - gdi32.DeleteDC(self.mem_dc) - self.mem_dc = None - if self.mem_dc_persistent: - gdi32.DeleteDC(self.mem_dc_persistent) - self.mem_dc_persistent = None - - if self.window_dc and self.hwnd: - user32.ReleaseDC(self.hwnd, self.window_dc) - self.window_dc = None - if self.window_dc_persistent and self.hwnd_persistent: - user32.ReleaseDC(self.hwnd_persistent, self.window_dc_persistent) - self.window_dc_persistent = None - - # Cleanup chat windows - for chat_name in list(self._chat_windows.keys()): - self._cleanup_chat_window(chat_name) - - def _cleanup_chat_window(self, chat_name: str): - """Clean up resources for a specific chat window.""" - # Unregister from layout manager - self._layout_manager.unregister_window(f"chat_{chat_name}") - - # Cleanup DCs - if chat_name in self._chat_window_dcs: - window_dc, mem_dc = self._chat_window_dcs[chat_name] - hwnd = self._chat_hwnds.get(chat_name) - if mem_dc: - gdi32.DeleteDC(mem_dc) - if window_dc and hwnd: - user32.ReleaseDC(hwnd, window_dc) - del self._chat_window_dcs[chat_name] - - # Destroy window - if chat_name in self._chat_hwnds: - hwnd = self._chat_hwnds[chat_name] - if hwnd: - user32.DestroyWindow(hwnd) - del self._chat_hwnds[chat_name] - - # Clean up state - self._chat_windows.pop(chat_name, None) - self._chat_window_dirty.pop(chat_name, None) - self._chat_canvases.pop(chat_name, None) - self._chat_last_render_state.pop(chat_name, None) - - def _safe_report(self, payload): - if not self.error_queue: - return - try: - self.error_queue.put_nowait(payload) - except Exception: - pass - - def _emit_heartbeat(self): - now = time.time() - if now >= self._next_heartbeat: - self._next_heartbeat = now + 1.0 - self._safe_report({"type": "heartbeat", "ts": now}) - - def _report_exception(self, context: str, exc: Exception): - self._safe_report({ - "type": "error", - "context": context, - "error": f"{type(exc).__name__}: {exc}", - "trace": traceback.format_exc(), - "ts": time.time(), - }) - - def _update_logic_main(self): - if not self.hwnd: - return - - # Typewriter progression (only affects main message) - if self.typewriter_active and self.current_message: - now = time.time() - chars = (now - self.last_typewriter_update) * 200 - if chars > 0: - self.typewriter_char_count += chars - self.last_typewriter_update = now - msg_len = len(self.current_message.get('message', '')) - if self.typewriter_char_count >= msg_len: - self.typewriter_active = False - self.typewriter_char_count = float(msg_len) - - key = 0x00FF00FF - fade_amount = int(1080 * self.dt) - if fade_amount < 1: fade_amount = 1 - - # Fade logic for main window - if self.fade_state == 1: # Fade in - self.current_opacity = min(self.target_opacity, self.current_opacity + fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) - if self.current_opacity >= self.target_opacity: - self.fade_state = 2 - - elif self.fade_state == 3: # Fade out - self.current_opacity = max(0, self.current_opacity - fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) - if self.current_opacity <= 0: - self.fade_state = 0 - self.current_message = None - - if self.fade_state == 2: - # Tracking opacity target - if self.current_opacity != self.target_opacity: - if self.current_opacity < self.target_opacity: - self.current_opacity = min(self.target_opacity, self.current_opacity + fade_amount) - else: - self.current_opacity = max(self.target_opacity, self.current_opacity - fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd, key, self.current_opacity, LWA_ALPHA | LWA_COLORKEY) - - # Auto fade out check - should_fade_out = True - - # Don't fade out if loading - if self.is_loading: - should_fade_out = False - # Check if message duration has passed - elif self.current_message: - now = time.time() - if now <= self.min_display_time: - should_fade_out = False - else: - # No message, no loading -> fade out - should_fade_out = True - - if should_fade_out: - self.fade_state = 3 - - def _check_collision(self) -> bool: - """Check if main window overlaps with persistent window.""" - if not self.current_message or not self.persistent_infos: - return False - - # Get Main Window Rect - main_x = int(self.display_props.get('x', 20)) - main_y = int(self.display_props.get('y', 20)) - main_w = int(self.display_props.get('width', 400)) - # Use existing canvas height if available, otherwise estimate or assume max - # It's safer to rely on canvas if _draw_main_frame ran at least once for this content - main_h = self.canvas.height if self.canvas else 200 - - # Get Persistent Window Rect - pers_x = int(self.display_props.get('persistent_x', 20)) - pers_y = int(self.display_props.get('persistent_y', 300)) - pers_w = int(self.display_props.get('persistent_width', 300)) - pers_h = self.canvas_persistent.height if self.canvas_persistent else 200 - - # Check intersection - # Rect1: (main_x, main_y, main_x + main_w, main_y + main_h) - # Rect2: (pers_x, pers_y, pers_x + pers_w, pers_y + pers_h) - - return not (main_x + main_w <= pers_x or - pers_x + pers_w <= main_x or - main_y + main_h <= pers_y or - pers_y + pers_h <= main_y) - - def _update_logic_persistent(self, collision_detected=False): - if not self.hwnd_persistent: - return - - now = time.time() - - # Check expiry - expired = [k for k, v in self.persistent_infos.items() if v.get('expiry') and now > v['expiry']] - for k in expired: - del self.persistent_infos[k] - - # Determine target state - has_content = bool(self.persistent_infos) - - # If collision detected, force hide irrespective of content - should_show = has_content and not collision_detected - - if should_show and self.persistent_fade_state in (0, 3): - self.persistent_fade_state = 1 # Start Fade in - elif not should_show and self.persistent_fade_state in (1, 2): - self.persistent_fade_state = 3 # Start Fade out - - key = 0x00FF00FF - fade_amount = int(1080 * self.dt) - if fade_amount < 1: fade_amount = 1 - - if self.persistent_fade_state == 1: # Fade in - self.persistent_opacity = min(self.target_opacity, self.persistent_opacity + fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) - if self.persistent_opacity >= self.target_opacity: - self.persistent_fade_state = 2 - - elif self.persistent_fade_state == 3: # Fade out - self.persistent_opacity = max(0, self.persistent_opacity - fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) - if self.persistent_opacity <= 0: - self.persistent_fade_state = 0 - - elif self.persistent_fade_state == 2: # Visible - if self.persistent_opacity != self.target_opacity: - if self.persistent_opacity < self.target_opacity: - self.persistent_opacity = min(self.target_opacity, self.persistent_opacity + fade_amount) - else: - self.persistent_opacity = max(self.target_opacity, self.persistent_opacity - fade_amount) - user32.SetLayeredWindowAttributes(self.hwnd_persistent, key, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) - - def _check_window_collision(self, msg_win: Optional[Dict], pers_win: Dict) -> bool: - """Check if message window overlaps with persistent window (unified system).""" - # No collision if no message window or message not visible - if not msg_win: - return False - if not msg_win.get('current_message') and not msg_win.get('is_loading'): - return False - if msg_win.get('fade_state', 0) in (0, 3): # Hidden or fading out - return False - - # No collision if persistent window has no items - if not pers_win.get('items'): - return False - - # Get message window rect - msg_props = msg_win.get('props', {}) - msg_x = int(msg_props.get('x', 20)) - msg_y = int(msg_props.get('y', 20)) - msg_w = int(msg_props.get('width', 400)) - msg_canvas = msg_win.get('canvas') - msg_h = msg_canvas.height if msg_canvas else 200 - - # Get persistent window rect - pers_props = pers_win.get('props', {}) - pers_x = int(pers_props.get('x', pers_props.get('persistent_x', 20))) - pers_y = int(pers_props.get('y', pers_props.get('persistent_y', 300))) - pers_w = int(pers_props.get('width', pers_props.get('persistent_width', 300))) - pers_canvas = pers_win.get('canvas') - pers_h = pers_canvas.height if pers_canvas else 200 - - # Check intersection (AABB test) - return not (msg_x + msg_w <= pers_x or - pers_x + pers_w <= msg_x or - msg_y + msg_h <= pers_y or - pers_y + pers_h <= msg_y) - - def _update_persistent_fade(self, win: Dict, collision_detected: bool = False): - """Update persistent window fade based on content and collision.""" - items = win.get('items', {}) - has_content = bool(items) - - # If collision detected, force hide - should_show = has_content and not collision_detected - - fade_state = win.get('fade_state', 0) - - if should_show and fade_state in (0, 3): - win['fade_state'] = 1 # Start fade in - elif not should_show and fade_state in (1, 2): - win['fade_state'] = 3 # Start fade out - - def run(self): - try: - if not PIL_AVAILABLE: - self._report_exception("init", ImportError("PIL not available")) - return - - if self.use_stdin: - threading.Thread(target=self._read_stdin, daemon=True).start() - - if not _ensure_window_class(): - self._report_exception("init", RuntimeError("Failed to register window class")) - return - - # Initialize Main Window - w = int(self.display_props.get('width', 400)) - h = 100 - x = int(self.display_props.get('x', 20)) - y = int(self.display_props.get('y', 20)) - - self.hwnd = self._create_overlay_window("HeadsUp", x, y, w, h) - if not self.hwnd: - self._report_exception("init", RuntimeError("Failed to create main window")) - return - - self.window_dc, self.mem_dc = self._init_gdi(self.hwnd) - - # Initialize Persistent Window - pw = int(self.display_props.get('persistent_width', 300)) - ph = 100 - px = int(self.display_props.get('persistent_x', 20)) - py = int(self.display_props.get('persistent_y', 300)) - - try: - self.hwnd_persistent = self._create_overlay_window("HeadsUpPersistent", px, py, pw, ph) - if self.hwnd_persistent: - self.window_dc_persistent, self.mem_dc_persistent = self._init_gdi(self.hwnd_persistent) - except Exception as e: - self._report_exception("init_persistent", e) - # Continue without persistent window if it fails? - pass - - self._init_fonts() - - last_z = time.time() - self.last_update_time = time.time() - - # Signal successful start - self._emit_heartbeat() + # Signal successful start + self._emit_heartbeat() while self.running: try: @@ -2095,31 +1933,12 @@ def run(self): pass # ========================================================= - # UPDATE AND RENDER ALL UNIFIED WINDOWS + # UPDATE AND RENDER ALL UNIFIED WINDOWS (includes chat windows now) # ========================================================= self._update_all_windows() - # Update and draw chat windows - if self._chat_windows: - try: - self._update_chat_windows() - self._draw_chat_windows() - except Exception as e: - self._report_exception("draw_chat_windows", e) - - now = time.time() - if now - last_z > 0.1: - # Bring all windows to top - flags = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS - # Unified windows - for win in self._windows.values(): - if win.get('hwnd'): - user32.SetWindowPos(win['hwnd'], HWND_TOPMOST, 0, 0, 0, 0, flags) - # Chat windows - for chat_hwnd in self._chat_hwnds.values(): - if chat_hwnd: - user32.SetWindowPos(chat_hwnd, HWND_TOPMOST, 0, 0, 0, 0, flags) - last_z = now + # Note: Removed repeated z-order updates (bringing windows to front every 0.1s) + # HUD windows are set to topmost once during creation and when properties change self._emit_heartbeat() @@ -2134,15 +1953,9 @@ def run(self): except Exception as e: self._report_exception("run_crash", e) finally: - # Cleanup unified windows + # Cleanup unified windows (including chat windows) for name in list(self._windows.keys()): self._destroy_window(name) - # Cleanup legacy windows - self._cleanup_gdi() - if self.hwnd: - user32.DestroyWindow(self.hwnd) - if self.hwnd_persistent: - user32.DestroyWindow(self.hwnd_persistent) def _handle_message(self, msg): try: @@ -2256,9 +2069,8 @@ def _handle_message(self, msg): win['fade_state'] = 3 win['current_message'] = None win['is_loading'] = False - # Immediately notify layout manager that this window is now hidden - window_name = self._get_window_name(self.WINDOW_TYPE_MESSAGE, group) - self._layout_manager.set_window_visible(window_name, False) + # Note: Don't release layout slot yet - window still visible during fade-out + # Layout slot will be released when fade completes (opacity reaches 0) elif t == 'draw': # Get or create message window for this group @@ -2503,6 +2315,7 @@ def _handle_message(self, msg): 'auto_hide_delay': 10.0, 'max_messages': 50, 'sender_colors': {}, 'show_timestamps': False, 'message_spacing': 8, 'fade_old_messages': True, + 'scroll_fade_height': 40, # Height of fade gradient at top when scrolled 'is_chat_window': True, # Layout manager props (margin/spacing now global) 'anchor': 'top_left', @@ -2513,19 +2326,41 @@ def _handle_message(self, msg): # Also merge top-level msg properties for backwards compatibility for key in ['x', 'y', 'width', 'max_height', 'auto_hide', 'auto_hide_delay', 'max_messages', 'sender_colors', 'fade_old_messages', - 'anchor', 'priority', 'layout_mode']: + 'scroll_fade_height', 'anchor', 'priority', 'layout_mode']: if key in msg and msg[key] is not None: default_props[key] = msg[key] - self._chat_windows[chat_name] = { - 'messages': [], + # Create unified window for chat + window_name = f"chat_{chat_name}" + self._windows[window_name] = { + 'type': self.WINDOW_TYPE_CHAT, + 'group': chat_name, # Chat window name is also the group name 'props': default_props, + 'messages': [], 'last_message_time': 0, 'visible': True, 'opacity': 0, - 'fade_state': 0, # hidden + 'target_opacity': int(default_props.get('opacity', 0.85) * 255), + 'fade_state': 0, # 0=hidden, 1=fade_in, 2=visible, 3=fade_out + 'canvas_dirty': True, + 'hwnd': None, + 'window_dc': None, + 'mem_dc': None, + 'canvas': None, + 'dib_bitmap': None, + 'dib_bits': None, + 'old_bitmap': None, + 'dib_width': 0, + 'dib_height': 0, + 'last_render_state': None, } - self._chat_window_dirty[chat_name] = True + + # Register with layout manager using same name as other windows + layout_name = window_name + anchor = default_props.get('anchor', 'top_left') + priority = default_props.get('priority', 5) + layout_mode = default_props.get('layout_mode', 'auto') + self._layout_manager.register_window(layout_name, anchor, priority, layout_mode) # Create window for this chat w = int(default_props.get('width', 400)) @@ -2571,22 +2406,26 @@ def _handle_message(self, msg): hwnd = self._create_overlay_window(f"HeadsUpChat_{chat_name}", x, y, w, h) if hwnd: - self._chat_hwnds[chat_name] = hwnd + # Store hwnd in the unified window state + self._windows[window_name]['hwnd'] = hwnd window_dc, mem_dc = self._init_gdi(hwnd) - self._chat_window_dcs[chat_name] = (window_dc, mem_dc) + self._windows[window_name]['window_dc'] = window_dc + self._windows[window_name]['mem_dc'] = mem_dc elif t == 'update_chat_window': chat_name = msg.get('name') - if chat_name and chat_name in self._chat_windows: + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + win = self._windows[window_name] props = msg.get('props', {}) - self._chat_windows[chat_name]['props'].update(props) - self._chat_window_dirty[chat_name] = True + win['props'].update(props) + win['canvas_dirty'] = True # Update window position if changed if 'x' in props or 'y' in props or 'width' in props or 'max_height' in props: - hwnd = self._chat_hwnds.get(chat_name) + hwnd = win.get('hwnd') if hwnd: - chat_props = self._chat_windows[chat_name]['props'] + chat_props = win['props'] x = int(chat_props.get('x', 20)) y = int(chat_props.get('y', 20)) w = int(chat_props.get('width', 400)) @@ -2596,11 +2435,14 @@ def _handle_message(self, msg): elif t == 'delete_chat_window': chat_name = msg.get('name') if chat_name: - self._cleanup_chat_window(chat_name) + window_name = f"chat_{chat_name}" + if window_name in self._windows: + self._destroy_window(window_name) elif t == 'chat_message': chat_name = msg.get('name') - if chat_name and chat_name in self._chat_windows: + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: now = time.time() message = { 'sender': msg.get('sender', ''), @@ -2608,48 +2450,51 @@ def _handle_message(self, msg): 'color': msg.get('color'), 'timestamp': now, } - chat = self._chat_windows[chat_name] - chat['messages'].append(message) - chat['last_message_time'] = now + win = self._windows[window_name] + win['messages'].append(message) + win['last_message_time'] = now # Trim old messages if over limit - max_messages = chat['props'].get('max_messages', 50) - if len(chat['messages']) > max_messages: - chat['messages'] = chat['messages'][-max_messages:] + max_messages = win['props'].get('max_messages', 50) + if len(win['messages']) > max_messages: + win['messages'] = win['messages'][-max_messages:] # Show window if auto-hide was triggered - if chat['fade_state'] == 0 or chat['fade_state'] == 3: - chat['fade_state'] = 1 # fade in - chat['visible'] = True + if win['fade_state'] == 0 or win['fade_state'] == 3: + win['fade_state'] = 1 # fade in + win['visible'] = True # Immediately notify layout manager - self._layout_manager.set_window_visible(f"chat_{chat_name}", True) + self._layout_manager.set_window_visible(window_name, True) - self._chat_window_dirty[chat_name] = True + win['canvas_dirty'] = True elif t == 'clear_chat_window': chat_name = msg.get('name') - if chat_name and chat_name in self._chat_windows: - self._chat_windows[chat_name]['messages'] = [] - self._chat_window_dirty[chat_name] = True + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + self._windows[window_name]['messages'] = [] + self._windows[window_name]['canvas_dirty'] = True elif t == 'show_chat_window': chat_name = msg.get('name') - if chat_name and chat_name in self._chat_windows: - chat = self._chat_windows[chat_name] - chat['visible'] = True - chat['fade_state'] = 1 # fade in + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + win = self._windows[window_name] + win['visible'] = True + win['fade_state'] = 1 # fade in # Immediately notify layout manager - self._layout_manager.set_window_visible(f"chat_{chat_name}", True) - self._chat_window_dirty[chat_name] = True + self._layout_manager.set_window_visible(window_name, True) + win['canvas_dirty'] = True elif t == 'hide_chat_window': chat_name = msg.get('name') - if chat_name and chat_name in self._chat_windows: - chat = self._chat_windows[chat_name] - chat['fade_state'] = 3 # fade out - # Immediately notify layout manager - self._layout_manager.set_window_visible(f"chat_{chat_name}", False) - self._chat_window_dirty[chat_name] = True + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + win = self._windows[window_name] + win['fade_state'] = 3 # fade out + # Note: Don't release layout slot yet - window still visible during fade-out + # Layout slot will be released when fade completes (opacity reaches 0) + win['canvas_dirty'] = True except Exception as e: self._report_exception("handle_message", e) @@ -2670,6 +2515,91 @@ def _init_gdi(self, hwnd): mem_dc = gdi32.CreateCompatibleDC(window_dc) return window_dc, mem_dc + def _get_cached_progress_track(self, width: int, height: int, bg_color: Tuple[int, int, int], + scale: int = 2) -> Image.Image: + """Get or create a cached progress bar background track. + + Caches the empty progress bar background to avoid recreating it every frame. + The track includes the rounded rectangle with antialiasing. + + Note: Returns a copy because callers modify the returned image (draw fill on it). + """ + cache_key = (width, height, bg_color, scale) + + if cache_key in self._progress_track_cache: + self._render_stats['track_cache_hits'] += 1 + # Copy required: callers draw the progress fill onto this image + return self._progress_track_cache[cache_key].copy() + + self._render_stats['track_cache_misses'] += 1 + + # Create the track at scaled resolution + scaled_width = width * scale + scaled_height = height * scale + radius = scaled_height // 2 + + track = Image.new('RGBA', (scaled_width, scaled_height), (0, 0, 0, 0)) + track_draw = ImageDraw.Draw(track) + + track_color = tuple(max(0, c - 30) for c in bg_color) + outline_color = tuple(max(0, c - 50) for c in bg_color) + (150,) + track_draw.rounded_rectangle( + [0, 0, scaled_width - 1, scaled_height - 1], + radius=radius, + fill=track_color + (255,), + outline=outline_color + ) + + # Limit cache size using simple FIFO eviction + if len(self._progress_track_cache) >= self._max_progress_track_cache: + oldest_key = next(iter(self._progress_track_cache)) + del self._progress_track_cache[oldest_key] + + self._progress_track_cache[cache_key] = track + return track.copy() + + def _get_cached_gradient_overlay(self, fill_width: int, fill_height: int, + highlight_height: int, shadow_height: int) -> Image.Image: + """Get or create a cached gradient overlay for progress bar fills. + + The gradient provides the depth effect (top highlight, bottom shadow). + Cached because the gradient pattern is the same for same dimensions. + + Note: Returns a copy because gradient is pasted/composited onto other images. + """ + cache_key = (fill_width, fill_height, highlight_height, shadow_height) + + if cache_key in self._progress_gradient_cache: + self._render_stats['gradient_cache_hits'] += 1 + # Copy required: gradient is composited onto the progress bar buffer + return self._progress_gradient_cache[cache_key].copy() + + self._render_stats['gradient_cache_misses'] += 1 + + gradient = Image.new('RGBA', (fill_width + 1, fill_height + 1), (0, 0, 0, 0)) + gradient_draw = ImageDraw.Draw(gradient) + + # Top highlight (lighter) + for i in range(highlight_height): + alpha = int(60 * (1 - i / highlight_height)) + gradient_draw.line([(0, i), (fill_width, i)], fill=(255, 255, 255, alpha)) + + # Bottom shadow (darker) + for i in range(shadow_height): + alpha = int(40 * (i / shadow_height)) + gradient_draw.line( + [(0, fill_height - shadow_height + i), (fill_width, fill_height - shadow_height + i)], + fill=(0, 0, 0, alpha) + ) + + # Limit cache size + if len(self._progress_gradient_cache) >= self._max_progress_gradient_cache: + oldest_key = next(iter(self._progress_gradient_cache)) + del self._progress_gradient_cache[oldest_key] + + self._progress_gradient_cache[cache_key] = gradient + return gradient.copy() + def _draw_progress_bar(self, draw: ImageDraw.Draw, img: Image.Image, x: int, y: int, width: int, height: int, percentage: float, bg_color: Tuple[int, int, int], @@ -2679,6 +2609,7 @@ def _draw_progress_bar(self, draw: ImageDraw.Draw, img: Image.Image, x: int, y: Draw a modern, sleek progress bar with antialiasing via supersampling. Uses 2x supersampling for smooth edges on rounded corners. + OPTIMIZED: Uses caching for track backgrounds and gradient overlays. Args: draw: ImageDraw object @@ -2699,28 +2630,18 @@ def _draw_progress_bar(self, draw: ImageDraw.Draw, img: Image.Image, x: int, y: # Supersampling scale factor for antialiasing scale = 2 - - # Create high-resolution buffer for the progress bar - bar_buffer = Image.new('RGBA', (width * scale, height * scale), (0, 0, 0, 0)) - bar_draw = ImageDraw.Draw(bar_buffer) - scaled_height = height * scale scaled_width = width * scale - radius = scaled_height // 2 # Fully rounded ends at scaled size + radius = scaled_height // 2 - # Draw background track - track_color = tuple(max(0, c - 30) for c in bg_color) - bar_draw.rounded_rectangle( - [0, 0, scaled_width - 1, scaled_height - 1], - radius=radius, - fill=track_color + (255,), - outline=tuple(max(0, c - 50) for c in bg_color) + (150,) - ) + # Get cached track background (or create if not cached) + bar_buffer = self._get_cached_progress_track(width, height, bg_color, scale) # Calculate fill width at scaled size fill_width = int((scaled_width - 2 * scale) * percentage / 100) if fill_width > radius: # Only draw if there's meaningful progress + bar_draw = ImageDraw.Draw(bar_buffer) fill_x = scale fill_y = scale fill_h = scaled_height - 2 * scale @@ -2733,23 +2654,12 @@ def _draw_progress_bar(self, draw: ImageDraw.Draw, img: Image.Image, x: int, y: fill=fill_color + (255,) ) - # Create gradient overlay for depth effect - gradient_overlay = Image.new('RGBA', (fill_width + 1, fill_h + 1), (0, 0, 0, 0)) - gradient_draw = ImageDraw.Draw(gradient_overlay) - - # Top highlight (lighter) + # Get cached gradient overlay highlight_height = fill_h // 3 - for i in range(highlight_height): - alpha = int(60 * (1 - i / highlight_height)) - highlight_color = (255, 255, 255, alpha) - gradient_draw.line([(0, i), (fill_width, i)], fill=highlight_color) - - # Bottom shadow (darker) shadow_height = fill_h // 4 - for i in range(shadow_height): - alpha = int(40 * (i / shadow_height)) - shadow_color = (0, 0, 0, alpha) - gradient_draw.line([(0, fill_h - shadow_height + i), (fill_width, fill_h - shadow_height + i)], fill=shadow_color) + gradient_overlay = self._get_cached_gradient_overlay( + fill_width, fill_h, highlight_height, shadow_height + ) # Create a mask from the fill shape to apply gradient only within the bar mask = Image.new('L', (scaled_width, scaled_height), 0) @@ -2807,616 +2717,6 @@ def _read_stdin(self): except: break - def _draw_persistent_frame(self): - if not self.persistent_infos: - return - - # Check render state - try: - props_hash = tuple(sorted((k, v) for k, v in self.display_props.items() if isinstance(v, (str, int, float, bool, tuple)))) - except: - props_hash = str(self.display_props) - - # Synchronize ALL timer updates to the same tick - now = time.time() - current_second = int(now) - sync_time = float(current_second) - self._persistent_render_time = sync_time - - # Update progress animations and check if any are active - animations_active = False - items_to_remove = [] - - for title, anim in list(self._progress_animations.items()): - if title not in self.persistent_infos: - continue - - info = self.persistent_infos[title] - - # Handle timer-based progress bars - if anim.get('is_timer'): - timer_elapsed = now - anim['timer_start'] - timer_duration = anim['timer_duration'] - timer_progress = min(100, (timer_elapsed / timer_duration) * 100) - - # Update both animation current and target for timer - anim['current'] = timer_progress - anim['target'] = timer_progress - info['progress_current'] = timer_progress - - if timer_elapsed < timer_duration: - # Optimization: For timers > 100s, changes are <1% per second - # No need for smooth animation, just update once per second - if timer_duration <= 100: - animations_active = True # Short timers get smooth animation - elif info.get('auto_close') and not info.get('auto_close_triggered'): - # Timer completed, schedule auto-close - info['auto_close_triggered'] = True - info['auto_close_time'] = now + 2.0 # 2 second delay - animations_active = True # Keep animating for auto-close - else: - # Regular progress bar animation - elapsed = now - anim.get('start_time', now) - duration = self._progress_transition_duration - - if duration > 0 and elapsed < duration: - # Easing function (ease-out cubic) - t = elapsed / duration - t = 1 - (1 - t) ** 3 - anim['current'] = anim['start_value'] + (anim['target'] - anim['start_value']) * t - animations_active = True - else: - anim['current'] = anim['target'] - - # Check for auto-close on 100% - percentage = (anim['current'] / info.get('progress_maximum', 100)) * 100 - if percentage >= 100 and info.get('auto_close') and not info.get('auto_close_triggered'): - info['auto_close_triggered'] = True - info['auto_close_time'] = now + 2.0 # 2 second delay - animations_active = True - - # Handle auto-close removal - if info.get('auto_close_triggered') and info.get('auto_close_time'): - if now >= info['auto_close_time']: - items_to_remove.append(title) - else: - animations_active = True - - # Remove items scheduled for auto-close - for title in items_to_remove: - if title in self.persistent_infos: - del self.persistent_infos[title] - if title in self._progress_animations: - del self._progress_animations[title] - - persistent_state_list = [] - has_active_timers = False - for k, v in sorted(self.persistent_infos.items()): - if v.get('expiry'): - rem = max(0, int(v['expiry'] - sync_time + 0.999)) - else: - rem = -1 - progress_state = None - if v.get('is_progress'): - # Check if this is an active timer - if v.get('is_timer'): - timer_elapsed = now - v.get('timer_start', now) - if timer_elapsed < v.get('timer_duration', 0): - has_active_timers = True - # Use animated value for state hash if animating - if k in self._progress_animations: - animated_value = self._progress_animations[k]['current'] - progress_state = (animated_value, v.get('progress_maximum', 100)) - else: - progress_state = (v.get('progress_current', 0), v.get('progress_maximum', 100)) - persistent_state_list.append((k, v['description'], rem, progress_state)) - persistent_state = tuple(persistent_state_list) - - # Determine render frequency based on what's active: - # - Smooth animations (progress bar transitions) need configured framerate - # - Timers only need 1fps (once per second) since we show whole seconds only - configured_fps = int(self.display_props.get('framerate', 60)) - if animations_active: - # Smooth transitions need configured framerate - anim_frame = int(now * configured_fps) - needs_continuous_render = True - elif has_active_timers: - # Timers only need to update once per second (whole seconds display) - anim_frame = current_second - needs_continuous_render = True - else: - anim_frame = 0 - needs_continuous_render = False - - current_state = (persistent_state, props_hash, current_second, anim_frame) - - # Skip re-render if state unchanged - if not needs_continuous_render and self.last_render_state_persistent == current_state and self.canvas_persistent: - return - - self.last_render_state_persistent = current_state - self.canvas_persistent_dirty = True - - props = self.display_props - bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) - text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) - accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) - - width = int(props.get('persistent_width', 300)) - radius = int(props.get('border_radius', 12)) - padding = int(props.get('content_padding', 16)) - - # Update renderer colors - if self.md_renderer: - self.md_renderer.set_colors(text_color, accent, bg) - - # We need a temp canvas - temp_h = 2000 - temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(temp) - - y = padding - font_bold = self.fonts.get('bold', self.fonts['normal']) - font_normal = self.fonts['normal'] - - for title, info in sorted(self.persistent_infos.items(), key=lambda x: x[1]['added_at']): - # Check expiry but don't delete (logic does that) - if info.get('expiry') and self._persistent_render_time > info['expiry']: - continue - - # Calculate timer width/draw timer for expiry OR progress timer - timer_w = 0 - timer_text = None - - # For progress items with timer, calculate remaining time - if info.get('is_progress') and info.get('is_timer'): - timer_start = info.get('timer_start', self._persistent_render_time) - timer_duration = info.get('timer_duration', 0) - elapsed_time = self._persistent_render_time - timer_start - remaining_seconds = max(0, timer_duration - elapsed_time) - remaining = int(remaining_seconds + 0.999) - - r = remaining - d = r // 86400 - r %= 86400 - h = r // 3600 - r %= 3600 - m = r // 60 - s = r % 60 - - parts = [] - if d > 0: parts.append(f"{d}d") - if h > 0: parts.append(f"{h}h") - if m > 0: parts.append(f"{m}m") - parts.append(f"{s}s") - - timer_text = " ".join(parts) - elif info.get('expiry'): - remaining = max(0, int(info['expiry'] - self._persistent_render_time + 0.999)) - r = remaining - d = r // 86400 - r %= 86400 - h = r // 3600 - r %= 3600 - m = r // 60 - s = r % 60 - - parts = [] - if d > 0: parts.append(f"{d}d") - if h > 0: parts.append(f"{h}h") - if m > 0: parts.append(f"{m}m") - parts.append(f"{s}s") - - timer_text = " ".join(parts) - - if timer_text: - timer_w, _ = self._get_text_size(timer_text, font_bold) - draw.text((width - padding - timer_w, y), timer_text, fill=text_color + (255,), font=font_bold) - timer_w += 10 - - # Title Row - render with emoji support - max_title_w = width - (padding * 2) - timer_w - title_lines = title.split('\n') - final_lines = [] - for line in title_lines: - if self.md_renderer: - wrapped = self.md_renderer._wrap_text(line, font_bold, max_title_w) - final_lines.extend(wrapped) - else: - final_lines.append(line) - - for i, line in enumerate(final_lines): - # Render title with emoji support - self._render_text_with_emoji(draw, line, padding, y, accent + (255,), font_bold) - y += 20 - - if final_lines: - y += 8 - else: - y += 20 - - # Check if this is a progress bar item - if info.get('is_progress'): - progress_maximum = float(info.get('progress_maximum', 100)) - if title in self._progress_animations: - progress_current = self._progress_animations[title]['current'] - else: - progress_current = float(info.get('progress_current', 0)) - - if progress_maximum <= 0: - progress_maximum = 100 - - percentage = min(100, max(0, (progress_current / progress_maximum) * 100)) - progress_color = accent - if info.get('progress_color'): - progress_color = self._hex_to_rgb(info['progress_color']) - - bar_height = 16 - bar_width = width - (padding * 2) - - y += 4 # Margin above progress bar - y = self._draw_progress_bar( - draw, temp, padding, y, bar_width, bar_height, - percentage, bg, progress_color, text_color - ) - - # Draw percentage below progress bar - values_text = f"{percentage:.0f}%" - - values_font = self.fonts.get('normal', font_normal) - try: - bbox = values_font.getbbox(values_text) - values_width = bbox[2] - bbox[0] - except: - values_width = len(values_text) * 7 - - values_x = padding + (bar_width - values_width) // 2 - values_color = tuple(c - 40 if c > 40 else c for c in text_color) + (200,) - draw.text((values_x, y), values_text, fill=values_color, font=values_font) - y += 18 - - desc = info.get('description', '') - if desc: - desc = self._strip_emotions(desc) - y += 4 - y = self.md_renderer.render( - draw, temp, desc, padding, y, width - padding * 2 - ) - else: - desc = info.get('description', '') - if desc: - desc = self._strip_emotions(desc) - y = self.md_renderer.render( - draw, temp, desc, padding, y, width - padding * 2 - ) - - # Add spacing after item - y += 8 - - # Finish - bottom_padding = padding - 4 - final_h = max(60, y + bottom_padding) - - if self.canvas_persistent is None or self.canvas_persistent.width != width or self.canvas_persistent.height != final_h: - self.canvas_persistent = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) - else: - self.canvas_persistent.paste((255, 0, 255, 255), (0, 0, width, final_h)) - - final_draw = ImageDraw.Draw(self.canvas_persistent) - final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, - fill=bg + (255,), outline=(55, 62, 74)) - - crop = temp.crop((0, 0, width, final_h)) - self.canvas_persistent.paste(crop, (0, 0), crop) - - if self.hwnd_persistent: - user32.MoveWindow(self.hwnd_persistent, - int(props.get('persistent_x', 20)), - int(props.get('persistent_y', 300)), - width, final_h, True) - user32.SetLayeredWindowAttributes(self.hwnd_persistent, 0x00FF00FF, self.persistent_opacity, LWA_ALPHA | LWA_COLORKEY) - - # ========================================================================= - # Chat Window Rendering - # ========================================================================= - - def _update_chat_windows(self): - """Update all chat windows (fade logic and auto-hide).""" - now = time.time() - fade_speed = 600 # opacity units per second - - for chat_name, chat in list(self._chat_windows.items()): - props = chat['props'] - auto_hide = props.get('auto_hide', False) - auto_hide_delay = props.get('auto_hide_delay', 10.0) - old_fade_state = chat['fade_state'] - - # Check auto-hide - if auto_hide and chat['messages'] and chat['fade_state'] == 2: - if now - chat['last_message_time'] > auto_hide_delay: - chat['fade_state'] = 3 # Start fade out - - # Handle fade states - if chat['fade_state'] == 1: # Fade in - chat['opacity'] = min(255, chat['opacity'] + fade_speed * self.dt) - if chat['opacity'] >= 255: - chat['opacity'] = 255 - chat['fade_state'] = 2 # Visible - self._chat_window_dirty[chat_name] = True - - elif chat['fade_state'] == 3: # Fade out - chat['opacity'] = max(0, chat['opacity'] - fade_speed * self.dt) - if chat['opacity'] <= 0: - chat['opacity'] = 0 - chat['fade_state'] = 0 # Hidden - chat['visible'] = False - self._chat_window_dirty[chat_name] = True - - # Update layout manager visibility when fade state changes - if old_fade_state != chat['fade_state']: - layout_name = f"chat_{chat_name}" - is_visible = chat['fade_state'] in (1, 2) # Visible when fading in or fully visible - self._layout_manager.set_window_visible(layout_name, is_visible) - - # Update position from layout manager for visible windows - if chat['fade_state'] in (1, 2): - layout_mode = props.get('layout_mode', 'auto') - if layout_mode == 'auto': - layout_name = f"chat_{chat_name}" - pos = self._layout_manager.get_position(layout_name) - if pos: - hwnd = self._chat_hwnds.get(chat_name) - if hwnd: - x, y = pos - w = int(props.get('width', 400)) - h = int(props.get('max_height', 400)) - # Check if position changed - old_x = chat.get('_last_x', -1) - old_y = chat.get('_last_y', -1) - if x != old_x or y != old_y: - user32.MoveWindow(hwnd, x, y, w, h, True) - chat['_last_x'] = x - chat['_last_y'] = y - self._chat_window_dirty[chat_name] = True - - def _draw_chat_windows(self): - """Draw all visible chat windows.""" - for chat_name, chat in self._chat_windows.items(): - if chat['opacity'] <= 0: - continue - - try: - self._draw_chat_frame(chat_name, chat) - except Exception as e: - self._report_exception("draw_chat_windows", e) - - def _draw_chat_frame(self, chat_name: str, chat: Dict): - """Draw a single chat window frame with full markdown support.""" - props = chat['props'] - messages = chat['messages'] - - # Build state hash for caching - msg_state = tuple((m['sender'], m['text'], m.get('color')) for m in messages[-50:]) - props_hash = ( - props.get('width'), props.get('max_height'), - props.get('bg_color'), props.get('text_color'), - props.get('accent_color'), props.get('font_size'), - props.get('message_spacing'), props.get('fade_old_messages'), - ) - current_state = (msg_state, props_hash, int(chat['opacity'])) - - # Skip redraw if unchanged - if chat_name in self._chat_last_render_state: - if self._chat_last_render_state[chat_name] == current_state: - if chat_name in self._chat_canvases and not self._chat_window_dirty.get(chat_name, False): - # Just update opacity and position - hwnd = self._chat_hwnds.get(chat_name) - if hwnd: - user32.SetLayeredWindowAttributes( - hwnd, 0x00FF00FF, int(chat['opacity']), LWA_ALPHA | LWA_COLORKEY - ) - # Also update position from layout manager - layout_mode = props.get('layout_mode', 'auto') - if layout_mode == 'auto': - layout_name = f"chat_{chat_name}" - pos = self._layout_manager.get_position(layout_name) - if pos: - canvas = self._chat_canvases[chat_name] - w, h = canvas.size - x, y = pos - old_x = chat.get('_last_x', -1) - old_y = chat.get('_last_y', -1) - if x != old_x or y != old_y: - user32.MoveWindow(hwnd, x, y, w, h, True) - chat['_last_x'] = x - chat['_last_y'] = y - return - - self._chat_last_render_state[chat_name] = current_state - self._chat_window_dirty[chat_name] = True - - # Extract props - width = int(props.get('width', 400)) - max_height = int(props.get('max_height', 400)) - bg = self._hex_to_rgb(props.get('bg_color', '#1e212b')) - text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) - accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) - radius = int(props.get('border_radius', 12)) - padding = int(props.get('content_padding', 12)) - message_spacing = int(props.get('message_spacing', 8)) - fade_old = props.get('fade_old_messages', True) - sender_colors = props.get('sender_colors', {}) - - # Get fonts - font_bold = self.fonts.get('bold', self.fonts['normal']) - font_normal = self.fonts['normal'] - - # Update markdown renderer colors for this chat - if self.md_renderer: - self.md_renderer.set_colors(text_color, accent, bg) - - # Render messages to temp canvas - temp_h = max(2000, max_height * 3) - temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) - draw = ImageDraw.Draw(temp) - - content_width = width - (padding * 2) - y = padding - - for msg in messages: - sender = msg.get('sender', '') - text = msg.get('text', '') - msg_color = msg.get('color') - - # Determine sender color - if msg_color: - sender_color = self._hex_to_rgb(msg_color) - elif sender in sender_colors: - sender_color = self._hex_to_rgb(sender_colors[sender]) - else: - sender_color = accent - - # Draw sender name with emoji support - sender_display = sender + ":" - self._render_text_with_emoji(draw, sender_display, padding, y, sender_color + (255,), font_bold, emoji_y_offset=3) - y += 20 - - # Render message text with full markdown support - if self.md_renderer and text.strip(): - # Use the markdown renderer for full formatting - y = self.md_renderer.render(draw, temp, text, padding, y, content_width) - else: - # Fallback: simple text - draw.text((padding, y), text, fill=text_color + (255,), font=font_normal) - y += 20 - - y += message_spacing - - # Calculate final height - total_content_height = y + padding - final_h = min(total_content_height, max_height) - fade_zone = 60 # pixels at top that fade out - - # Create final canvas - canvas = Image.new('RGBA', (width, final_h), (255, 0, 255, 255)) - canvas_draw = ImageDraw.Draw(canvas) - - # Draw background - canvas_draw.rounded_rectangle( - [0, 0, width - 1, final_h - 1], - radius=radius, - fill=bg + (255,), - outline=(55, 62, 74) - ) - - # If content overflows, show from bottom (newest messages visible) - if total_content_height > max_height: - # Crop from bottom of temp - crop_y = y + padding - final_h - if crop_y < 0: - crop_y = 0 - crop = temp.crop((0, crop_y, width, crop_y + final_h)) - - # Apply fade gradient at top if enabled - if fade_old and crop_y > 0: - # Create gradient mask for fading old content at top - gradient = Image.new('L', (width, final_h), 255) - gradient_draw = ImageDraw.Draw(gradient) - - for gy in range(fade_zone): - alpha = int(255 * (gy / fade_zone)) - gradient_draw.line([(0, gy), (width, gy)], fill=alpha) - - # Apply gradient to crop alpha - crop_rgba = crop.split() - if len(crop_rgba) == 4: - r, g, b, a = crop_rgba - # Multiply alpha by gradient - from PIL import ImageChops - new_alpha = ImageChops.multiply(a, gradient) - crop.putalpha(new_alpha) - - canvas.paste(crop, (0, 0), crop) - else: - # Content fits, just paste - crop = temp.crop((0, 0, width, final_h)) - canvas.paste(crop, (0, 0), crop) - - self._chat_canvases[chat_name] = canvas - - # Update layout manager with actual rendered height - layout_name = f"chat_{chat_name}" - self._layout_manager.update_window_height(layout_name, final_h) - - # Blit to window - hwnd = self._chat_hwnds.get(chat_name) - if hwnd and chat_name in self._chat_window_dcs: - window_dc, mem_dc = self._chat_window_dcs[chat_name] - - # Get position from layout manager if in auto mode - layout_mode = props.get('layout_mode', 'auto') - if layout_mode == 'auto': - pos = self._layout_manager.get_position(layout_name) - if pos: - x, y_pos = pos - else: - x = int(props.get('x', 20)) - y_pos = int(props.get('y', 20)) - else: - x = int(props.get('x', 20)) - y_pos = int(props.get('y', 20)) - - user32.MoveWindow(hwnd, x, y_pos, width, final_h, True) - - # Blit - self._blit_to_window_chat(hwnd, canvas, window_dc, mem_dc, chat_name) - - # Set opacity - user32.SetLayeredWindowAttributes( - hwnd, 0x00FF00FF, int(chat['opacity']), LWA_ALPHA | LWA_COLORKEY - ) - - self._chat_window_dirty[chat_name] = False - - def _blit_to_window_chat(self, hwnd, canvas, window_dc, mem_dc, chat_name: str): - """Blit a chat canvas to its window using DIB.""" - if not canvas or not hwnd: - return - - w, h = canvas.size - - # Create DIB for this blit - bmi = BITMAPINFO() - bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.bmiHeader.biWidth = w - bmi.bmiHeader.biHeight = -h # Top-down - bmi.bmiHeader.biPlanes = 1 - bmi.bmiHeader.biBitCount = 32 - bmi.bmiHeader.biCompression = BI_RGB - - dib_bits = ctypes.c_void_p() - dib_bitmap = gdi32.CreateDIBSection( - mem_dc, ctypes.byref(bmi), DIB_RGB_COLORS, - ctypes.byref(dib_bits), None, 0 - ) - - if not dib_bitmap or not dib_bits: - return - - old_bitmap = gdi32.SelectObject(mem_dc, dib_bitmap) - - try: - # Copy pixel data - raw = canvas.tobytes("raw", "BGRA") - ctypes.memmove(dib_bits, raw, len(raw)) - - # Blit to window - gdi32.BitBlt(window_dc, 0, 0, w, h, mem_dc, 0, 0, SRCCOPY) - - finally: - gdi32.SelectObject(mem_dc, old_bitmap) - gdi32.DeleteObject(dib_bitmap) - def run_overlay_in_subprocess(command_queue, error_queue=None): """Entry point for running the overlay in a subprocess. diff --git a/hud_server/rendering/markdown.py b/hud_server/rendering/markdown.py index 3c84eeffe..fe62b5b92 100644 --- a/hud_server/rendering/markdown.py +++ b/hud_server/rendering/markdown.py @@ -6,6 +6,7 @@ - Win32 API for window management """ +import copy import os import re from typing import Tuple, Dict, List @@ -24,9 +25,27 @@ ImageFont = None ImageChops = None +# Import cache size constants (with fallback defaults for standalone usage) +try: + from hud_server.constants import ( + MAX_IMAGE_CACHE_SIZE, + MAX_INLINE_TOKEN_CACHE_SIZE, + MAX_TEXT_WRAP_CACHE_SIZE, + MAX_TEXT_SIZE_CACHE_SIZE, + ) +except ImportError: + # Fallback defaults for standalone testing + MAX_IMAGE_CACHE_SIZE = 20 + MAX_INLINE_TOKEN_CACHE_SIZE = 100 + MAX_TEXT_WRAP_CACHE_SIZE = 200 + MAX_TEXT_SIZE_CACHE_SIZE = 2000 + class MarkdownRenderer: - """Full-featured Markdown renderer with typewriter support.""" + """Full-featured Markdown renderer with typewriter support. + + OPTIMIZED: Includes LRU caching for parsed inline tokens and text sizes. + """ def __init__(self, fonts: Dict, colors: Dict, color_emojis: bool = True): self.fonts = fonts @@ -36,9 +55,45 @@ def __init__(self, fonts: Dict, colors: Dict, color_emojis: bool = True): self.letter_spacing = 0 # No letter spacing self.char_count = 0 # For typewriter tracking self._text_size_cache = {} + self._text_size_cache_max = MAX_TEXT_SIZE_CACHE_SIZE self._image_cache = {} # Cache for loaded images + self._image_cache_max = MAX_IMAGE_CACHE_SIZE self._image_load_failures = set() # Track failed URLs to avoid retrying + # LRU cache for parsed inline tokens (expensive to compute) + # Key: text -> List[Dict] of tokens + self._inline_token_cache = {} + self._max_token_cache_size = MAX_INLINE_TOKEN_CACHE_SIZE + + # LRU cache for wrapped text lines + # Key: (text, font_id, max_width) -> List[str] + self._wrap_cache = {} + self._max_wrap_cache_size = MAX_TEXT_WRAP_CACHE_SIZE + + # Cache statistics for monitoring + self._cache_stats = { + 'text_size_hits': 0, + 'text_size_misses': 0, + 'token_cache_hits': 0, + 'token_cache_misses': 0, + 'wrap_cache_hits': 0, + 'wrap_cache_misses': 0, + } + + def get_cache_stats(self) -> Dict[str, int]: + """Get cache statistics for performance monitoring.""" + return dict(self._cache_stats) + + def clear_caches(self): + """Clear all caches. Call when memory pressure is high.""" + self._text_size_cache.clear() + self._image_cache.clear() + self._inline_token_cache.clear() + self._wrap_cache.clear() + # Reset stats + for key in self._cache_stats: + self._cache_stats[key] = 0 + def set_colors(self, text: Tuple, accent: Tuple, bg: Tuple): self.colors = {'text': text, 'accent': accent, 'bg': bg} @@ -90,7 +145,7 @@ def _load_image(self, url: str, max_width: int) -> Image.Image: img = img.resize((max_width, new_height), Image.Resampling.LANCZOS) # Cache the result (limit cache size) - if len(self._image_cache) > 20: + if len(self._image_cache) >= self._image_cache_max: # Remove oldest entry oldest_key = next(iter(self._image_cache)) del self._image_cache[oldest_key] @@ -104,12 +159,18 @@ def _load_image(self, url: str, max_width: int) -> Image.Image: return None def _get_text_size(self, text: str, font) -> Tuple[int, int]: - """Get text size with caching.""" + """Get text size with caching. + + OPTIMIZED: Uses LRU-style cache with statistics tracking. + """ # Use id(font) because font objects are not hashable but are persistent in this app key = (text, id(font)) if key in self._text_size_cache: + self._cache_stats['text_size_hits'] += 1 return self._text_size_cache[key] + self._cache_stats['text_size_misses'] += 1 + try: bbox = font.getbbox(text) size = (int(bbox[2] - bbox[0]), int(bbox[3] - bbox[1])) @@ -117,7 +178,7 @@ def _get_text_size(self, text: str, font) -> Tuple[int, int]: size = (len(text) * 8, 16) # Limit cache size to prevent memory leaks (simple eviction) - if len(self._text_size_cache) > 2000: + if len(self._text_size_cache) > self._text_size_cache_max: self._text_size_cache.clear() self._text_size_cache[key] = size @@ -282,6 +343,18 @@ def _get_emoji_length(self, text: str, pos: int) -> int: return length def _wrap_text(self, text: str, font, max_width: int) -> List[str]: + """Wrap text to fit within max_width. + + OPTIMIZED: Uses caching for repeated wrap calculations. + """ + # Check cache first + cache_key = (text, id(font), max_width) + if cache_key in self._wrap_cache: + self._cache_stats['wrap_cache_hits'] += 1 + return self._wrap_cache[cache_key] + + self._cache_stats['wrap_cache_misses'] += 1 + words = text.split(' ') lines, current = [], [] for word in words: @@ -295,7 +368,16 @@ def _wrap_text(self, text: str, font, max_width: int) -> List[str]: current = [word] if current: lines.append(' '.join(current)) - return lines or [''] + result = lines or [''] + + # Cache result with size limit + if len(self._wrap_cache) >= self._max_wrap_cache_size: + # Simple FIFO eviction + oldest_key = next(iter(self._wrap_cache)) + del self._wrap_cache[oldest_key] + + self._wrap_cache[cache_key] = result + return result # ========================================================================= # INLINE TOKENIZER - supports all inline markdown @@ -304,6 +386,8 @@ def _wrap_text(self, text: str, font, max_width: int) -> List[str]: def tokenize_inline(self, text: str) -> List[Dict]: """Parse inline markdown into tokens. + OPTIMIZED: Uses LRU caching for repeated tokenization of the same text. + Each token includes: - 'type': The token type (text, bold, italic, code, link, etc.) - 'text': The visible text content (without markdown syntax) @@ -314,6 +398,28 @@ def tokenize_inline(self, text: str) -> List[Dict]: This allows the typewriter effect to correctly track position in the original text. """ + # Check cache first - tokens are immutable once parsed + if text in self._inline_token_cache: + self._cache_stats['token_cache_hits'] += 1 + # Deep copy required: callers modify token positions for typewriter effect + return copy.deepcopy(self._inline_token_cache[text]) + + self._cache_stats['token_cache_misses'] += 1 + + tokens = self._tokenize_inline_uncached(text) + + # Cache the result + if len(self._inline_token_cache) >= self._max_token_cache_size: + # Simple FIFO eviction + oldest_key = next(iter(self._inline_token_cache)) + del self._inline_token_cache[oldest_key] + + # Cache a deep copy, return the original (saves one copy on cache miss) + self._inline_token_cache[text] = copy.deepcopy(tokens) + return tokens + + def _tokenize_inline_uncached(self, text: str) -> List[Dict]: + """Internal tokenization without caching. Called by tokenize_inline.""" tokens = [] i = 0 @@ -378,9 +484,35 @@ def tokenize_inline(self, text: str) -> List[Dict]: end = text.find('**', i + 2) if end != -1: content = text[i+2:end] + + # Parse content for emojis to support emoji rendering in bold text + sub_tokens = [] + j = 0 + while j < len(content): + emoji_len = self._get_emoji_length(content, j) + if emoji_len > 0: + # Found an emoji - add it as a sub-token + emoji_text = content[j:j+emoji_len] + sub_tokens.append({ + 'type': 'emoji', + 'text': emoji_text + }) + j += emoji_len + else: + # Regular text + if sub_tokens and sub_tokens[-1].get('type') == 'text': + sub_tokens[-1]['text'] += content[j] + else: + sub_tokens.append({ + 'type': 'text', + 'text': content[j] + }) + j += 1 + tokens.append({ 'type': 'bold', 'text': content, + 'sub_tokens': sub_tokens if len(sub_tokens) > 1 else None, # Only include if there are mixed tokens 'start': start_pos, 'end': end + 2, 'content_start': start_pos + 2, # After ** @@ -1550,6 +1682,71 @@ def _render_cell_content_with_pos(self, draw, text: str, x: int, y: int, max_wid else: display_text = token.get('text', '') + # Check if this token has sub-tokens (e.g., bold with emojis inside) + sub_tokens = token.get('sub_tokens') + if sub_tokens and ttype == 'bold': + # Render sub-tokens with bold font for text and emoji font for emojis + bold_font = tfont + emoji_font = self.fonts.get('emoji', base_font) + space_w, _ = self._get_text_size(' ', bold_font) + + for sub_idx, sub_token in enumerate(sub_tokens): + sub_type = sub_token.get('type') + sub_text = sub_token.get('text', '') + + if not sub_text: + continue + + # Choose font based on sub-token type + if sub_type == 'emoji': + sub_font = emoji_font + is_emoji_token = True + else: + sub_font = bold_font + is_emoji_token = False + + # Render word by word + words = sub_text.split(' ') + for i, word in enumerate(words): + if not word and i > 0: + render_x += space_w + continue + + # Handle space before word + if i > 0: + if render_x + space_w > x + max_width and render_x > x: + render_y += line_h + 4 + render_x = x + else: + render_x += space_w + + word_w, word_h = self._get_text_size(word, sub_font) + + # Check if word fits on current line + if render_x + word_w > x + max_width and render_x > x: + render_y += line_h + 4 + render_x = x + + # Draw word with emoji support + emoji_y_offset = 7 if is_emoji_token else 0 + if is_emoji_token and self.color_emojis: + draw.text((render_x, render_y + emoji_y_offset), word, fill=tcolor, font=sub_font, embedded_color=True) + else: + draw.text((render_x, render_y), word, fill=tcolor, font=sub_font) + + render_x += word_w + + # Add automatic space after emoji if next sub-token is text and doesn't start with space + if is_emoji_token and sub_idx + 1 < len(sub_tokens): + next_token = sub_tokens[sub_idx + 1] + next_text = next_token.get('text', '') + if next_token.get('type') == 'text' and next_text and not next_text.startswith(' '): + render_x += space_w + + continue # Skip the normal rendering below + + # Normal rendering for tokens without sub-tokens + # Calculate visible portion visible_chars = len(display_text) if typewriter_pos != float('inf'): @@ -1615,6 +1812,18 @@ def _render_cell_content_with_pos(self, draw, text: str, x: int, y: int, max_wid render_x += word_w + # Add automatic space after emoji to maintain consistent spacing + # Only if this is the last word in the emoji token and there are more tokens to render + if is_emoji and i == len(words) - 1: + # Check if there's a next token that's not whitespace-only + token_idx = tokens.index(token) if token in tokens else -1 + if token_idx >= 0 and token_idx + 1 < len(tokens): + next_token = tokens[token_idx + 1] + next_text = next_token.get('text', '') + # Add space only if next token is not already whitespace-only + if next_text and not next_text.isspace(): + render_x += space_w + def _count_wrapped_lines_breaking(self, text: str, font, max_width: int) -> int: """Count lines needed when breaking mid-word is allowed but word-wrap is preferred.""" if not text or max_width <= 0: diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index 9a41e0964..de36b0d7c 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -383,40 +383,19 @@ async def test_chat_game(session: TestSession): # Run All Tests # ============================================================================= -async def cleanup_all_chats(session: TestSession): - """Clean up all chat windows created during tests.""" - chat_names = [ - f"chat_{session.session_id}", - f"md_chat_{session.session_id}", - f"conv_{session.session_id}", - f"autohide_{session.session_id}", - f"overflow_{session.session_id}", - ] - for chat_name in chat_names: - try: - await session.delete_chat_window(chat_name) - except Exception: - pass # Ignore errors - window may not exist - - async def run_all_chat_tests(session: TestSession): """Run all chat tests.""" - try: - await test_chat_basic(session) - await asyncio.sleep(1) - await test_chat_markdown(session) - await asyncio.sleep(1) - await test_chat_auto_hide(session) - await asyncio.sleep(1) - await test_chat_overflow(session) - await asyncio.sleep(1) - await test_chat_wingman(session) - finally: - # Always clean up chats when finished - await cleanup_all_chats(session) + await test_chat_basic(session) + await asyncio.sleep(1) + await test_chat_markdown(session) + await asyncio.sleep(1) + await test_chat_auto_hide(session) + await asyncio.sleep(1) + await test_chat_overflow(session) + await asyncio.sleep(1) + await test_chat_wingman(session) if __name__ == "__main__": from hud_server.tests.test_runner import run_interactive_test run_interactive_test(run_all_chat_tests) - diff --git a/hud_server/tests/test_runner.py b/hud_server/tests/test_runner.py index 9754c5802..f76cfa0e8 100644 --- a/hud_server/tests/test_runner.py +++ b/hud_server/tests/test_runner.py @@ -3,12 +3,33 @@ """ import asyncio -from typing import Callable +import httpx +from typing import Callable, Optional from hud_server import HudServer from hud_server.tests.test_session import TestSession, SESSION_CONFIGS +async def check_server_running(host: str = "127.0.0.1", port: int = 7862, timeout: float = 2.0) -> bool: + """ + Check if a HUD server is already running at the specified host/port. + + Args: + host: Host to check + port: Port to check + timeout: Request timeout in seconds + + Returns: + True if server is running and responding to health checks + """ + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(f"http://{host}:{port}/health") + return response.status_code == 200 + except (httpx.ConnectError, httpx.TimeoutException, Exception): + return False + + async def run_test(session: TestSession, test_func: Callable, *args, **kwargs): """Run a single async test on a session.""" try: @@ -59,15 +80,23 @@ def __init__(self, host: str = "127.0.0.1", port: int = 7862, session_ids: list[ self.host = host self.port = port self.session_ids = session_ids or [1] - self.server: HudServer = None + self.server: Optional[HudServer] = None self.sessions: list[TestSession] = [] + self.server_was_running = False # Track if we started the server or it was already running async def __aenter__(self): - # Start server - self.server = HudServer() - started = self.server.start(host=self.host, port=self.port) - if not started: - raise RuntimeError("Failed to start HUD server") + # Check if server is already running + self.server_was_running = await check_server_running(self.host, self.port) + + if not self.server_was_running: + # Start our own server + print(f"[TestContext] Starting HUD server at {self.host}:{self.port}") + self.server = HudServer() + started = self.server.start(host=self.host, port=self.port) + if not started: + raise RuntimeError("Failed to start HUD server") + else: + print(f"[TestContext] Using existing HUD server at {self.host}:{self.port}") # Create sessions base_url = f"http://{self.host}:{self.port}" @@ -79,17 +108,37 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): # Cleanup sessions await cleanup_sessions(self.sessions) - # Stop server - if self.server: + # Stop server only if we started it + if self.server and not self.server_was_running: + print("[TestContext] Stopping HUD server (we started it)") await self.server.stop() + elif self.server_was_running: + print("[TestContext] Leaving existing HUD server running") -def run_interactive_test(test_func: Callable, session_ids: list[int] = None): +def run_interactive_test(test_func: Callable, session_ids: list[int] = None, + host: str = "127.0.0.1", port: int = 7862): """Run a test interactively with automatic server management.""" async def _run(): - async with TestContext(session_ids=session_ids or [1]) as ctx: + async with TestContext(host=host, port=port, session_ids=session_ids or [1]) as ctx: for session in ctx.sessions: await test_func(session) asyncio.run(_run()) + +async def run_test_with_existing_server_check(test_func: Callable, + host: str = "127.0.0.1", port: int = 7862, + session_ids: list[int] = None): + """ + Run a test, checking for an existing server first. + + This is useful for running tests when a HUD server might already be running + (e.g., during development or when multiple tests are run in sequence). + """ + async with TestContext(host=host, port=port, session_ids=session_ids or [1]) as ctx: + print(f"[Test] Running test with {len(ctx.sessions)} session(s)") + for session in ctx.sessions: + await test_func(session) + + diff --git a/hud_server/tests/test_snake.py b/hud_server/tests/test_snake.py index ed396900b..9dc2d4818 100644 --- a/hud_server/tests/test_snake.py +++ b/hud_server/tests/test_snake.py @@ -2,13 +2,21 @@ """ Test Snake - Interactive Snake game using the HUD Server. -A fun Snake game implementation that uses: +An advanced Snake game implementation featuring: - Each grid cell is its own HUD window positioned across the screen - HUDs are created on-demand (only for snake and food, not empty cells) - Manual window placement to create a full-screen grid - Keyboard controls (arrow keys) - HUD messages for start/game over screens and stats -- Auto-ends after 2 minutes + +Features: +- 🌈 Snake body gradient (head to tail color fade) +- ∞ Endless mode (no time limit) +- 🔥 Combo system for eating quickly (2s window) +- 🍎 Multiple foods on screen simultaneously +- 🌟 Rare golden apples worth +5 points +- 🎨 Animated border colors that change with score +- 📊 Real-time stats with combo display Usage: python -m hud_server.tests.test_snake @@ -57,16 +65,27 @@ SCREEN_OFFSET_Y = MARGIN_TOP # Game timing -GAME_DURATION = 120 # 2 minutes -INITIAL_SPEED = 0.15 # seconds between moves -SPEED_INCREMENT = 0.005 # speed increase per food eaten -MIN_SPEED = 0.05 # fastest possible speed +GAME_DURATION = None # None = endless mode, no time limit +INITIAL_SPEED = 0.125 # seconds between moves +SPEED_INCREMENT = 0.0025 # speed increase per food eaten +MIN_SPEED = 0.035 # fastest possible speed (faster for endless mode) + +# Multi-food system +MAX_FOODS = 1 # Maximum number of regular foods on screen +GOLDEN_APPLE_CHANCE = 0.15 # 15% chance for golden apple +GOLDEN_APPLE_POINTS = 5 +GOLDEN_APPLE_DURATION = 10 # seconds before it disappears + +# Combo system +COMBO_TIME_WINDOW = 2.0 # seconds to maintain combo +COMBO_MULTIPLIER = 0.5 # bonus points per combo level # Cell types for display CELL_EMPTY = "empty" CELL_SNAKE_HEAD = "snake_head" CELL_SNAKE_BODY = "snake_body" CELL_FOOD = "food" +CELL_GOLDEN_FOOD = "golden_food" CELL_BORDER = "border" # Colors for different cell types @@ -75,13 +94,61 @@ CELL_SNAKE_HEAD: "#00ff00", CELL_SNAKE_BODY: "#00aa00", CELL_FOOD: "#ff3333", + CELL_GOLDEN_FOOD: "#ffd700", # Gold CELL_BORDER: "#0066cc", } +# Border color progression based on speed/score (extended for endless mode) +BORDER_COLORS = [ + "#0066cc", # 0 - Initial blue + "#0088ff", # 2 - Light blue + "#00aaff", # 4 - Cyan + "#00cccc", # 6 - Turquoise + "#00cc88", # 8 - Teal + "#00cc44", # 10 - Green-blue + "#44cc00", # 12 - Green + "#88cc00", # 14 - Yellow-green + "#cccc00", # 16 - Yellow + "#cc8800", # 18 - Orange + "#cc4400", # 20 - Red-orange + "#cc0000", # 22 - Red + "#cc0044", # 24 - Pink-red + "#cc0088", # 26 - Magenta + "#8800cc", # 28 - Purple + "#4400cc", # 30 - Blue-purple + "#0044cc", # 32 - Deep blue + "#00ccaa", # 34 - Aqua + "#ccaa00", # 36 - Gold + "#cc00cc", # 38 - Fuchsia + "#00ffff", # 40 - Bright cyan + "#ff00ff", # 42 - Bright magenta + "#ffff00", # 44 - Bright yellow + "#ff6600", # 46 - Bright orange + "#ff0066", # 48 - Hot pink + "#6600ff", # 50+ - Electric purple +] + +# Snake body gradient colors (head to tail) +def get_snake_body_color(index: int, total_length: int) -> str: + """Calculate gradient color for snake body segment.""" + if index == 0: + return COLORS[CELL_SNAKE_HEAD] # Head is always bright green + + # Gradient from bright to dark green + ratio = index / max(total_length - 1, 1) + # Start: #00ff00 (bright green), End: #003300 (dark green) + r = 0 + g = int(255 * (1 - ratio * 0.8)) # 255 -> 51 + b = 0 + return f"#{r:02x}{g:02x}{b:02x}" + # Colors COLOR_GAME = "#00ff00" COLOR_GAME_OVER = "#ff0000" +# Current border color index +_current_border_color_index = 0 + # ============================================================================= # Game Logic @@ -109,19 +176,64 @@ def reset(self): self.snake = [(start_x, start_y), (start_x - 1, start_y), (start_x - 2, start_y)] self.direction = Direction.RIGHT self.next_direction = Direction.RIGHT - self.food = self._spawn_food() + + # Multi-food system + self.foods = [] # List of regular food positions + self.golden_food = None # Golden apple position (if any) + self.golden_food_spawn_time = None # When golden apple spawned + + # Spawn initial foods + for _ in range(MAX_FOODS): + self.foods.append(self._spawn_food()) + + # Combo system + self.combo = 0 + self.combo_last_time = None + self.score = 0 self.game_over = False self.game_over_reason = "" - def _spawn_food(self) -> tuple[int, int]: + def _spawn_food(self, force_golden: bool = False) -> tuple[int, int]: """Spawn food at a random empty location.""" while True: x = random.randint(0, self.width - 1) y = random.randint(0, self.height - 1) - if (x, y) not in self.snake: + # Check if position is empty (not snake, not other food, not golden food) + if (x, y) not in self.snake and \ + (x, y) not in self.foods and \ + (x, y) != self.golden_food: return (x, y) + def _spawn_golden_food(self): + """Try to spawn a golden apple.""" + if self.golden_food is None and random.random() < GOLDEN_APPLE_CHANCE: + self.golden_food = self._spawn_food(force_golden=True) + self.golden_food_spawn_time = time.time() + + def _check_golden_food_timeout(self): + """Remove golden food if it's been too long.""" + if self.golden_food and self.golden_food_spawn_time: + if time.time() - self.golden_food_spawn_time > GOLDEN_APPLE_DURATION: + self.golden_food = None + self.golden_food_spawn_time = None + + def _update_combo(self): + """Update combo counter.""" + current_time = time.time() + if self.combo_last_time and current_time - self.combo_last_time <= COMBO_TIME_WINDOW: + self.combo += 1 + else: + self.combo = 1 + self.combo_last_time = current_time + + def _reset_combo(self): + """Reset combo when window expires.""" + if self.combo_last_time: + if time.time() - self.combo_last_time > COMBO_TIME_WINDOW: + self.combo = 0 + self.combo_last_time = None + def set_direction(self, direction: Direction): """Set the next direction (will be applied on next update).""" current = self.direction @@ -136,6 +248,12 @@ def update(self): if self.game_over: return + # Check combo timeout + self._reset_combo() + + # Check golden food timeout + self._check_golden_food_timeout() + self.direction = self.next_direction head_x, head_y = self.snake[0] dx, dy = self.direction.value @@ -156,12 +274,36 @@ def update(self): self.snake.insert(0, new_head) - if new_head == self.food: - self.score += 1 - self.food = self._spawn_food() - else: + ate_food = False + + # Check golden food collision + if new_head == self.golden_food: + ate_food = True + self._update_combo() + bonus = GOLDEN_APPLE_POINTS + int(self.combo * COMBO_MULTIPLIER) + self.score += bonus + self.golden_food = None + self.golden_food_spawn_time = None + # Keep snake growing for all points + for _ in range(GOLDEN_APPLE_POINTS - 1): + pass # Snake will grow by not popping tail + # Check regular food collision + elif new_head in self.foods: + ate_food = True + self._update_combo() + bonus = 1 + int(self.combo * COMBO_MULTIPLIER) + self.score += bonus + self.foods.remove(new_head) + # Spawn new food + self.foods.append(self._spawn_food()) + # Try to spawn golden apple + self._spawn_golden_food() + + if not ate_food: self.snake.pop() + return ate_food # Return whether food was eaten + # ============================================================================= # HUD Cell Management - On-demand creation @@ -185,8 +327,67 @@ def get_cell_group_name(x: int, y: int) -> str: # Track which cells currently have HUDs _active_cell_huds: set = set() +# Track border positions for color animation +_border_positions: list = [] + + +def get_border_positions(game: SnakeGame) -> list[tuple[int, int]]: + """Get all border cell positions in clockwise order starting from top-left.""" + positions = [] + + # Top border (left to right, including both corners) + for x in range(-1, game.width + 1): + positions.append((x, -1)) + + # Right border (top to bottom, skip top corner but include bottom corner) + for y in range(0, game.height + 1): + positions.append((game.width, y)) + + # Bottom border (right to left, skip right corner but include left corner) + for x in range(game.width - 1, -2, -1): + positions.append((x, game.height)) + + # Left border (bottom to top, skip bottom corner but include top) + for y in range(game.height - 1, -1, -1): + positions.append((-1, y)) + + return positions + + +async def animate_border_color_change(session: TestSession, game: SnakeGame, new_color_index: int): + """Animate the border color change by updating cells one by one in a wave.""" + global _current_border_color_index + + if new_color_index >= len(BORDER_COLORS): + new_color_index = len(BORDER_COLORS) - 1 + + new_color = BORDER_COLORS[new_color_index] + _current_border_color_index = new_color_index -async def show_cell(session: TestSession, x: int, y: int, cell_type: str): + # Update COLORS dict for future border cells + COLORS[CELL_BORDER] = new_color + + # Get all border positions if not already cached + global _border_positions + if not _border_positions: + _border_positions = get_border_positions(game) + + # Animate border with pulsating effect + # Update each border cell with a small delay to create wave effect + delay_per_cell = 0.003 # 3ms delay between each cell update + + # For higher scores, add rotation effect by starting from different positions + start_offset = (new_color_index * 5) % len(_border_positions) + + for i in range(len(_border_positions)): + idx = (i + start_offset) % len(_border_positions) + x, y = _border_positions[idx] + await show_cell(session, x, y, CELL_BORDER, color_override=new_color) + if i % 5 == 0: # Every 5 cells, add a small delay + await asyncio.sleep(delay_per_cell) + + +async def show_cell(session: TestSession, x: int, y: int, cell_type: str, color_override: str = None, pulsate: bool = False): """Show or update a cell HUD. Creates it if it doesn't exist.""" if not session._client: return @@ -194,26 +395,32 @@ async def show_cell(session: TestSession, x: int, y: int, cell_type: str): group_name = get_cell_group_name(x, y) screen_x, screen_y = get_cell_position(x, y) + # Use override color if provided, otherwise use default color for cell type + cell_color = color_override if color_override else COLORS[cell_type] + + # Special properties for golden food (pulsating effect) + props = { + "layout_mode": "manual", + "x": screen_x, + "y": screen_y, + "width": CELL_SIZE, + "height": CELL_SIZE, + "bg_color": cell_color, + "opacity": 1.0, + "border_radius": 4, + "font_size": 1, + "content_padding": 0, + "disable_animations": not pulsate, + "disable_transitions": not pulsate, + "duration": 999999, # Endless mode - very long duration + } + await session._client.show_message( group_name=group_name, title=" ", content=" ", # Need non-empty content to keep HUD visible - color=COLORS[cell_type], - props={ - "layout_mode": "manual", - "x": screen_x, - "y": screen_y, - "width": CELL_SIZE, - "height": CELL_SIZE, - "bg_color": COLORS[cell_type], - "opacity": 1.0, - "border_radius": 4, - "font_size": 1, - "content_padding": 0, - "disable_animations": True, - "disable_transitions": True, - "duration": 120, # 2 minutes - same as game duration - } + color=cell_color, + props=props ) _active_cell_huds.add((x, y)) @@ -249,15 +456,21 @@ async def render_initial_state(session: TestSession, game: SnakeGame): # Show borders first await render_borders(session, game) - # Show snake head - await show_cell(session, game.snake[0][0], game.snake[0][1], CELL_SNAKE_HEAD) + # Show snake with gradient + for i, pos in enumerate(game.snake): + if i == 0: + await show_cell(session, pos[0], pos[1], CELL_SNAKE_HEAD) + else: + color = get_snake_body_color(i, len(game.snake)) + await show_cell(session, pos[0], pos[1], CELL_SNAKE_BODY, color_override=color) - # Show snake body - for pos in game.snake[1:]: - await show_cell(session, pos[0], pos[1], CELL_SNAKE_BODY) + # Show all regular foods + for food_pos in game.foods: + await show_cell(session, food_pos[0], food_pos[1], CELL_FOOD) - # Show food - await show_cell(session, game.food[0], game.food[1], CELL_FOOD) + # Show golden food if exists + if game.golden_food: + await show_cell(session, game.golden_food[0], game.golden_food[1], CELL_GOLDEN_FOOD, pulsate=True) async def render_borders(session: TestSession, game: SnakeGame): @@ -279,7 +492,7 @@ async def render_borders(session: TestSession, game: SnakeGame): await show_cell(session, game.width, y, CELL_BORDER) -async def update_display(session: TestSession, old_states: dict, new_states: dict): +async def update_display(session: TestSession, game: SnakeGame, old_states: dict, new_states: dict): """Update only the cells that changed.""" all_positions = set(old_states.keys()) | set(new_states.keys()) @@ -293,43 +506,240 @@ async def update_display(session: TestSession, old_states: dict, new_states: dic await hide_cell(session, pos[0], pos[1]) else: # Cell has content - show/update it - await show_cell(session, pos[0], pos[1], new_type) + cell_type, extra_data = new_type if isinstance(new_type, tuple) else (new_type, None) + + if cell_type == CELL_SNAKE_BODY and extra_data: + # Use gradient color for snake body + await show_cell(session, pos[0], pos[1], cell_type, color_override=extra_data) + elif cell_type == CELL_GOLDEN_FOOD: + # Golden food with pulsating effect + await show_cell(session, pos[0], pos[1], cell_type, pulsate=True) + else: + await show_cell(session, pos[0], pos[1], cell_type) def get_game_state(game: SnakeGame) -> dict: """Get current state of all non-empty cells.""" states = {} + + # Snake with gradient if game.snake: states[game.snake[0]] = CELL_SNAKE_HEAD - for pos in game.snake[1:]: - states[pos] = CELL_SNAKE_BODY - states[game.food] = CELL_FOOD + for i, pos in enumerate(game.snake[1:], start=1): + color = get_snake_body_color(i, len(game.snake)) + states[pos] = (CELL_SNAKE_BODY, color) # Store type and color + + # Regular foods + for food_pos in game.foods: + states[food_pos] = CELL_FOOD + + # Golden food + if game.golden_food: + states[game.golden_food] = CELL_GOLDEN_FOOD + return states +# ============================================================================= +# Combo Display +# ============================================================================= + +async def show_combo_flash(session: TestSession, combo: int): + """Show a flashy combo notification in the center of the screen.""" + if not session._client or combo < 2: + return + + # Different messages for different combo levels + if combo >= 10: + emoji = "🔥💥" + message = f"**INSANE COMBO x{combo}!**" + color = "#ff0066" + elif combo >= 5: + emoji = "🔥" + message = f"**MEGA COMBO x{combo}!**" + color = "#ff6600" + elif combo >= 3: + emoji = "⚡" + message = f"**COMBO x{combo}!**" + color = "#ffaa00" + else: + emoji = "✨" + message = f"**x{combo} Combo**" + color = "#00ff00" + + combo_text = f"{emoji} {message} {emoji}" + + await session._client.show_message( + group_name="snake_combo_flash", + title=" ", + content=combo_text, + color=color, + props={ + "anchor": "center", + "priority": 150, + "layout_mode": "auto", + "width": 400, + "bg_color": "#000000", + "text_color": color, + "accent_color": color, + "opacity": 0.95, + "border_radius": 20, + "font_size": 24, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": False, + "disable_transitions": False, + "duration": 1.5, # Show for 1.5 seconds + } + ) + + # ============================================================================= # Game Screens # ============================================================================= async def show_start_screen(session: TestSession): - """Display the game start screen.""" - start_message = f"""# 🐍 FULL-SCREEN SNAKE GAME 🐍 + """Display the game start screen as individual HUD elements.""" + if not session._client: + return -## How to Play -- Use **Arrow Keys** to control the snake -- Eat 🍎 to grow longer and score points -- Avoid hitting the blue borders and yourself -- Game lasts **2 minutes** + # Title HUD - Highest priority + await session._client.show_message( + group_name="snake_menu_title", + title=" ", # Space to pass validation + content="# 🐍 ENDLESS SNAKE GAME 🐍", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 250, + "layout_mode": "auto", + "width": 600, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 16, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) -## Controls -- **↑ ↓ ← →** : Move snake -- **SPACE** : Start game + # How to Play HUD + await session._client.show_message( + group_name="snake_menu_howto", + title=" ", + content="""## How to Play +- Use **Arrow Keys** to control the snake +- Eat 🍎 red apples to grow and score **+1 point** +- Eat 🌟 **GOLDEN APPLES** for **+5 points** (rare!) +- Build **COMBOS** by eating quickly (2s window) +- Avoid hitting the borders and yourself +- **ENDLESS MODE** - No time limit, play until you lose!""", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 240, + "layout_mode": "auto", + "width": 600, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 14, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) -## Grid Size: {GRID_WIDTH} x {GRID_HEIGHT} + # Features HUD + await session._client.show_message( + group_name="snake_menu_features", + title=" ", + content="""## Features +- 🌈 Snake body gradient (head to tail) +- 🎨 Border colors change with your score +- 🔥 Combo system for bonus points +- ⚡ Multiple foods on screen +- 🌟 Golden apples (disappear after 10s)""", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 230, + "layout_mode": "auto", + "width": 600, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 14, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) -**Press SPACE to begin!**""" + # Controls HUD + await session._client.show_message( + group_name="snake_menu_controls", + title=" ", + content=f"""## Controls +- **↑ ↓ ← →** : Move snake +- **Grid Size:** {GRID_WIDTH} x {GRID_HEIGHT}""", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 220, + "layout_mode": "auto", + "width": 600, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 14, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) - await session.draw_assistant_message(start_message) + # Start Button HUD + await session._client.show_message( + group_name="snake_menu_start", + title=" ", + content="🎮 **Press SPACE to begin your endless journey!** 🎮", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 210, + "layout_mode": "auto", + "width": 600, + "bg_color": "#1a4d1a", + "text_color": "#ffffff", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 16, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, speed: float, force: bool = False): @@ -337,13 +747,22 @@ async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, spee if not session._client: return - time_left = int(GAME_DURATION - elapsed) + # Format elapsed time (endless mode) + minutes = int(elapsed // 60) + seconds = int(elapsed % 60) + time_str = f"{minutes}:{seconds:02d}" + + # Combo display + combo_str = "" + if game.combo > 1: + combo_str = f"\n🔥 **COMBO x{game.combo}** 🔥" - stats_message = f"""**Score:** {game.score} | **Length:** {len(game.snake)} | **Time:** {time_left}s""" + stats_message = f"""**Score:** {game.score} | **Length:** {len(game.snake)} +**Time:** {time_str} | **Speed:** {1/speed:.1f}/s{combo_str}""" await session._client.show_message( group_name="snake_stats", - title="🎮 Snake", + title="🎮 Endless Snake", content=stats_message, color=COLOR_GAME, props={ @@ -361,39 +780,167 @@ async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, spee "typewriter_effect": False, "disable_animations": True, "disable_transitions": True, - "duration": 120, # 2 minutes - same as game duration + "duration": 999999, # Endless mode } ) async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: float): - """Display the game over screen.""" - if game.score >= 30: + """Display the game over screen as individual HUD elements.""" + if not session._client: + return + + # Better score ratings for endless mode + if game.score >= 100: + result_emoji, rating = "👑", "GODLIKE!" + elif game.score >= 75: result_emoji, rating = "🏆", "LEGENDARY!" - elif game.score >= 20: + elif game.score >= 50: + result_emoji, rating = "💎", "MASTER!" + elif game.score >= 30: result_emoji, rating = "🌟", "AMAZING!" - elif game.score >= 10: + elif game.score >= 20: result_emoji, rating = "🎉", "GREAT!" - elif game.score >= 5: + elif game.score >= 10: result_emoji, rating = "👍", "GOOD!" + elif game.score >= 5: + result_emoji, rating = "😊", "NICE!" else: - result_emoji, rating = "😅", "NICE TRY!" + result_emoji, rating = "😅", "KEEP TRYING!" - game_over_message = f"""# {result_emoji} GAME OVER {result_emoji} + # Format time + minutes = int(elapsed // 60) + seconds = int(elapsed % 60) + time_str = f"{minutes}:{seconds:02d}" -## {rating} + # Game Over Title HUD + await session._client.show_message( + group_name="snake_gameover_title", + title=" ", + content=f"# {result_emoji} GAME OVER {result_emoji}", + color=COLOR_GAME_OVER, + props={ + "anchor": "top_left", + "priority": 250, + "layout_mode": "auto", + "width": 500, + "bg_color": "#1a0a0a", + "text_color": "#ff6666", + "accent_color": COLOR_GAME_OVER, + "opacity": 0.98, + "border_radius": 12, + "font_size": 18, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) -### Final Stats + # Rating HUD + await session._client.show_message( + group_name="snake_gameover_rating", + title=" ", + content=f"## {rating}", + color=COLOR_GAME_OVER, + props={ + "anchor": "top_left", + "priority": 240, + "layout_mode": "auto", + "width": 500, + "bg_color": "#0a0e14", + "text_color": "#ffaa00", + "accent_color": COLOR_GAME_OVER, + "opacity": 0.98, + "border_radius": 12, + "font_size": 16, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) + + # Stats HUD + await session._client.show_message( + group_name="snake_gameover_stats", + title=" ", + content=f"""### Final Stats - **Score:** {game.score} - **Final Length:** {len(game.snake)} -- **Time Played:** {int(elapsed)}s / {GAME_DURATION}s -- **Reason:** {game.game_over_reason} - ---- +- **Survival Time:** {time_str} +- **Reason:** {game.game_over_reason}""", + color=COLOR_GAME_OVER, + props={ + "anchor": "top_left", + "priority": 230, + "layout_mode": "auto", + "width": 500, + "bg_color": "#0a0e14", + "text_color": "#f0f0f0", + "accent_color": COLOR_GAME_OVER, + "opacity": 0.98, + "border_radius": 12, + "font_size": 14, + "content_padding": 20, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) -*Press any key to exit*""" + # Play Again Button HUD + await session._client.show_message( + group_name="snake_gameover_playagain", + title=" ", + content="🔄 **Press SPACE to play again**", + color=COLOR_GAME, + props={ + "anchor": "top_left", + "priority": 220, + "layout_mode": "auto", + "width": 500, + "bg_color": "#1a4d1a", + "text_color": "#ffffff", + "accent_color": COLOR_GAME, + "opacity": 0.98, + "border_radius": 12, + "font_size": 15, + "content_padding": 18, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) - await session.draw_assistant_message(game_over_message) + # Exit Button HUD + await session._client.show_message( + group_name="snake_gameover_exit", + title=" ", + content="👋 **Press ESC to exit**", + color="#888888", + props={ + "anchor": "top_left", + "priority": 210, + "layout_mode": "auto", + "width": 500, + "bg_color": "#1a1a1a", + "text_color": "#cccccc", + "accent_color": "#888888", + "opacity": 0.98, + "border_radius": 12, + "font_size": 15, + "content_padding": 18, + "typewriter_effect": False, + "disable_animations": True, + "disable_transitions": True, + "duration": 3600, + } + ) # ============================================================================= @@ -402,6 +949,13 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: async def test_snake_game(session: TestSession): """Run the interactive Snake game.""" + global _current_border_color_index, _border_positions, _active_cell_huds + + # Reset global state for new game + _current_border_color_index = 0 + _border_positions = [] + _active_cell_huds = set() + print(f"[{session.name}] Starting Full-Screen Snake Game...") game = SnakeGame() @@ -413,10 +967,19 @@ async def test_snake_game(session: TestSession): while not keyboard.is_pressed('space'): await asyncio.sleep(0.1) + # Wait for key release to avoid double-triggering + await asyncio.sleep(0.2) + + # Hide start menu before game starts + if session._client: + await session._client.delete_group("snake_menu_title") + await session._client.delete_group("snake_menu_howto") + await session._client.delete_group("snake_menu_features") + await session._client.delete_group("snake_menu_controls") + await session._client.delete_group("snake_menu_start") + print(f"[{session.name}] Game started!") - # Hide start screen - await session.hide() # Render initial game state (just snake + food) await render_initial_state(session, game) @@ -451,26 +1014,22 @@ def on_arrow_right(e): start_time = time.time() current_speed = INITIAL_SPEED last_update = start_time - last_stats = {"score": -1, "time": -1} + last_stats = {"score": -1, "time": -1, "combo": -1} elapsed = 0.0 + last_combo_shown = 0 try: while game_running: current_time = time.time() elapsed = current_time - start_time - # Check time limit - if elapsed >= GAME_DURATION: - game.game_over = True - game.game_over_reason = "Time's up!" - break - # Update game at current speed if current_time - last_update >= current_speed: old_states = get_game_state(game) old_score = game.score + old_combo = game.combo - game.update() + ate_food = game.update() last_update = current_time if game.game_over: @@ -479,15 +1038,27 @@ def on_arrow_right(e): new_states = get_game_state(game) - # Speed up on food eaten + # Speed up on food eaten and animate border color change if game.score > old_score: current_speed = max(MIN_SPEED, INITIAL_SPEED - (game.score * SPEED_INCREMENT)) + # Trigger border color animation based on score + # Change color every 2 points to make it more visible + new_color_index = min(game.score // 2, len(BORDER_COLORS) - 1) + if new_color_index != _current_border_color_index: + # Start animation in background (non-blocking) + asyncio.create_task(animate_border_color_change(session, game, new_color_index)) + + # Show combo flash when reaching combo milestones + if game.combo >= 2 and game.combo != old_combo: + asyncio.create_task(show_combo_flash(session, game.combo)) + # Update only changed cells - await update_display(session, old_states, new_states) + await update_display(session, game, old_states, new_states) - # Update stats only when changed - current_stats = {"score": game.score, "time": int(GAME_DURATION - elapsed)} + # Update stats when score, time, or combo changes + current_minute = int(elapsed // 60) + current_stats = {"score": game.score, "time": current_minute, "combo": game.combo} if current_stats != last_stats: await show_stats(session, game, elapsed, current_speed) last_stats = current_stats.copy() @@ -497,12 +1068,56 @@ def on_arrow_right(e): # Cleanup and show game over await cleanup_all_cells(session) await show_game_over_screen(session, game, elapsed) - await asyncio.sleep(5) - finally: + # Wait for player decision: SPACE to play again, ESC to exit + print(f"[{session.name}] Game Over! Press SPACE to play again or ESC to exit...") + play_again = False + + while True: + if keyboard.is_pressed('space'): + play_again = True + print(f"[{session.name}] Restarting game...") + break + elif keyboard.is_pressed('esc'): + play_again = False + print(f"[{session.name}] Exiting game...") + break + await asyncio.sleep(0.1) + + # Wait for key release before continuing + await asyncio.sleep(0.3) + + # Hide game over menu + if session._client: + await session._client.delete_group("snake_gameover_title") + await session._client.delete_group("snake_gameover_rating") + await session._client.delete_group("snake_gameover_stats") + await session._client.delete_group("snake_gameover_playagain") + await session._client.delete_group("snake_gameover_exit") + + # Cleanup keyboard hooks before returning keyboard.unhook_all() - await session.hide() - print(f"[{session.name}] Snake game ended. Final score: {game.score}") + + return play_again + + except Exception as e: + print(f"[{session.name}] Error in game: {e}") + keyboard.unhook_all() + # Cleanup all menu HUDs + if session._client: + # Start menu + await session._client.delete_group("snake_menu_title") + await session._client.delete_group("snake_menu_howto") + await session._client.delete_group("snake_menu_features") + await session._client.delete_group("snake_menu_controls") + await session._client.delete_group("snake_menu_start") + # Game over menu + await session._client.delete_group("snake_gameover_title") + await session._client.delete_group("snake_gameover_rating") + await session._client.delete_group("snake_gameover_stats") + await session._client.delete_group("snake_gameover_playagain") + await session._client.delete_group("snake_gameover_exit") + return False # ============================================================================= @@ -510,11 +1125,13 @@ def on_arrow_right(e): # ============================================================================= async def run_snake_test(): - """Run the Snake game test.""" + """Run the enhanced endless Snake game test with advanced features.""" from hud_server.tests.test_runner import TestContext print("=" * 60) - print("SNAKE GAME TEST") + print("ENDLESS SNAKE GAME - ENHANCED EDITION") + print("=" * 60) + print("Features: Gradient Snake | Combos | Golden Apples | Animated Borders") print("=" * 60) session_config = { @@ -543,8 +1160,20 @@ async def run_snake_test(): session.config = session_config session.name = "Snake" - print("HUD Server started. Get ready to play Snake! 🐍\n") - await test_snake_game(session) + print("HUD Server started. Get ready for ENDLESS Snake! 🐍✨\n") + print("🌈 Gradient Snake | 🔥 Combos | 🌟 Golden Apples | 🎨 Animated Borders\n") + + # Play again loop + while True: + play_again = await test_snake_game(session) + if not play_again: + print("Thanks for playing! 🐍✨") + break + else: + print("\n" + "=" * 60) + print("Starting new game...") + print("=" * 60 + "\n") + await asyncio.sleep(0.5) # Small delay before restart if __name__ == "__main__": diff --git a/skills/hud/default_config.yaml b/skills/hud/default_config.yaml index e11a4ac4d..6251afd95 100644 --- a/skills/hud/default_config.yaml +++ b/skills/hud/default_config.yaml @@ -29,7 +29,7 @@ custom_properties: - id: bg_color name: Background Color - hint: Background color of HUD elements (hex format). + hint: Background color of HUD elements (hex format, e.g. #1e212b or #1e212b80 for 50% transparent). property_type: string value: "#1e212b" required: false diff --git a/skills/hud/main.py b/skills/hud/main.py index 1d3d5483a..e8360ae6b 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -100,14 +100,14 @@ async def validate(self) -> list[WingmanInitializationError]: "bottom_left", "bottom_center", "bottom_right" ] - # Color validation helper + # Color validation helper - supports #RGB, #RRGGBB, or #RRGGBBAA formats def is_valid_hex_color(color: str) -> bool: if not isinstance(color, str): return False if not color.startswith('#'): return False hex_part = color[1:] - if len(hex_part) not in (3, 6): + if len(hex_part) not in (3, 6, 8): # 3, 6, or 8 hex chars (with alpha) return False try: int(hex_part, 16) From d34e5710bb34595c65ccc0c4fc1ce4ddad837e7b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:40:05 +0100 Subject: [PATCH 08/27] Merge consecutive same-sender chat messages in HUD chat windows (#12) * Initial plan * Append consecutive same-sender chat messages instead of creating new entries When a message is added to a chat window from the same sender as the last message, the text is appended to the last message (separated by a space) instead of creating a new message entry with a repeated sender name. Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Update chat tests for same-sender message merging behavior - test_chat_markdown: alternate senders so each markdown feature renders separately - test_chat_auto_hide: use different sender for second message to prevent merging - test_chat_overflow: unique sender per message to prevent merging during overflow test - Add test_chat_message_merging: dedicated test for consecutive same-sender merging Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --- hud_server/hud_manager.py | 18 +++++++--- hud_server/overlay/overlay.py | 25 ++++++++++---- hud_server/tests/test_chat.py | 63 ++++++++++++++++++++++++++++++++--- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py index efedd8e25..9a47dc1c7 100644 --- a/hud_server/hud_manager.py +++ b/hud_server/hud_manager.py @@ -638,11 +638,19 @@ def send_chat_message( return False state = self._groups[window_name] - state.chat_messages.append(ChatMessage( - sender=sender, - text=text, - color=color - )) + + # Append to last message if same sender + if ( + state.chat_messages + and state.chat_messages[-1].sender == sender + ): + state.chat_messages[-1].text += " " + text + else: + state.chat_messages.append(ChatMessage( + sender=sender, + text=text, + color=color + )) # Limit chat history max_messages = state.props.get("max_messages", 50) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 02e6dfa01..75cd36676 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -2444,14 +2444,25 @@ def _handle_message(self, msg): window_name = f"chat_{chat_name}" if chat_name and window_name in self._windows: now = time.time() - message = { - 'sender': msg.get('sender', ''), - 'text': msg.get('text', ''), - 'color': msg.get('color'), - 'timestamp': now, - } win = self._windows[window_name] - win['messages'].append(message) + sender = msg.get('sender', '') + + # Append to last message if same sender + if ( + win['messages'] + and win['messages'][-1]['sender'] == sender + ): + win['messages'][-1]['text'] += " " + msg.get('text', '') + win['messages'][-1]['timestamp'] = now + else: + message = { + 'sender': sender, + 'text': msg.get('text', ''), + 'color': msg.get('color'), + 'timestamp': now, + } + win['messages'].append(message) + win['last_message_time'] = now # Trim old messages if over limit diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index de36b0d7c..7ea739912 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -5,6 +5,7 @@ - Natural conversation flow with realistic timing - Full markdown support in messages - Multiple participants with custom colors +- Consecutive same-sender message merging - Auto-hide and manual show/hide - Message overflow with fade effect """ @@ -231,7 +232,7 @@ async def test_chat_markdown(session: TestSession): ) await asyncio.sleep(0.5) - # Test various markdown features + # Test various markdown features (alternate senders so each renders separately) await session.send_chat_message(chat_name, "User", "Show me markdown features") await asyncio.sleep(1.5) @@ -239,6 +240,9 @@ async def test_chat_markdown(session: TestSession): "**Bold**, *italic*, `code`, ~~strike~~") await asyncio.sleep(1.5) + await session.send_chat_message(chat_name, "User", "How about lists?") + await asyncio.sleep(1.5) + await session.send_chat_message(chat_name, session.name, """Here's a list: - First item - Second item @@ -246,12 +250,18 @@ async def test_chat_markdown(session: TestSession): - Third item""") await asyncio.sleep(2) + await session.send_chat_message(chat_name, "User", "And code?") + await asyncio.sleep(1.5) + await session.send_chat_message(chat_name, session.name, """Code block: ```python print("Hello!") ```""") await asyncio.sleep(2) + await session.send_chat_message(chat_name, "User", "Tables?") + await asyncio.sleep(1.5) + await session.send_chat_message(chat_name, session.name, """| Col1 | Col2 | |------|------| | A | B | @@ -326,7 +336,7 @@ async def test_chat_auto_hide(session: TestSession): await session.send_chat_message(chat_name, "Test", "This will auto-hide in 3 seconds...") await asyncio.sleep(1) - await session.send_chat_message(chat_name, "Test", "Timer resets with each message") + await session.send_chat_message(chat_name, "Info", "Timer resets with each message") await asyncio.sleep(4) # Wait for auto-hide # Should be hidden now, send new message to show again @@ -354,9 +364,9 @@ async def test_chat_overflow(session: TestSession): ) await asyncio.sleep(0.5) - # Send many messages to overflow + # Send many messages to overflow (unique senders per message to prevent merging) for i in range(15): - await session.send_chat_message(chat_name, f"User{i%3}", f"Message #{i+1}: Testing overflow behavior") + await session.send_chat_message(chat_name, f"User{i}", f"Message #{i+1}: Testing overflow behavior") await asyncio.sleep(0.4) await asyncio.sleep(2) @@ -364,6 +374,49 @@ async def test_chat_overflow(session: TestSession): print(f"[{session.name}] Overflow test complete") +async def test_chat_message_merging(session: TestSession): + """Test that consecutive messages from the same sender are merged.""" + print(f"[{session.name}] Testing message merging...") + + chat_name = f"merge_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=45, + layout_mode="auto", + width=400, + max_height=300, + auto_hide=False, + sender_colors={ + "Alice": "#4cd964", + "Bob": "#00aaff", + }, + ) + await asyncio.sleep(0.5) + + # Same sender consecutive - should merge into one block + await session.send_chat_message(chat_name, "Alice", "Hello!") + await asyncio.sleep(0.8) + await session.send_chat_message(chat_name, "Alice", "How are you?") + await asyncio.sleep(0.8) + await session.send_chat_message(chat_name, "Alice", "I have a question.") + await asyncio.sleep(1.5) + + # Different sender - should start a new block + await session.send_chat_message(chat_name, "Bob", "Hi Alice!") + await asyncio.sleep(0.8) + await session.send_chat_message(chat_name, "Bob", "I'm doing great.") + await asyncio.sleep(1.5) + + # Switch back - new block for Alice again + await session.send_chat_message(chat_name, "Alice", "Glad to hear it!") + await asyncio.sleep(2) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Message merging test complete") + + async def test_chat_wingman(session: TestSession): """Run the Wingman conversation scenario.""" await test_chat_conversation(session, CONVERSATION_WINGMAN) @@ -389,6 +442,8 @@ async def run_all_chat_tests(session: TestSession): await asyncio.sleep(1) await test_chat_markdown(session) await asyncio.sleep(1) + await test_chat_message_merging(session) + await asyncio.sleep(1) await test_chat_auto_hide(session) await asyncio.sleep(1) await test_chat_overflow(session) From 304a2eca9874638756e455d3433da40e88477b76 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 14:49:36 +0100 Subject: [PATCH 09/27] Add chat message update support via message IDs (#13) * Initial plan * Add chat message update feature with message IDs - Add `id` field (UUID) to ChatMessage dataclass - Change send_chat_message to return message ID (or merged message's ID) - Add update_chat_message method to HudManager - Add ChatMessageResponse and ChatMessageUpdateRequest models - Add PUT /chat/message endpoint for updating messages by ID - Update POST /chat/message to return message_id in response - Update HTTP client (async + sync) with update_chat_message method - Update TestSession with update_chat_message helper - Update overlay handler to track message IDs and handle updates - Update state serialization/deserialization to include message IDs - Add test_chat_message_update test case - Update README documentation with message update API Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Address code review: keep sender/text required in ChatMessage, fix comment Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --- hud_server/README.md | 30 +++++++++++- hud_server/http_client.py | 21 ++++++++- hud_server/hud_manager.py | 57 ++++++++++++++++++++--- hud_server/models.py | 21 +++++++++ hud_server/overlay/overlay.py | 15 ++++++ hud_server/server.py | 22 +++++++-- hud_server/tests/test_chat.py | 78 ++++++++++++++++++++++++++++++++ hud_server/tests/test_session.py | 21 +++++++-- 8 files changed, 249 insertions(+), 16 deletions(-) diff --git a/hud_server/README.md b/hud_server/README.md index 19e451ed0..cc2f0add9 100644 --- a/hud_server/README.md +++ b/hud_server/README.md @@ -141,11 +141,39 @@ with HudHttpClientSync() as client: - `POST /chat/window` - Create a chat window - `DELETE /chat/window/{name}` - Delete a chat window -- `POST /chat/message` - Send a chat message +- `POST /chat/message` - Send a chat message (returns `message_id`) +- `PUT /chat/message` - Update an existing message by ID - `DELETE /chat/messages/{name}` - Clear chat history - `POST /chat/show/{name}` - Show a hidden chat window - `POST /chat/hide/{name}` - Hide a chat window +#### Message Updates + +When sending a chat message via `POST /chat/message`, the response includes a `message_id` that uniquely identifies the message. This ID can be used to update the message content later via `PUT /chat/message`. + +If consecutive messages are sent by the same sender, they are automatically merged into a single message. In this case, `POST /chat/message` returns the existing merged message's ID, so updates will apply to the combined message. + +**Send a message:** +```python +response = await client.send_chat_message( + window_name="my_chat", + sender="Assistant", + text="Processing your request..." +) +message_id = response["message_id"] +``` + +**Update the message later:** +```python +await client.update_chat_message( + window_name="my_chat", + message_id=message_id, + text="Done! Here are your results: ..." +) +``` + +This works for both the most recent message and any past message still in the chat history. + ### State Management - `GET /state/{group_name}` - Get group state for persistence diff --git a/hud_server/http_client.py b/hud_server/http_client.py index 2e446f9d7..b9ddda293 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -475,7 +475,7 @@ async def send_chat_message( text: str, color: Optional[str] = None ) -> Optional[dict]: - """Send a message to a chat window.""" + """Send a message to a chat window. Returns response with message_id.""" data = { "window_name": window_name, "sender": sender, @@ -486,6 +486,20 @@ async def send_chat_message( return await self._request("POST", "/chat/message", data) + async def update_chat_message( + self, + window_name: str, + message_id: str, + text: str + ) -> Optional[dict]: + """Update an existing chat message's text content by its ID.""" + data = { + "window_name": window_name, + "message_id": message_id, + "text": text + } + return await self._request("PUT", "/chat/message", data) + async def clear_chat_window(self, name: str) -> Optional[dict]: """Clear all messages from a chat window.""" encoded_name = quote(name, safe='') @@ -774,6 +788,11 @@ def send_chat_message(self, window_name: str, sender: str, text: str, color: Opt window_name, sender, text, color )) if self._client else None + def update_chat_message(self, window_name: str, message_id: str, text: str): + return self._run_coro(self._client.update_chat_message( + window_name, message_id, text + )) if self._client else None + def clear_chat_window(self, name: str): return self._run_coro(self._client.clear_chat_window(name)) if self._client else None diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py index 9a47dc1c7..8788d7970 100644 --- a/hud_server/hud_manager.py +++ b/hud_server/hud_manager.py @@ -9,6 +9,7 @@ import threading import time +import uuid from typing import Any, Optional from dataclasses import dataclass, field @@ -57,6 +58,7 @@ class ChatMessage: """A chat message.""" sender: str text: str + id: str = field(default_factory=lambda: str(uuid.uuid4())) color: Optional[str] = None timestamp: float = field(default_factory=time.time) @@ -106,6 +108,7 @@ def to_dict(self) -> dict[str, Any]: }, "chat_messages": [ { + "id": msg.id, "sender": msg.sender, "text": msg.text, "color": msg.color, @@ -163,6 +166,7 @@ def from_dict(cls, data: dict[str, Any]) -> "GroupState": # Restore chat messages for msg_data in data.get("chat_messages", []): state.chat_messages.append(ChatMessage( + id=msg_data.get("id", str(uuid.uuid4())), sender=msg_data.get("sender", ""), text=msg_data.get("text", ""), color=msg_data.get("color"), @@ -631,11 +635,16 @@ def send_chat_message( sender: str, text: str, color: Optional[str] = None - ) -> bool: - """Send a message to a chat window.""" + ) -> Optional[str]: + """Send a message to a chat window. + + Returns the message ID if successful, None if the window was not found. + If the message is merged with the previous message from the same sender, + the existing message's ID is returned. + """ with self._lock: if window_name not in self._groups: - return False + return None state = self._groups[window_name] @@ -645,12 +654,15 @@ def send_chat_message( and state.chat_messages[-1].sender == sender ): state.chat_messages[-1].text += " " + text + message_id = state.chat_messages[-1].id else: - state.chat_messages.append(ChatMessage( + msg = ChatMessage( sender=sender, text=text, color=color - )) + ) + state.chat_messages.append(msg) + message_id = msg.id # Limit chat history max_messages = state.props.get("max_messages", 50) @@ -660,12 +672,45 @@ def send_chat_message( self._notify_callbacks({ "type": "chat_message", "name": window_name, + "id": message_id, "sender": sender, "text": text, "color": color }) - return True + return message_id + + def update_chat_message( + self, + window_name: str, + message_id: str, + text: str + ) -> bool: + """Update an existing chat message's text content. + + Finds the message by ID in the specified chat window and replaces its text. + Works for both current and past messages in the chat history. + + Returns True if the message was found and updated, False otherwise. + """ + with self._lock: + if window_name not in self._groups: + return False + + state = self._groups[window_name] + + for msg in state.chat_messages: + if msg.id == message_id: + msg.text = text + self._notify_callbacks({ + "type": "update_chat_message", + "name": window_name, + "id": message_id, + "text": text + }) + return True + + return False def clear_chat_window(self, name: str) -> bool: """Clear all messages from a chat window.""" diff --git a/hud_server/models.py b/hud_server/models.py index d48e31250..0461bd0bb 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -264,6 +264,19 @@ class ChatMessageRequest(BaseModel): """Optional sender color override.""" +class ChatMessageUpdateRequest(BaseModel): + """Request to update an existing chat message.""" + + window_name: str + """Name of the chat window containing the message.""" + + message_id: str + """ID of the message to update (returned by send_chat_message).""" + + text: str + """New message text to replace the existing content.""" + + class CreateChatWindowRequest(BaseModel): """Request to create a chat window.""" @@ -317,6 +330,14 @@ class OperationResponse(BaseModel): message: Optional[str] = None +class ChatMessageResponse(BaseModel): + """Response from sending a chat message, includes the message ID.""" + + status: str = "ok" + message_id: str + """The unique ID of the message (new or merged).""" + + class ErrorResponse(BaseModel): """Error response.""" diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 75cd36676..80ecb84ae 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -2446,6 +2446,7 @@ def _handle_message(self, msg): now = time.time() win = self._windows[window_name] sender = msg.get('sender', '') + message_id = msg.get('id', '') # Append to last message if same sender if ( @@ -2456,6 +2457,7 @@ def _handle_message(self, msg): win['messages'][-1]['timestamp'] = now else: message = { + 'id': message_id, 'sender': sender, 'text': msg.get('text', ''), 'color': msg.get('color'), @@ -2479,6 +2481,19 @@ def _handle_message(self, msg): win['canvas_dirty'] = True + elif t == 'update_chat_message': + chat_name = msg.get('name') + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + win = self._windows[window_name] + message_id = msg.get('id', '') + new_text = msg.get('text', '') + for m in win['messages']: + if m.get('id') == message_id: + m['text'] = new_text + win['canvas_dirty'] = True + break + elif t == 'clear_chat_window': chat_name = msg.get('name') window_name = f"chat_{chat_name}" diff --git a/hud_server/server.py b/hud_server/server.py index 25631a551..960a82bc3 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -36,11 +36,13 @@ ProgressRequest, TimerRequest, ChatMessageRequest, + ChatMessageUpdateRequest, CreateChatWindowRequest, StateRestoreRequest, HealthResponse, GroupStateResponse, OperationResponse, + ChatMessageResponse, ) # Try to import overlay support (bundled with hud_server) @@ -396,16 +398,28 @@ async def delete_chat_window(name: str): raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") return OperationResponse(status="ok") - @app.post("/chat/message", response_model=OperationResponse, tags=["chat"]) + @app.post("/chat/message", response_model=ChatMessageResponse, tags=["chat"]) async def send_chat_message(request: ChatMessageRequest): - """Send a message to a chat window.""" - if not self.manager.send_chat_message( + """Send a message to a chat window. Returns the message ID.""" + message_id = self.manager.send_chat_message( window_name=request.window_name, sender=request.sender, text=request.text, color=request.color - ): + ) + if message_id is None: raise HTTPException(status_code=404, detail=f"Chat window '{request.window_name}' not found") + return ChatMessageResponse(status="ok", message_id=message_id) + + @app.put("/chat/message", response_model=OperationResponse, tags=["chat"]) + async def update_chat_message(request: ChatMessageUpdateRequest): + """Update an existing chat message's text content by its ID.""" + if not self.manager.update_chat_message( + window_name=request.window_name, + message_id=request.message_id, + text=request.text + ): + raise HTTPException(status_code=404, detail=f"Message '{request.message_id}' not found in window '{request.window_name}'") return OperationResponse(status="ok") @app.delete("/chat/messages/{window_name}", response_model=OperationResponse, tags=["chat"]) diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index 7ea739912..9af761e59 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -432,6 +432,82 @@ async def test_chat_game(session: TestSession): await test_chat_conversation(session, CONVERSATION_GAME) +async def test_chat_message_update(session: TestSession): + """Test updating existing chat messages by ID. + + Demonstrates: + - Sending a message and getting back its ID + - Updating a recent message's content + - Updating an older (past) message's content + - Verifying that message IDs are returned for merged messages too + """ + print(f"[{session.name}] Testing message update...") + + chat_name = f"update_{session.session_id}" + + await session.create_chat_window( + name=chat_name, + anchor=session.config.get("anchor", "top_left"), + priority=35, + layout_mode="auto", + width=400, + max_height=300, + auto_hide=False, + sender_colors={ + "Alice": "#4cd964", + "Bob": "#00aaff", + }, + ) + await asyncio.sleep(0.5) + + # Send a message and get its ID + msg1_id = await session.send_chat_message(chat_name, "Alice", "Hello! This is my first message.") + assert msg1_id is not None, "Expected a message ID back from send_chat_message" + print(f" Message 1 ID: {msg1_id}") + await asyncio.sleep(1) + + # Send another message from a different sender + msg2_id = await session.send_chat_message(chat_name, "Bob", "Hey Alice, how are you?") + assert msg2_id is not None, "Expected a message ID back from send_chat_message" + assert msg2_id != msg1_id, "Different senders should produce different message IDs" + print(f" Message 2 ID: {msg2_id}") + await asyncio.sleep(1) + + # Update the most recent message (current) + await session.update_chat_message(chat_name, msg2_id, "Hey Alice, how are you doing today?") + print(f" Updated message 2 (current)") + await asyncio.sleep(1.5) + + # Update the older message (past) — should also work + await session.update_chat_message(chat_name, msg1_id, "Hello! This message was **updated** after the fact.") + print(f" Updated message 1 (past)") + await asyncio.sleep(1.5) + + # Test that merged messages return the existing ID + msg3_id = await session.send_chat_message(chat_name, "Alice", "I'm adding to my updated message.") + # Bob was the last sender, so this creates a new message for Alice + assert msg3_id is not None, "Expected a message ID back" + print(f" Message 3 ID: {msg3_id}") + await asyncio.sleep(0.8) + + # Now send another from Alice — should merge and return same ID + msg3_merged_id = await session.send_chat_message(chat_name, "Alice", "This should merge!") + assert msg3_merged_id == msg3_id, "Consecutive same-sender messages should return the same ID" + print(f" Merged message ID matches: {msg3_merged_id == msg3_id}") + await asyncio.sleep(1.5) + + # Update the merged message + await session.update_chat_message( + chat_name, msg3_id, + "Merged and then **updated** — all via the same ID!" + ) + print(f" Updated merged message") + await asyncio.sleep(2) + + await session.delete_chat_window(chat_name) + print(f"[{session.name}] Message update test complete") + + # ============================================================================= # Run All Tests # ============================================================================= @@ -444,6 +520,8 @@ async def run_all_chat_tests(session: TestSession): await asyncio.sleep(1) await test_chat_message_merging(session) await asyncio.sleep(1) + await test_chat_message_update(session) + await asyncio.sleep(1) await test_chat_auto_hide(session) await asyncio.sleep(1) await test_chat_overflow(session) diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py index 633789de4..99ad43c75 100644 --- a/hud_server/tests/test_session.py +++ b/hud_server/tests/test_session.py @@ -289,16 +289,29 @@ async def create_chat_window(self, name: str, **props): await self._client.create_chat_window(name=name, **props) async def send_chat_message(self, window_name: str, sender: str, text: str, - color: Optional[str] = None): - """Send a message to a chat window.""" + color: Optional[str] = None) -> Optional[str]: + """Send a message to a chat window. Returns the message ID.""" if not self._client: - return - await self._client.send_chat_message( + return None + result = await self._client.send_chat_message( window_name=window_name, sender=sender, text=text, color=color, ) + if result: + return result.get("message_id") + return None + + async def update_chat_message(self, window_name: str, message_id: str, text: str): + """Update an existing chat message's text content by its ID.""" + if not self._client: + return + await self._client.update_chat_message( + window_name=window_name, + message_id=message_id, + text=text, + ) async def clear_chat_window(self, name: str): """Clear a chat window.""" From e685c50660024d6b74c4c2339eaa40f60add6078 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:12:09 +0100 Subject: [PATCH 10/27] Reactive HUD foreground management via WinEvent hook (#14) * Initial plan * Use reactive WinEvent hook for HUD foreground management instead of polling Replace the removed polling loop with an event-driven approach using SetWinEventHook(EVENT_SYSTEM_FOREGROUND). The callback sets a thread-safe threading.Event flag, and the main render loop checks/clears it each frame, re-applying HWND_TOPMOST only when a foreground change is actually detected. This is both more performant (zero cost when no changes occur) and more responsive (reacts instantly to foreground changes) than the old 50ms poll. Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> * Add log line when HUD foreground change is detected and repositioned Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: SawPsyder <44277746+SawPsyder@users.noreply.github.com> --- hud_server/overlay/overlay.py | 70 ++++++++++++++++++++++++++++++++--- hud_server/platform/win32.py | 32 ++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 80ecb84ae..3c07140fb 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -35,9 +35,11 @@ BITMAPINFOHEADER, BITMAPINFO, MSG, WS_POPUP, WS_EX_LAYERED, WS_EX_TRANSPARENT, WS_EX_TOPMOST, WS_EX_TOOLWINDOW, WS_EX_NOACTIVATE, LWA_ALPHA, LWA_COLORKEY, SWP_SHOWWINDOW, - SWP_NOACTIVATE, SRCCOPY, DIB_RGB_COLORS, BI_RGB, + SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SRCCOPY, DIB_RGB_COLORS, BI_RGB, SW_SHOWNOACTIVATE, HWND_TOPMOST, PM_REMOVE, - _ensure_window_class, _class_name + _ensure_window_class, _class_name, + force_on_top, WINEVENTPROC, + EVENT_SYSTEM_FOREGROUND, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, ) from hud_server.layout import LayoutManager, Anchor, LayoutMode from hud_server.constants import ( @@ -75,6 +77,11 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, self._layout_margin = layout_margin self._layout_spacing = layout_spacing + # Reactive foreground management + self._foreground_changed = threading.Event() + self._win_event_hook = None + self._win_event_proc = None # prevent GC of the callback + # ===================================================================== # UNIFIED WINDOW SYSTEM # ===================================================================== @@ -1902,9 +1909,13 @@ def run(self): self._init_fonts() - # Note: Removed last_z tracking since we no longer repeatedly bring windows to front self.last_update_time = time.time() + # Install WinEvent hook for reactive foreground monitoring. + # The callback fires only when a different window becomes the foreground + # window, so we re-apply topmost to all HUD windows only when needed. + self._install_foreground_hook() + # Signal successful start self._emit_heartbeat() @@ -1937,8 +1948,12 @@ def run(self): # ========================================================= self._update_all_windows() - # Note: Removed repeated z-order updates (bringing windows to front every 0.1s) - # HUD windows are set to topmost once during creation and when properties change + # Re-apply topmost to all HUD windows when the foreground + # window changed (event-driven, not polled). + if self._foreground_changed.is_set(): + self._foreground_changed.clear() + self._reapply_topmost() + print("[HUD] Foreground change detected - moved HUD to absolute foreground") self._emit_heartbeat() @@ -1953,10 +1968,55 @@ def run(self): except Exception as e: self._report_exception("run_crash", e) finally: + self._uninstall_foreground_hook() # Cleanup unified windows (including chat windows) for name in list(self._windows.keys()): self._destroy_window(name) + def _install_foreground_hook(self): + """Install a WinEvent hook to detect foreground window changes. + + Uses EVENT_SYSTEM_FOREGROUND which fires whenever a different window + becomes the foreground window. WINEVENT_OUTOFCONTEXT means the callback + runs in our own process/thread context (no DLL injection needed). + WINEVENT_SKIPOWNPROCESS avoids firing for our own HUD windows. + """ + def _on_foreground_change(hook, event, hwnd, id_object, id_child, + event_thread, event_time): + self._foreground_changed.set() + + # Must keep a reference to prevent garbage collection of the ctypes callback + self._win_event_proc = WINEVENTPROC(_on_foreground_change) + self._win_event_hook = user32.SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, # eventMin + EVENT_SYSTEM_FOREGROUND, # eventMax + None, # hmodWinEventProc (None for out-of-context) + self._win_event_proc, # callback + 0, # idProcess (0 = all processes) + 0, # idThread (0 = all threads) + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + + def _uninstall_foreground_hook(self): + """Remove the WinEvent hook on shutdown.""" + if self._win_event_hook: + try: + user32.UnhookWinEvent(self._win_event_hook) + except Exception: + pass + self._win_event_hook = None + self._win_event_proc = None + + def _reapply_topmost(self): + """Re-apply topmost z-order to all visible HUD windows.""" + for win in self._windows.values(): + hwnd = win.get('hwnd') + if not hwnd: + continue + # Only re-apply to windows that are visible or fading in + if win.get('fade_state', 0) in (1, 2, 3): + force_on_top(hwnd) + def _handle_message(self, msg): try: t = msg.get('type') diff --git a/hud_server/platform/win32.py b/hud_server/platform/win32.py index 7417f181b..3ab9916ff 100644 --- a/hud_server/platform/win32.py +++ b/hud_server/platform/win32.py @@ -100,6 +100,38 @@ class MSG(ctypes.Structure): PM_REMOVE = 0x0001 +# WinEvent hook constants for reactive foreground monitoring +EVENT_SYSTEM_FOREGROUND = 0x0003 +WINEVENT_OUTOFCONTEXT = 0x0000 +WINEVENT_SKIPOWNPROCESS = 0x0002 + +# Callback type for SetWinEventHook +WINEVENTPROC = ctypes.WINFUNCTYPE( + None, # void return + wintypes.HANDLE, # hWinEventHook + wintypes.DWORD, # event + wintypes.HWND, # hwnd + ctypes.c_long, # idObject + ctypes.c_long, # idChild + wintypes.DWORD, # idEventThread + wintypes.DWORD, # dwmsEventTime +) + +# SetWinEventHook / UnhookWinEvent prototypes +user32.SetWinEventHook.argtypes = [ + wintypes.DWORD, # eventMin + wintypes.DWORD, # eventMax + wintypes.HMODULE, # hmodWinEventProc + WINEVENTPROC, # lpfnWinEventProc + wintypes.DWORD, # idProcess + wintypes.DWORD, # idThread + wintypes.DWORD, # dwFlags +] +user32.SetWinEventHook.restype = wintypes.HANDLE + +user32.UnhookWinEvent.argtypes = [wintypes.HANDLE] +user32.UnhookWinEvent.restype = wintypes.BOOL + def _wnd_proc(hwnd, msg, wparam, lparam): """Window procedure callback - must handle all message types safely.""" try: From 2ea7f4b83ddac4bb3dbcb592c31fca49af8af6fd Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 10 Feb 2026 19:08:26 +0100 Subject: [PATCH 11/27] Add type-safe enums and property classes for HUD elements and fix font family and font size rendering --- hud_server/__init__.py | 73 ++- hud_server/constants.py | 2 + hud_server/http_client.py | 481 ++++++++++++++++---- hud_server/overlay/overlay.py | 169 ++++--- hud_server/rendering/markdown.py | 24 +- hud_server/types.py | 743 +++++++++++++++++++++++++++++++ skills/hud/default_config.yaml | 2 +- skills/hud/main.py | 87 ++-- 8 files changed, 1390 insertions(+), 191 deletions(-) create mode 100644 hud_server/types.py diff --git a/hud_server/__init__.py b/hud_server/__init__.py index c33d5b805..6b4b9e842 100644 --- a/hud_server/__init__.py +++ b/hud_server/__init__.py @@ -8,10 +8,31 @@ - server.py: FastAPI HTTP server - hud_manager.py: State management for HUD groups - http_client.py: HTTP client for skills to use +- types.py: Type definitions (enums, property classes) for HUD elements - overlay/overlay.py: PIL-based overlay renderer (Windows) - rendering/markdown.py: Markdown rendering - platform/win32.py: Win32 API definitions -- hud_types.py: Type definitions for HUD elements + +Type-Safe Usage: + from hud_server import HudHttpClient, Anchor, HudColor, FontFamily + from hud_server.types import message_props, chat_window_props + + async with HudHttpClient() as client: + # Create group with typed props + props = message_props( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.WARNING, + font_family=FontFamily.CONSOLAS + ) + await client.create_group("alerts", props=props) + + # Use enums directly in method calls + await client.show_message( + "alerts", + "Warning", + "Low fuel!", + color=HudColor.WARNING + ) """ from hud_server.server import HudServer @@ -28,11 +49,38 @@ HealthResponse, GroupStateResponse, ) +from hud_server.types import ( + # Enums + Anchor, + LayoutMode, + FontFamily, + HudColor, + WindowType, + FadeState, + # Property classes + BaseProps, + MessageProps, + PersistentProps, + ChatWindowProps, + ProgressProps, + TimerProps, + # Helper functions + color, + rgb, + # Convenience constructors + message_props, + chat_window_props, + persistent_props, + # Defaults + Defaults, +) __all__ = [ + # Server and clients "HudServer", "HudHttpClient", "HudHttpClientSync", + # Models "HudServerSettings", "GroupState", "MessageRequest", @@ -43,5 +91,28 @@ "StateRestoreRequest", "HealthResponse", "GroupStateResponse", + # Enums + "Anchor", + "LayoutMode", + "FontFamily", + "HudColor", + "WindowType", + "FadeState", + # Property classes + "BaseProps", + "MessageProps", + "PersistentProps", + "ChatWindowProps", + "ProgressProps", + "TimerProps", + # Helper functions + "color", + "rgb", + # Convenience constructors + "message_props", + "chat_window_props", + "persistent_props", + # Defaults + "Defaults", ] diff --git a/hud_server/constants.py b/hud_server/constants.py index 101390fc6..5eaddad4a 100644 --- a/hud_server/constants.py +++ b/hud_server/constants.py @@ -97,6 +97,8 @@ PATH_TIMER = "/timer" PATH_CHAT_WINDOW = "/chat/window" PATH_CHAT_MESSAGE = "/chat/message" +PATH_CHAT_SHOW = "/chat/message/show" +PATH_CHAT_HIDE = "/chat/message/show" PATH_STATE = "/state" PATH_STATE_RESTORE = "/state/restore" diff --git a/hud_server/http_client.py b/hud_server/http_client.py index b9ddda293..18a18448f 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -6,28 +6,79 @@ This replaces the WebSocket-based client for the integrated HUD server. Usage: - # Async usage + from hud_server.http_client import HudHttpClient + from hud_server.types import Anchor, HudColor, FontFamily, message_props + + # Async usage with type-safe props async with HudHttpClient() as client: - await client.show_message("group1", "Title", "Content") + # Using convenience constructors + props = message_props( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.ACCENT_ORANGE, + font_family=FontFamily.CONSOLAS + ) + await client.create_group("notifications", props=props) + await client.show_message("notifications", "Alert", "Something happened!") + + # Using enums directly (auto-converted to values) + await client.create_chat_window( + "chat", + anchor=Anchor.BOTTOM_LEFT, + width=500 + ) # Sync usage client = HudHttpClientSync() - client.show_message("group1", "Title", "Content") + client.connect() + client.show_message("group1", "Title", "Content", color=HudColor.SUCCESS) + client.disconnect() + +For available property types and values, see: + - hud_server.types - Enums and typed property classes + - Anchor, LayoutMode - Position/layout options + - HudColor - Predefined color palette + - FontFamily - Available fonts + - MessageProps, ChatWindowProps, PersistentProps - Typed property containers """ import asyncio import threading -import time import httpx -from typing import Optional, Any +from typing import Optional, Any, Union from urllib.parse import quote from api.enums import LogType +from hud_server.constants import PATH_GROUPS, PATH_STATE, PATH_STATE_RESTORE, PATH_HEALTH, PATH_MESSAGE, \ + PATH_MESSAGE_APPEND, PATH_MESSAGE_HIDE, PATH_LOADER, PATH_ITEMS, PATH_PROGRESS, PATH_TIMER, PATH_CHAT_WINDOW, \ + PATH_CHAT_MESSAGE, PATH_CHAT_SHOW, PATH_CHAT_HIDE from services.printr import Printr from hud_server import constants as hud_const +from hud_server.types import ( + Anchor, + LayoutMode, + HudColor, + FontFamily, + BaseProps +) printr = Printr() +def _resolve_enum(value: Any) -> Any: + """Convert enum values to their string representation.""" + if isinstance(value, (Anchor, LayoutMode, HudColor, FontFamily)): + return value.value + return value + + +def _resolve_props(props: Optional[BaseProps]) -> Optional[dict]: + """Resolve all enum values in a props dictionary or BaseProps instance.""" + if props is None: + return None + # Convert BaseProps to dict if needed + props_dict = props.to_dict() if isinstance(props, BaseProps) else props + return {k: _resolve_enum(v) for k, v in props_dict.items()} + + class HudHttpClient: """Async HTTP client for the HUD Server.""" @@ -73,7 +124,7 @@ async def connect(self, timeout: float = DEFAULT_CONNECT_TIMEOUT) -> bool: } ) # Test connection - response = await self._client.get("/health") + response = await self._client.get(PATH_HEALTH) if response.status_code == 200: self._connected = True return True @@ -191,61 +242,95 @@ async def _execute_request(): async def health_check(self) -> bool: """Check if server is responsive.""" - result = await self._request("GET", "/health") + result = await self._request("GET", PATH_HEALTH) return result is not None and result.get("status") == "healthy" async def get_status(self) -> Optional[dict]: """Get server status including all groups.""" - return await self._request("GET", "/health") + return await self._request("GET", PATH_HEALTH) # ─────────────────────────────── Groups ─────────────────────────────── # async def create_group( self, group_name: str, - props: Optional[dict] = None + props: Optional[BaseProps] = None ) -> Optional[dict]: - """Create or update a HUD group.""" - return await self._request("POST", "/groups", { + """Create or update a HUD group. + + Args: + group_name: Unique identifier for the group + props: Optional group properties (use types module for type-safe construction) + + Properties can include (see types.py for full list): + - anchor: Screen anchor point (use Anchor enum) + - layout_mode: 'auto', 'manual', 'hybrid' (use LayoutMode enum) + - priority: Stacking priority (0-100) + - width, max_height: Size in pixels + - bg_color, text_color, accent_color: Colors (use HudColor enum) + - opacity: Window opacity (0.0-1.0) + - font_size, font_family: Typography (use FontFamily enum) + - border_radius, content_padding: Visual styling + + Returns: + Server response dict or None if failed + + Example: + from hud_server.types import Anchor, HudColor, message_props + + props = message_props( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.ACCENT_ORANGE + ) + await client.create_group("alerts", props=props) + """ + return await self._request("POST", PATH_GROUPS, { "group_name": group_name, - "props": props + "props": _resolve_props(props) }) async def update_group( self, group_name: str, - props: dict + props: BaseProps ) -> bool: - """ - Update properties of an existing group. + """Update properties of an existing group. + The server will broadcast the updated props to the overlay for real-time updates. - Returns True if successful, False otherwise. + Props can contain enum values (Anchor, HudColor, etc.) which will be auto-resolved. + + Args: + group_name: Name of the group to update + props: Properties to update (use types module for type-safe construction) + + Returns: + True if successful, False otherwise """ encoded_group = quote(group_name, safe='') - result = await self._request("PATCH", f"/groups/{encoded_group}", { - "props": props + result = await self._request("PATCH", f"{PATH_GROUPS}/{encoded_group}", { + "props": _resolve_props(props) }) return result is not None async def delete_group(self, group_name: str) -> Optional[dict]: """Delete a HUD group.""" encoded_group = quote(group_name, safe='') - return await self._request("DELETE", f"/groups/{encoded_group}") + return await self._request("DELETE", f"{PATH_GROUPS}/{encoded_group}") async def get_groups(self) -> Optional[dict]: """Get list of all group names.""" - return await self._request("GET", "/groups") + return await self._request("GET", PATH_GROUPS) # ─────────────────────────────── State ─────────────────────────────── # async def get_state(self, group_name: str) -> Optional[dict]: """Get the current state of a group for persistence.""" encoded_group = quote(group_name, safe='') - return await self._request("GET", f"/state/{encoded_group}") + return await self._request("GET", f"{PATH_STATE}/{encoded_group}") async def restore_state(self, group_name: str, state: dict) -> Optional[dict]: """Restore a group's state from a previous snapshot.""" - return await self._request("POST", "/state/restore", { + return await self._request("POST", PATH_STATE_RESTORE, { "group_name": group_name, "state": state }) @@ -257,27 +342,49 @@ async def show_message( group_name: str, title: str, content: str, - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, tools: Optional[list] = None, - props: Optional[dict] = None, + props: Optional[BaseProps] = None, duration: Optional[float] = None ) -> Optional[dict]: - """Show a message in a HUD group.""" + """Show a message in a HUD group. + + Args: + group_name: Name of the HUD group + title: Message title (displayed prominently) + content: Message content (supports Markdown) + color: Optional accent color override (use HudColor enum or hex string) + tools: Optional list of tool information for display + props: Optional MessageProps to override group defaults + duration: Optional display duration in seconds (0.1-3600) + + Returns: + Server response dict or None if failed + + Example: + await client.show_message( + "notifications", + "Alert", + "Something **important** happened!", + color=HudColor.WARNING, + duration=10.0 + ) + """ data: dict[str, Any] = { "group_name": group_name, "title": title, "content": content } if color: - data["color"] = color + data["color"] = _resolve_enum(color) if tools: data["tools"] = tools if props: - data["props"] = props + data["props"] = _resolve_props(props) if duration is not None: data["duration"] = duration - return await self._request("POST", "/message", data) + return await self._request("POST", PATH_MESSAGE, data) async def append_message( self, @@ -285,7 +392,7 @@ async def append_message( content: str ) -> Optional[dict]: """Append content to the current message (for streaming).""" - return await self._request("POST", "/message/append", { + return await self._request("POST", PATH_MESSAGE_APPEND, { "group_name": group_name, "content": content }) @@ -293,7 +400,7 @@ async def append_message( async def hide_message(self, group_name: str) -> Optional[dict]: """Hide the current message in a group.""" encoded_group = quote(group_name, safe='') - return await self._request("POST", f"/message/hide/{encoded_group}") + return await self._request("POST", f"{PATH_MESSAGE_HIDE}/{encoded_group}") # ─────────────────────────────── Loader ─────────────────────────────── # @@ -301,13 +408,22 @@ async def show_loader( self, group_name: str, show: bool = True, - color: Optional[str] = None + color: Optional[Union[str, HudColor]] = None ) -> Optional[dict]: - """Show or hide the loader animation.""" + """Show or hide the loader animation. + + Args: + group_name: Name of the HUD group + show: True to show, False to hide + color: Optional loader color (use HudColor enum or hex string) + + Returns: + Server response dict or None if failed + """ data = {"group_name": group_name, "show": show} if color: - data["color"] = color - return await self._request("POST", "/loader", data) + data["color"] = _resolve_enum(color) + return await self._request("POST", PATH_LOADER, data) # ─────────────────────────────── Items ─────────────────────────────── # @@ -316,50 +432,80 @@ async def add_item( group_name: str, title: str, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, duration: Optional[float] = None ) -> Optional[dict]: - """Add a persistent item to a group.""" + """Add a persistent item to a group. + + Args: + group_name: Name of the HUD group + title: Item title (unique identifier within group) + description: Item description text + color: Optional item color (use HudColor enum or hex string) + duration: Optional auto-remove duration in seconds + + Returns: + Server response dict or None if failed + + Example: + await client.add_item( + "status", + "Shield Status", + "Shields at 100%", + color=HudColor.SHIELD + ) + """ data: dict[str, Any] = { "group_name": group_name, "title": title, "description": description } if color: - data["color"] = color + data["color"] = _resolve_enum(color) if duration is not None: data["duration"] = duration - return await self._request("POST", "/items", data) + return await self._request("POST", PATH_ITEMS, data) async def update_item( self, group_name: str, title: str, description: Optional[str] = None, - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, duration: Optional[float] = None ) -> Optional[dict]: - """Update an existing item.""" + """Update an existing item. + + Args: + group_name: Name of the HUD group + title: Item title to update + description: New description (None to keep current) + color: New color (use HudColor enum or hex string, None to keep current) + duration: New auto-remove duration (None to keep current) + + Returns: + Server response dict or None if failed + """ data: dict[str, Any] = {"group_name": group_name, "title": title} if description is not None: data["description"] = description if color is not None: - data["color"] = color + data["color"] = _resolve_enum(color) if duration is not None: data["duration"] = duration - return await self._request("PUT", "/items", data) + return await self._request("PUT", PATH_ITEMS, data) async def remove_item(self, group_name: str, title: str) -> Optional[dict]: """Remove an item from a group.""" encoded_title = quote(title, safe='') - return await self._request("DELETE", f"/items/{group_name}/{encoded_title}") + return await self._request("DELETE", f"{PATH_ITEMS}/{group_name}/{encoded_title}") async def clear_items(self, group_name: str) -> Optional[dict]: """Clear all items from a group.""" encoded_group = quote(group_name, safe='') - return await self._request("DELETE", f"/items/{encoded_group}") + return await self._request("DELETE", f"{PATH_ITEMS}/{encoded_group}") # ─────────────────────────────── Progress ─────────────────────────────── # @@ -370,11 +516,35 @@ async def show_progress( current: float, maximum: float = 100, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, auto_close: bool = False, props: Optional[dict] = None ) -> Optional[dict]: - """Show or update a progress bar.""" + """Show or update a progress bar. + + Args: + group_name: Name of the HUD group + title: Progress bar title + current: Current progress value + maximum: Maximum progress value (default: 100) + description: Optional description text + color: Progress bar color (use HudColor enum or hex string) + auto_close: Automatically close when progress reaches maximum + props: Optional ProgressProps for styling + + Returns: + Server response dict or None if failed + + Example: + await client.show_progress( + "downloads", + "Downloading...", + current=45, + maximum=100, + color=HudColor.INFO, + auto_close=True + ) + """ data: dict[str, Any] = { "group_name": group_name, "title": title, @@ -384,11 +554,11 @@ async def show_progress( "auto_close": auto_close } if color: - data["color"] = color + data["color"] = _resolve_enum(color) if props: - data["props"] = props + data["props"] = _resolve_props(props) - return await self._request("POST", "/progress", data) + return await self._request("POST", PATH_PROGRESS, data) async def show_timer( self, @@ -396,12 +566,35 @@ async def show_timer( title: str, duration: float, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, auto_close: bool = True, initial_progress: float = 0, props: Optional[dict] = None ) -> Optional[dict]: - """Show a timer-based progress bar.""" + """Show a timer-based progress bar. + + Args: + group_name: Name of the HUD group + title: Timer title + duration: Timer duration in seconds + description: Optional description text + color: Timer color (use HudColor enum or hex string) + auto_close: Automatically close when timer completes (default: True) + initial_progress: Starting progress value (0-100) + props: Optional TimerProps for styling + + Returns: + Server response dict or None if failed + + Example: + await client.show_timer( + "cooldowns", + "Quantum Cooldown", + duration=30.0, + color=HudColor.QUANTUM, + auto_close=True + ) + """ data: dict[str, Any] = { "group_name": group_name, "title": title, @@ -411,11 +604,11 @@ async def show_timer( "initial_progress": initial_progress } if color: - data["color"] = color + data["color"] = _resolve_enum(color) if props: - data["props"] = props + data["props"] = _resolve_props(props) - return await self._request("POST", "/timer", data) + return await self._request("POST", PATH_TIMER, data) # ─────────────────────────────── Chat Window ─────────────────────────────── # @@ -423,30 +616,95 @@ async def create_chat_window( self, name: str, # Layout (anchor-based) - preferred - anchor: str = "top_left", + anchor: Union[str, Anchor] = Anchor.TOP_LEFT, priority: int = 5, - layout_mode: str = "auto", + layout_mode: Union[str, LayoutMode] = LayoutMode.AUTO, # Legacy position - only used if layout_mode='manual' x: int = 20, y: int = 20, # Size width: int = 400, max_height: int = 400, + # Colors + bg_color: Optional[Union[str, HudColor]] = None, + text_color: Optional[Union[str, HudColor]] = None, + accent_color: Optional[Union[str, HudColor]] = None, # Behavior auto_hide: bool = False, auto_hide_delay: float = 10.0, max_messages: int = 50, sender_colors: Optional[dict[str, str]] = None, fade_old_messages: bool = True, - **props + # Additional props + opacity: Optional[float] = None, + font_size: Optional[int] = None, + font_family: Optional[Union[str, FontFamily]] = None, + border_radius: Optional[int] = None, + **extra_props ) -> Optional[dict]: - """Create a new chat window.""" + """Create a new chat window. + + Args: + name: Unique name for the chat window + anchor: Screen anchor point (use Anchor enum) + priority: Stacking priority within anchor zone (0-100) + layout_mode: Layout mode (use LayoutMode enum) + x, y: Manual position (only used if layout_mode='manual') + width: Window width in pixels + max_height: Maximum height before scrolling + bg_color: Background color (use HudColor enum or hex string) + text_color: Text color (use HudColor enum or hex string) + accent_color: Accent color (use HudColor enum or hex string) + auto_hide: Automatically hide after inactivity + auto_hide_delay: Seconds before auto-hide + max_messages: Maximum messages to keep in history + sender_colors: Dict mapping sender names to colors + fade_old_messages: Fade older messages for visual distinction + opacity: Window opacity (0.0-1.0) + font_size: Font size in pixels (8-72) + font_family: Font family (use FontFamily enum) + border_radius: Corner radius in pixels (0-50) + **extra_props: Additional props passed to the window + + Returns: + Server response dict or None if failed + + Example: + await client.create_chat_window( + "game_chat", + anchor=Anchor.BOTTOM_LEFT, + width=500, + max_messages=100, + sender_colors={ + "Player": HudColor.ACCENT_GREEN.value, + "AI": HudColor.ACCENT_BLUE.value + } + ) + """ + # Build props dict with type resolution + props = {} + if bg_color is not None: + props["bg_color"] = _resolve_enum(bg_color) + if text_color is not None: + props["text_color"] = _resolve_enum(text_color) + if accent_color is not None: + props["accent_color"] = _resolve_enum(accent_color) + if opacity is not None: + props["opacity"] = opacity + if font_size is not None: + props["font_size"] = font_size + if font_family is not None: + props["font_family"] = _resolve_enum(font_family) + if border_radius is not None: + props["border_radius"] = border_radius + props.update(extra_props) + data = { "name": name, # Layout - "anchor": anchor, + "anchor": _resolve_enum(anchor), "priority": priority, - "layout_mode": layout_mode, + "layout_mode": _resolve_enum(layout_mode), # Legacy (for manual mode) "x": x, "y": y, @@ -461,30 +719,49 @@ async def create_chat_window( "fade_old_messages": fade_old_messages, "props": props if props else None } - return await self._request("POST", "/chat/window", data) + return await self._request("POST", PATH_CHAT_WINDOW, data) async def delete_chat_window(self, name: str) -> Optional[dict]: """Delete a chat window.""" encoded_name = quote(name, safe='') - return await self._request("DELETE", f"/chat/window/{encoded_name}") + return await self._request("DELETE", f"{PATH_CHAT_WINDOW}/{encoded_name}") async def send_chat_message( self, window_name: str, sender: str, text: str, - color: Optional[str] = None + color: Optional[Union[str, HudColor]] = None ) -> Optional[dict]: - """Send a message to a chat window. Returns response with message_id.""" + """Send a message to a chat window. + + Args: + window_name: Name of the chat window + sender: Sender name displayed with the message + text: Message text content + color: Optional sender color override (use HudColor enum or hex string) + + Returns: + Server response dict with message_id or None if failed + + Example: + result = await client.send_chat_message( + "game_chat", + "Player", + "Hello world!", + color=HudColor.ACCENT_GREEN + ) + message_id = result["message_id"] # For later updates + """ data = { "window_name": window_name, "sender": sender, "text": text } if color: - data["color"] = color + data["color"] = _resolve_enum(color) - return await self._request("POST", "/chat/message", data) + return await self._request("POST", PATH_CHAT_MESSAGE, data) async def update_chat_message( self, @@ -498,22 +775,22 @@ async def update_chat_message( "message_id": message_id, "text": text } - return await self._request("PUT", "/chat/message", data) + return await self._request("PUT", PATH_CHAT_MESSAGE, data) async def clear_chat_window(self, name: str) -> Optional[dict]: """Clear all messages from a chat window.""" encoded_name = quote(name, safe='') - return await self._request("DELETE", f"/chat/messages/{encoded_name}") + return await self._request("DELETE", f"{PATH_CHAT_MESSAGE}/{encoded_name}") async def show_chat_window(self, name: str) -> Optional[dict]: """Show a hidden chat window.""" encoded_name = quote(name, safe='') - return await self._request("POST", f"/chat/show/{encoded_name}") + return await self._request("POST", f"{PATH_CHAT_SHOW}/{encoded_name}") async def hide_chat_window(self, name: str) -> Optional[dict]: """Hide a chat window.""" encoded_name = quote(name, safe='') - return await self._request("POST", f"/chat/hide/{encoded_name}") + return await self._request("POST", f"{PATH_CHAT_HIDE}/{encoded_name}") @@ -643,11 +920,12 @@ def health_check(self) -> bool: def get_status(self) -> Optional[dict]: return self._run_coro(self._client.get_status()) if self._client else None - def create_group(self, group_name: str, props: Optional[dict] = None): + def create_group(self, group_name: str, props: Optional[BaseProps] = None): + """Create or update a HUD group. Props can contain enum values.""" return self._run_coro(self._client.create_group(group_name, props)) if self._client else None - def update_group(self, group_name: str, props: dict) -> bool: - """Update properties for an existing group for real-time updates.""" + def update_group(self, group_name: str, props: BaseProps) -> bool: + """Update properties for an existing group. Props can contain enum values.""" return self._run_coro(self._client.update_group(group_name, props)) if self._client else False def delete_group(self, group_name: str): @@ -667,11 +945,12 @@ def show_message( group_name: str, title: str, content: str, - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, tools: Optional[list] = None, props: Optional[dict] = None, duration: Optional[float] = None ): + """Show a message. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_message( group_name, title, content, color, tools, props, duration )) if self._client else None @@ -682,7 +961,13 @@ def append_message(self, group_name: str, content: str): def hide_message(self, group_name: str): return self._run_coro(self._client.hide_message(group_name)) if self._client else None - def show_loader(self, group_name: str, show: bool = True, color: Optional[str] = None): + def show_loader( + self, + group_name: str, + show: bool = True, + color: Optional[Union[str, HudColor]] = None + ): + """Show/hide loader. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_loader(group_name, show, color)) if self._client else None def add_item( @@ -690,9 +975,10 @@ def add_item( group_name: str, title: str, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, duration: Optional[float] = None ): + """Add persistent item. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.add_item( group_name, title, description, color, duration )) if self._client else None @@ -702,9 +988,10 @@ def update_item( group_name: str, title: str, description: Optional[str] = None, - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, duration: Optional[float] = None ): + """Update item. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.update_item( group_name, title, description, color, duration )) if self._client else None @@ -722,10 +1009,11 @@ def show_progress( current: float, maximum: float = 100, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, auto_close: bool = False, props: Optional[dict] = None ): + """Show progress bar. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_progress( group_name, title, current, maximum, description, color, auto_close, props )) if self._client else None @@ -736,11 +1024,12 @@ def show_timer( title: str, duration: float, description: str = "", - color: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, auto_close: bool = True, initial_progress: float = 0, props: Optional[dict] = None ): + """Show timer. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_timer( group_name, title, duration, description, color, auto_close, initial_progress, props )) if self._client else None @@ -749,23 +1038,33 @@ def create_chat_window( self, name: str, # Layout (anchor-based) - preferred - anchor: str = "top_left", + anchor: Union[str, Anchor] = Anchor.TOP_LEFT, priority: int = 5, - layout_mode: str = "auto", + layout_mode: Union[str, LayoutMode] = LayoutMode.AUTO, # Legacy position - only used if layout_mode='manual' x: int = 20, y: int = 20, # Size width: int = 400, max_height: int = 400, + # Colors + bg_color: Optional[Union[str, HudColor]] = None, + text_color: Optional[Union[str, HudColor]] = None, + accent_color: Optional[Union[str, HudColor]] = None, # Behavior auto_hide: bool = False, auto_hide_delay: float = 10.0, max_messages: int = 50, sender_colors: Optional[dict[str, str]] = None, fade_old_messages: bool = True, - **props + # Additional props + opacity: Optional[float] = None, + font_size: Optional[int] = None, + font_family: Optional[Union[str, FontFamily]] = None, + border_radius: Optional[int] = None, + **extra_props ): + """Create chat window. Accepts Anchor, LayoutMode, HudColor, FontFamily enums.""" return self._run_coro(self._client.create_chat_window( name=name, anchor=anchor, @@ -773,17 +1072,31 @@ def create_chat_window( layout_mode=layout_mode, x=x, y=y, width=width, max_height=max_height, + bg_color=bg_color, + text_color=text_color, + accent_color=accent_color, auto_hide=auto_hide, auto_hide_delay=auto_hide_delay, max_messages=max_messages, sender_colors=sender_colors, fade_old_messages=fade_old_messages, - **props + opacity=opacity, + font_size=font_size, + font_family=font_family, + border_radius=border_radius, + **extra_props )) if self._client else None def delete_chat_window(self, name: str): return self._run_coro(self._client.delete_chat_window(name)) if self._client else None - def send_chat_message(self, window_name: str, sender: str, text: str, color: Optional[str] = None): + def send_chat_message( + self, + window_name: str, + sender: str, + text: str, + color: Optional[Union[str, HudColor]] = None + ): + """Send chat message. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.send_chat_message( window_name, sender, text, color )) if self._client else None diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 3c07140fb..b8575e785 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -129,10 +129,10 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, 'width': 400, 'x': 20, 'y': 20, 'bg_color': '#1e212b', 'text_color': '#f0f0f0', 'accent_color': '#00aaff', 'opacity': 0.85, 'duration': 8.0, 'border_radius': 12, 'content_padding': 16, - 'max_height': 600, 'font_size': 16, 'color_emojis': True, + 'max_height': 600, 'font_size': 16, 'font_family': 'Segoe UI', 'color_emojis': True, 'typewriter_effect': True, # Persistent window defaults - 'persistent_x': 20, 'persistent_y': 300, 'persistent_width': 300, + 'persistent_x': 20, 'persistent_y': 300, } # Per-group props storage (set via create_group/update_group) @@ -148,6 +148,10 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, self.image_cache = {} self.md_renderer = None + # Font cache: stores font sets by (family, size) to avoid reloading + # Key: (family, size) -> {font_dict with font objects} + self._font_cache: Dict[tuple, Dict] = {} + # ===================================================================== # RENDER CACHING SYSTEM # ===================================================================== @@ -259,9 +263,9 @@ def _get_default_window_props(self, window_type: str, group: str) -> dict: # Adjust defaults based on window type if window_type == self.WINDOW_TYPE_PERSISTENT: # Use persistent_* props for position - props['x'] = props.get('persistent_x', 20) - props['y'] = props.get('persistent_y', 300) - props['width'] = props.get('persistent_width', 300) + props['x'] = props.get('x', 20) + props['y'] = props.get('y', 300) + props['width'] = props.get('width', 300) return props @@ -772,11 +776,15 @@ def _draw_message_window(self, name: str, win: Dict): win['last_render_state'] = current_state win['canvas_dirty'] = True - # Ensure renderer exists - if not self.md_renderer: - self._init_fonts() - colors = {'text': text_color, 'accent': accent, 'bg': bg} - self.md_renderer = MarkdownRenderer(self.fonts, colors, props.get('color_emojis', True)) + # Ensure fonts for this window's font_family are loaded + font_size = int(props.get('font_size', 16)) + font_family = props.get('font_family', 'Segoe UI') + window_fonts = self._load_fonts_for_size_and_family(font_size, font_family) + + # Always ensure renderer uses window's correct fonts (each window may have different font settings) + colors = {'text': text_color, 'accent': accent, 'bg': bg} + self.fonts = window_fonts # Update global fonts to match window + self.md_renderer = MarkdownRenderer(window_fonts, colors, props.get('color_emojis', True)) # Update renderer colors self.md_renderer.set_colors(text_color, accent, bg) @@ -992,11 +1000,15 @@ def _draw_persistent_window(self, name: str, win: Dict): win['last_render_state'] = current_state win['canvas_dirty'] = True - # Ensure renderer - if not self.md_renderer: - self._init_fonts() - colors = {'text': text_color, 'accent': accent, 'bg': bg} - self.md_renderer = MarkdownRenderer(self.fonts, colors, props.get('color_emojis', True)) + # Ensure fonts for this window's font_family are loaded + font_size = int(props.get('font_size', 16)) + font_family = props.get('font_family', 'Segoe UI') + window_fonts = self._load_fonts_for_size_and_family(font_size, font_family) + + # Always ensure renderer uses window's correct fonts (each window may have different font settings) + colors = {'text': text_color, 'accent': accent, 'bg': bg} + self.fonts = window_fonts # Update global fonts to match window + self.md_renderer = MarkdownRenderer(window_fonts, colors, props.get('color_emojis', True)) self.md_renderer.set_colors(text_color, accent, bg) @@ -1070,7 +1082,16 @@ def _draw_persistent_window(self, name: str, win: Dict): max_title_w = width - (padding * 2) - timer_w if font_bold: self._render_text_with_emoji(draw, title_text, padding, y, accent + (255,), font_bold, emoji_y_offset=3) - y += 22 + # Calculate proper spacing based on font height instead of hardcoded value + try: + bbox = font_bold.getbbox(title_text) + title_h = bbox[3] - bbox[1] + except: + title_h = font_size + # Add spacing: title height + padding (0.625x of title height for adequate breathing room) + y += title_h + max(10, int(title_h * 0.625)) + else: + y += 22 # Fallback if no font # Progress bar if info.get('is_progress'): @@ -1102,11 +1123,14 @@ def _draw_persistent_window(self, name: str, win: Dict): try: bbox = font_normal.getbbox(pct_text) pct_w = bbox[2] - bbox[0] + pct_h = bbox[3] - bbox[1] except: pct_w = len(pct_text) * 7 + pct_h = font_size pct_x = padding + (bar_width - pct_w) // 2 draw.text((pct_x, y), pct_text, fill=text_color + (200,), font=font_normal) - y += 18 + # Scale spacing based on font size (1.25x for spacing) plus small additional padding + y += int(pct_h * 1.25) + 4 # Description desc = info.get('description', '') @@ -1222,14 +1246,20 @@ def _draw_chat_window(self, name: str, win: Dict): win['last_render_state'] = current_state win['canvas_dirty'] = True + # Ensure fonts for this window's font_family are loaded + font_size = int(props.get('font_size', 14)) + font_family = props.get('font_family', 'Segoe UI') + window_fonts = self._load_fonts_for_size_and_family(font_size, font_family) + + # Always ensure renderer uses window's correct fonts + colors = {'text': text_color, 'accent': accent, 'bg': bg} + self.fonts = window_fonts # Update global fonts to match window + self.md_renderer = MarkdownRenderer(window_fonts, colors, color_emojis) + # Get fonts font_bold = self.fonts.get('bold', self.fonts.get('normal', self.fonts.get('regular'))) font_normal = self.fonts.get('normal', self.fonts.get('regular')) - # Update markdown renderer colors - if self.md_renderer: - self.md_renderer.set_colors(text_color, accent, bg) - # Render messages to temp canvas temp_h = max(2000, max_height * 3) temp = Image.new('RGBA', (width, temp_h), (0, 0, 0, 0)) @@ -1535,15 +1565,23 @@ def save_checkbox(m): return text - def _init_fonts(self, font_size: int = None, font_family: str = None): - """Initialize fonts for rendering. + def _load_fonts_for_size_and_family(self, size: int, family: str) -> Dict: + """Load fonts for a specific size and family combination. + + Uses cache to avoid reloading the same font set multiple times. Args: - font_size: Font size in pixels. Defaults to value from _default_props (16). - font_family: Font family name. Defaults to value from _default_props ('Segoe UI'). + size: Font size in pixels + family: Font family name + + Returns: + Dictionary of font objects for the given size and family """ - size = font_size if font_size is not None else int(self._default_props.get('font_size', 16)) - family = font_family if font_family is not None else self._default_props.get('font_family', 'Segoe UI') + cache_key = (family.lower(), size) + + # Return cached fonts if available + if cache_key in self._font_cache: + return self._font_cache[cache_key] # Map font family names to Windows font files font_map = { @@ -1555,24 +1593,20 @@ def _init_fonts(self, font_size: int = None, font_family: str = None): 'calibri': {'normal': 'calibri.ttf', 'bold': 'calibrib.ttf', 'italic': 'calibrii.ttf', 'bold_italic': 'calibriz.ttf'}, 'consolas': {'normal': 'consola.ttf', 'bold': 'consolab.ttf', 'italic': 'consolai.ttf', 'bold_italic': 'consolaz.ttf'}, 'courier new': {'normal': 'cour.ttf', 'bold': 'courbd.ttf', 'italic': 'couri.ttf', 'bold_italic': 'courbi.ttf'}, - 'roboto': {'normal': 'Roboto-Regular.ttf', 'bold': 'Roboto-Bold.ttf', 'italic': 'Roboto-Italic.ttf', 'bold_italic': 'Roboto-BoldItalic.ttf'}, } - # Get font files for the specified family (case-insensitive) family_lower = family.lower() font_files = font_map.get(family_lower, font_map['segoe ui']) - fonts_dir = "C:/Windows/Fonts/" - # Use configured font size directly pil_size = size - pil_code_size = size - 1 # Code font slightly smaller + pil_code_size = max(1, size - 1) # Code font slightly smaller, but at least 1 - # Load emoji font separately (may fail on some systems) + # Load emoji fonts emoji_font = None emoji_font_paths = [ - fonts_dir + "seguiemj.ttf", # Windows 10/11 Segoe UI Emoji - fonts_dir + "seguisym.ttf", # Fallback to Segoe UI Symbol + fonts_dir + "seguiemj.ttf", + fonts_dir + "seguisym.ttf", ] for emoji_path in emoji_font_paths: try: @@ -1581,7 +1615,6 @@ def _init_fonts(self, font_size: int = None, font_family: str = None): except: pass - # Load emoji fonts at different sizes for headers emoji_fonts = {'emoji': emoji_font} emoji_font_path = None for path in emoji_font_paths: @@ -1603,34 +1636,35 @@ def _init_fonts(self, font_size: int = None, font_family: str = None): except: pass + # Try to load fonts from Windows fonts directory try: - self.fonts = { + fonts_dict = { 'normal': ImageFont.truetype(fonts_dir + font_files['normal'], pil_size), 'bold': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size), 'italic': ImageFont.truetype(fonts_dir + font_files['italic'], pil_size), 'bold_italic': ImageFont.truetype(fonts_dir + font_files['bold_italic'], pil_size), 'code': ImageFont.truetype(fonts_dir + "consola.ttf", pil_code_size), - # Header fonts H1-H6 with decreasing sizes - 'h1': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 10), # Largest + 'h1': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 10), 'h2': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 6), 'h3': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 3), 'h4': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 1), 'h5': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size), - 'h6': ImageFont.truetype(fonts_dir + font_files['bold_italic'], pil_size - 1), # Smallest, italic - 'header': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 4), # Legacy + 'h6': ImageFont.truetype(fonts_dir + font_files['bold_italic'], pil_size - 1), + 'header': ImageFont.truetype(fonts_dir + font_files['bold'], pil_size + 4), 'emoji': emoji_font if emoji_font else ImageFont.truetype(fonts_dir + font_files['normal'], pil_size), - # Emoji fonts for headers 'emoji_h1': emoji_fonts.get('emoji_h1', emoji_font), 'emoji_h2': emoji_fonts.get('emoji_h2', emoji_font), 'emoji_h3': emoji_fonts.get('emoji_h3', emoji_font), 'emoji_h4': emoji_fonts.get('emoji_h4', emoji_font), 'emoji_h5': emoji_fonts.get('emoji_h5', emoji_font), 'emoji_h6': emoji_fonts.get('emoji_h6', emoji_font), + '_font_size': size, + '_font_family': family, } - except Exception as e: - # Fallback: try loading font by name directly (for custom fonts) + except Exception: + # Fallback: try loading by family name directly try: - self.fonts = { + fonts_dict = { 'normal': ImageFont.truetype(family, pil_size), 'bold': ImageFont.truetype(family, pil_size), 'italic': ImageFont.truetype(family, pil_size), @@ -1644,11 +1678,40 @@ def _init_fonts(self, font_size: int = None, font_family: str = None): 'h6': ImageFont.truetype(family, pil_size - 1), 'header': ImageFont.truetype(family, pil_size + 4), 'emoji': emoji_font if emoji_font else ImageFont.truetype(family, pil_size), + '_font_size': size, + '_font_family': family, } except: # Final fallback to default default = ImageFont.load_default() - self.fonts = {k: default for k in ['normal', 'bold', 'italic', 'bold_italic', 'code', 'header', 'emoji']} + fonts_dict = {k: default for k in ['normal', 'bold', 'italic', 'bold_italic', 'code', 'header', 'emoji']} + fonts_dict.update({ + 'h1': default, + 'h2': default, + 'h3': default, + 'h4': default, + 'h5': default, + 'h6': default, + }) + fonts_dict['_font_size'] = size + fonts_dict['_font_family'] = family + + # Cache the fonts + self._font_cache[cache_key] = fonts_dict + return fonts_dict + + def _init_fonts(self, font_size: int = None, font_family: str = None): + """Initialize fonts for rendering. + + Args: + font_size: Font size in pixels. Defaults to value from _default_props (16). + font_family: Font family name. Defaults to value from _default_props ('Segoe UI'). + """ + size = font_size if font_size is not None else int(self._default_props.get('font_size', 16)) + family = font_family if font_family is not None else self._default_props.get('font_family', 'Segoe UI') + + # Load fonts using the cache + self.fonts = self._load_fonts_for_size_and_family(size, family) colors = { 'text': self._hex_to_rgb(self._default_props.get('text_color', '#f0f0f0')), @@ -1867,9 +1930,9 @@ def _check_window_collision(self, msg_win: Optional[Dict], pers_win: Dict) -> bo # Get persistent window rect pers_props = pers_win.get('props', {}) - pers_x = int(pers_props.get('x', pers_props.get('persistent_x', 20))) - pers_y = int(pers_props.get('y', pers_props.get('persistent_y', 300))) - pers_w = int(pers_props.get('width', pers_props.get('persistent_width', 300))) + pers_x = int(pers_props.get('x', pers_props.get('x', 20))) + pers_y = int(pers_props.get('y', pers_props.get('y', 300))) + pers_w = int(pers_props.get('width', pers_props.get('width', 400))) pers_canvas = pers_win.get('canvas') pers_h = pers_canvas.height if pers_canvas else 200 @@ -2097,7 +2160,9 @@ def _handle_message(self, msg): new_size = props.get('font_size') new_family = props.get('font_family') if (new_size is not None and new_size != old_size) or (new_family is not None and new_family != old_family): - self._init_fonts() + font_size = int(new_size) if new_size is not None else old_size + font_family = new_family if new_family is not None else old_family + self._init_fonts(font_size, font_family) # Rebuild markdown renderer with new fonts text_color = self._hex_to_rgb(props.get('text_color', '#f0f0f0')) accent_color = self._hex_to_rgb(props.get('accent_color', '#00aaff')) @@ -2157,7 +2222,9 @@ def _handle_message(self, msg): # Re-init fonts if size or family changed if old_size != new_size or old_family != new_family: - self._init_fonts() + font_size = int(new_size) if new_size is not None else int(old_size) if old_size is not None else 16 + font_family = new_family if new_family is not None else old_family if old_family is not None else 'Segoe UI' + self._init_fonts(font_size, font_family) colors = { 'text': self._hex_to_rgb(win['props'].get('text_color', '#f0f0f0')), 'accent': self._hex_to_rgb(win['props'].get('accent_color', '#00aaff')), diff --git a/hud_server/rendering/markdown.py b/hud_server/rendering/markdown.py index fe62b5b92..47a9d102c 100644 --- a/hud_server/rendering/markdown.py +++ b/hud_server/rendering/markdown.py @@ -51,7 +51,9 @@ def __init__(self, fonts: Dict, colors: Dict, color_emojis: bool = True): self.fonts = fonts self.colors = colors self.color_emojis = color_emojis # Enable colored emoji rendering - self.line_height = 26 # Good line height for readability + # Calculate line height based on font size (1.625x for good readability) + font_size = fonts.get('_font_size', 16) + self.line_height = int(font_size * 1.625) self.letter_spacing = 0 # No letter spacing self.char_count = 0 # For typewriter tracking self._text_size_cache = {} @@ -984,14 +986,18 @@ def _render_header(self, draw, block: Dict, x: int, y: int, width: int) -> int: text = block['text'] content_start = block.get('content_start', block.get('start', 0)) - # Header styling for H1-H6 + # Get base font size for spacing calculations + base_font_size = self.fonts.get('_font_size', 16) + + # Header styling for H1-H6 with spacing scaled to font size + # Increased spacing multipliers to prevent overlap with content below (0.75x, 0.5x, 0.375x) header_styles = { - 1: {'font': 'h1', 'color': self.colors['accent'], 'spacing': 8}, - 2: {'font': 'h2', 'color': self.colors['accent'], 'spacing': 6}, - 3: {'font': 'h3', 'color': self.colors['accent'], 'spacing': 4}, - 4: {'font': 'h4', 'color': self.colors['text'], 'spacing': 4}, - 5: {'font': 'h5', 'color': self.colors['text'], 'spacing': 4}, - 6: {'font': 'h6', 'color': (160, 168, 180), 'spacing': 4}, + 1: {'font': 'h1', 'color': self.colors['accent'], 'spacing': int(base_font_size * 0.75)}, + 2: {'font': 'h2', 'color': self.colors['accent'], 'spacing': int(base_font_size * 0.5)}, + 3: {'font': 'h3', 'color': self.colors['accent'], 'spacing': int(base_font_size * 0.375)}, + 4: {'font': 'h4', 'color': self.colors['text'], 'spacing': int(base_font_size * 0.375)}, + 5: {'font': 'h5', 'color': self.colors['text'], 'spacing': int(base_font_size * 0.375)}, + 6: {'font': 'h6', 'color': (160, 168, 180), 'spacing': int(base_font_size * 0.375)}, } # Get style for this level (default to H6 style for levels > 6) @@ -2529,7 +2535,7 @@ def _render_tokens(self, draw, tokens: List[Dict], x: int, y: int, width: int, current_x += fcw - # Return Y position after the last line of text (not adding full line_h again) + # Return Y position after the last line of text return current_y + line_h + 10 diff --git a/hud_server/types.py b/hud_server/types.py new file mode 100644 index 000000000..f89321cfc --- /dev/null +++ b/hud_server/types.py @@ -0,0 +1,743 @@ +# -*- coding: utf-8 -*- +""" +HUD Server Types - Comprehensive type definitions for the HUD HTTP Client. + +This module provides strongly-typed enums and property classes for all HUD +server interactions. Use these types to ensure correct values when configuring +HUD elements. + +Usage: + from hud_server.types import ( + Anchor, LayoutMode, HudColor, FontFamily, + MessageProps, ChatWindowProps, GroupProps + ) + + # Create props with type safety and autocompletion + props = MessageProps( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.CYAN, + font_family=FontFamily.SEGOE_UI, + opacity=0.9 + ) + + # Use with HTTP client + await client.create_group("my_group", props=props.to_dict()) +""" + +from enum import Enum +from dataclasses import dataclass, asdict +from typing import Optional, Dict, Any + +from hud_server.constants import ( + ANCHOR_TOP_LEFT, + ANCHOR_TOP_CENTER, + ANCHOR_TOP_RIGHT, + ANCHOR_LEFT_CENTER, + ANCHOR_RIGHT_CENTER, + ANCHOR_BOTTOM_LEFT, + ANCHOR_BOTTOM_CENTER, + ANCHOR_BOTTOM_RIGHT, + ANCHOR_CENTER +) + + +# ============================================================================= +# ENUMS - Predefined values for restricted properties +# ============================================================================= + + +class Anchor(str, Enum): + """Screen anchor points for window positioning. + + Determines where on the screen a HUD window will be anchored. + Windows stack automatically from their anchor point. + """ + TOP_LEFT = ANCHOR_TOP_LEFT + """Anchor to top-left corner. Windows stack downward.""" + + TOP_CENTER = ANCHOR_TOP_CENTER + """Anchor to top-center. Windows stack downward.""" + + TOP_RIGHT = ANCHOR_TOP_RIGHT + """Anchor to top-right corner. Windows stack downward.""" + + LEFT_CENTER = ANCHOR_LEFT_CENTER + """Anchor to left edge, vertically centered. Windows stack toward center.""" + + CENTER = ANCHOR_CENTER + """Anchor to screen center. Fixed position, no automatic stacking.""" + + RIGHT_CENTER = ANCHOR_RIGHT_CENTER + """Anchor to right edge, vertically centered. Windows stack toward center.""" + + BOTTOM_LEFT = ANCHOR_BOTTOM_LEFT + """Anchor to bottom-left corner. Windows stack upward.""" + + BOTTOM_CENTER = ANCHOR_BOTTOM_CENTER + """Anchor to bottom-center. Windows stack upward.""" + + BOTTOM_RIGHT = ANCHOR_BOTTOM_RIGHT + """Anchor to bottom-right corner. Windows stack upward.""" + + +class LayoutMode(str, Enum): + """Layout modes for window positioning.""" + + AUTO = "auto" + """Automatic stacking based on anchor point. Recommended for most cases.""" + + MANUAL = "manual" + """User-specified x, y coordinates. No automatic adjustment.""" + + HYBRID = "hybrid" + """Automatic positioning with user-defined offsets. Reserved for future use.""" + + +class FontFamily(str, Enum): + """Commonly available font families for HUD text. + + These fonts are commonly available on Windows systems. + The HUD will fall back to a default if the specified font is not found. + """ + # Sans-serif fonts (clean, modern look) + SEGOE_UI = "Segoe UI" + """Default Windows font. Clean and readable. Recommended.""" + + ARIAL = "Arial" + """Classic sans-serif. Widely available.""" + + VERDANA = "Verdana" + """Wide, readable sans-serif designed for screens.""" + + TAHOMA = "Tahoma" + """Compact sans-serif, good for small sizes.""" + + TREBUCHET_MS = "Trebuchet MS" + """Humanist sans-serif with personality.""" + + CALIBRI = "Calibri" + """Modern sans-serif, default in Office.""" + + CONSOLAS = "Consolas" + """Modern monospace. Excellent for code. Recommended for technical data.""" + + COURIER_NEW = "Courier New" + """Classic monospace typewriter font.""" + + +class HudColor(str, Enum): + """Predefined colors for HUD elements. + + Use these predefined colors or specify custom hex values (#RRGGBB or #RRGGBBAA). + The alpha channel (AA) in hex codes controls transparency (00=transparent, FF=opaque). + """ + # Primary colors + WHITE = "#ffffff" + BLACK = "#000000" + RED = "#ff0000" + GREEN = "#00ff00" + BLUE = "#0000ff" + YELLOW = "#ffff00" + CYAN = "#00ffff" + MAGENTA = "#ff00ff" + + # Grayscale + GRAY_LIGHT = "#d0d0d0" + GRAY = "#808080" + GRAY_DARK = "#404040" + + # Theme colors (recommended for HUD) + ACCENT_BLUE = "#00aaff" + """Default accent color. Bright, noticeable.""" + + ACCENT_ORANGE = "#ff8800" + """Warm accent. Good for warnings or highlights.""" + + ACCENT_GREEN = "#00ff88" + """Success/positive indicator.""" + + ACCENT_PURPLE = "#aa00ff" + """Alternative accent color.""" + + ACCENT_PINK = "#ff0088" + """Vibrant pink accent.""" + + # Status colors + SUCCESS = "#22c55e" + """Green success indicator.""" + + WARNING = "#f59e0b" + """Orange/amber warning indicator.""" + + ERROR = "#ef4444" + """Red error indicator.""" + + INFO = "#3b82f6" + """Blue informational indicator.""" + + # Background colors + BG_DARK = "#1e212b" + """Default dark background. Recommended.""" + + BG_DARKER = "#13151a" + """Very dark background.""" + + BG_MEDIUM = "#2d3142" + """Medium dark background.""" + + BG_LIGHT = "#3d4157" + """Lighter background.""" + + # Text colors + TEXT_PRIMARY = "#f0f0f0" + """Default text color. High contrast on dark backgrounds.""" + + TEXT_SECONDARY = "#a0a0a0" + """Subdued text for secondary information.""" + + TEXT_MUTED = "#606060" + """Very subdued text.""" + + # Semi-transparent variants (with alpha channel) + WHITE_50 = "#ffffff80" + """50% transparent white.""" + + BLACK_50 = "#00000080" + """50% transparent black.""" + + BG_DARK_90 = "#1e212be6" + """90% opaque dark background.""" + + BG_DARK_75 = "#1e212bbf" + """75% opaque dark background.""" + + BG_DARK_50 = "#1e212b80" + """50% opaque dark background.""" + + # Game-themed colors + SHIELD = "#00aaff" + """Shield/energy color.""" + + HEALTH = "#22c55e" + """Health/life color.""" + + ARMOR = "#f59e0b" + """Armor/protection color.""" + + DANGER = "#ef4444" + """Danger/damage color.""" + + QUANTUM = "#aa00ff" + """Quantum/warp color.""" + + FUEL = "#ffcc00" + """Fuel/energy resource color.""" + + +class WindowType(str, Enum): + """Types of HUD windows.""" + + MESSAGE = "message" + """Temporary message window. Fades out after display duration.""" + + PERSISTENT = "persistent" + """Persistent information window. Stays visible until explicitly hidden.""" + + CHAT = "chat" + """Chat window for message streams.""" + + +class FadeState(int, Enum): + """Window fade animation states.""" + + HIDDEN = 0 + """Window is fully hidden.""" + + FADE_IN = 1 + """Window is fading in (appearing).""" + + VISIBLE = 2 + """Window is fully visible.""" + + FADE_OUT = 3 + """Window is fading out (disappearing).""" + + +# ============================================================================= +# PROPERTY CLASSES - Typed property containers for each window type +# ============================================================================= + + +@dataclass +class BaseProps: + """Base properties shared by all HUD elements.""" + + # Position & Layout + x: Optional[int] = None + """X position in pixels. Used in MANUAL layout mode.""" + + y: Optional[int] = None + """Y position in pixels. Used in MANUAL layout mode.""" + + width: Optional[int] = None + """Window width in pixels. Range: 100-3840.""" + + max_height: Optional[int] = None + """Maximum window height in pixels. Range: 100-2160.""" + + layout_mode: Optional[str] = None + """Layout mode: 'auto', 'manual', or 'hybrid'. Use LayoutMode enum.""" + + anchor: Optional[str] = None + """Screen anchor point. Use Anchor enum.""" + + priority: Optional[int] = None + """Stacking priority within anchor zone. Higher = closer to anchor. Range: 0-100.""" + + z_order: Optional[int] = None + """Z-order for layering. Higher = on top. Range: -1000 to 1000.""" + + # Colors + bg_color: Optional[str] = None + """Background color in hex format (#RRGGBB or #RRGGBBAA). Use HudColor enum.""" + + text_color: Optional[str] = None + """Text color in hex format. Use HudColor enum.""" + + accent_color: Optional[str] = None + """Accent color for titles and highlights. Use HudColor enum.""" + + title_color: Optional[str] = None + """Override color for title text. Use HudColor enum.""" + + # Visual styling + opacity: Optional[float] = None + """Window opacity. Range: 0.0 (transparent) to 1.0 (opaque).""" + + border_radius: Optional[int] = None + """Corner radius in pixels. Range: 0-50.""" + + content_padding: Optional[int] = None + """Padding inside the window in pixels. Range: 0-100.""" + + # Typography + font_size: Optional[int] = None + """Font size in pixels. Range: 8-72.""" + + font_family: Optional[str] = None + """Font family name. Use FontFamily enum.""" + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary, excluding None values.""" + return {k: v for k, v in asdict(self).items() if v is not None} + + +@dataclass +class MessageProps(BaseProps): + """Properties for message windows (temporary notifications). + + Message windows display temporary content that fades out after a duration. + Supports markdown content and typewriter animation. + """ + + # Behavior + typewriter_effect: Optional[bool] = None + """Enable typewriter animation for text. Default: True.""" + + typewriter_speed: Optional[int] = None + """Characters per second for typewriter effect. Range: 1-1000.""" + + auto_fade: Optional[bool] = None + """Automatically fade out after display. Default: True.""" + + fade_delay: Optional[float] = None + """Seconds before starting fade out. Range: 0.0-300.0.""" + + fade_duration: Optional[float] = None + """Duration of fade animation in seconds. Range: 0.1-10.0.""" + + show_loader: Optional[bool] = None + """Show loading animation while waiting for content.""" + + +@dataclass +class PersistentProps(BaseProps): + """Properties for persistent windows (information panels). + + Persistent windows display items that remain visible until removed. + Good for status indicators, tracked information, etc. + """ + pass # Uses base props. Items are added/removed via add_item/remove_item + + +@dataclass +class ChatWindowProps(BaseProps): + """Properties for chat windows (message streams). + + Chat windows display a scrolling list of messages from multiple senders. + """ + + # Size + max_height: Optional[int] = None + """Maximum height before scrolling. Range: 100-2160.""" + + # Behavior + auto_hide: Optional[bool] = None + """Automatically hide after inactivity. Default: False.""" + + auto_hide_delay: Optional[float] = None + """Seconds of inactivity before auto-hide. Default: 10.0.""" + + max_messages: Optional[int] = None + """Maximum messages to keep in history. Default: 50.""" + + fade_old_messages: Optional[bool] = None + """Fade older messages for visual distinction. Default: True.""" + + show_timestamps: Optional[bool] = None + """Show timestamps on messages. Default: False.""" + + message_spacing: Optional[int] = None + """Vertical spacing between messages in pixels. Default: 8.""" + + sender_colors: Optional[Dict[str, str]] = None + """Map of sender names to colors. E.g., {'User': '#00ff00', 'AI': '#00aaff'}.""" + + +@dataclass +class ProgressProps(BaseProps): + """Properties for progress bar displays.""" + + auto_close: Optional[bool] = None + """Automatically close when progress reaches maximum. Default: False.""" + + +@dataclass +class TimerProps(BaseProps): + """Properties for timer/countdown displays.""" + + auto_close: Optional[bool] = None + """Automatically close when timer completes. Default: True.""" + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + + +def color(hex_value: str) -> str: + """Validate and return a hex color value. + + Args: + hex_value: Color in hex format (#RGB, #RRGGBB, or #RRGGBBAA) + + Returns: + The validated hex color string + + Raises: + ValueError: If the color format is invalid + + Example: + color("#ff0000") # Red + color("#ff000080") # Semi-transparent red + """ + if not isinstance(hex_value, str): + raise ValueError(f"Color must be a string, got {type(hex_value)}") + + if not hex_value.startswith('#'): + raise ValueError(f"Color must start with '#', got '{hex_value}'") + + hex_part = hex_value[1:] + if len(hex_part) not in (3, 6, 8): + raise ValueError( + f"Color must be #RGB, #RRGGBB, or #RRGGBBAA format, got '{hex_value}'" + ) + + try: + int(hex_part, 16) + except ValueError: + raise ValueError(f"Invalid hex color: '{hex_value}'") + + return hex_value + + +def rgb(r: int, g: int, b: int, a: Optional[int] = None) -> str: + """Create a hex color from RGB(A) values. + + Args: + r: Red component (0-255) + g: Green component (0-255) + b: Blue component (0-255) + a: Optional alpha component (0-255). 0=transparent, 255=opaque. + + Returns: + Hex color string (#RRGGBB or #RRGGBBAA) + + Example: + rgb(255, 0, 0) # "#ff0000" - Red + rgb(255, 0, 0, 128) # "#ff000080" - Semi-transparent red + """ + for val, name in [(r, 'r'), (g, 'g'), (b, 'b')]: + if not 0 <= val <= 255: + raise ValueError(f"{name} must be 0-255, got {val}") + + if a is not None: + if not 0 <= a <= 255: + raise ValueError(f"a must be 0-255, got {a}") + return f"#{r:02x}{g:02x}{b:02x}{a:02x}" + + return f"#{r:02x}{g:02x}{b:02x}" + + +# ============================================================================= +# DEFAULTS - Easy access to default values +# ============================================================================= + + +class Defaults: + """Default values for HUD properties. + + Use these constants when you want to explicitly set the default value. + """ + + # Layout + ANCHOR = Anchor.TOP_LEFT + LAYOUT_MODE = LayoutMode.AUTO + PRIORITY = 10 + Z_ORDER = 0 + + # Position (for manual mode) + X = 20 + Y = 20 + + # Size + WIDTH = 400 + MAX_HEIGHT = 600 + + # Colors + BG_COLOR = HudColor.BG_DARK + TEXT_COLOR = HudColor.TEXT_PRIMARY + ACCENT_COLOR = HudColor.ACCENT_BLUE + + # Visual + OPACITY = 0.85 + BORDER_RADIUS = 12 + CONTENT_PADDING = 16 + + # Typography + FONT_SIZE = 16 + FONT_FAMILY = FontFamily.SEGOE_UI + + # Behavior + TYPEWRITER_EFFECT = True + TYPEWRITER_SPEED = 200 + AUTO_FADE = True + FADE_DELAY = 8.0 + FADE_DURATION = 0.5 + SHOW_LOADER = True + + # Chat specific + AUTO_HIDE = False + AUTO_HIDE_DELAY = 10.0 + MAX_MESSAGES = 50 + MESSAGE_SPACING = 8 + FADE_OLD_MESSAGES = True + SHOW_TIMESTAMPS = False + + +# ============================================================================= +# CONVENIENCE CONSTRUCTORS +# ============================================================================= + + +def message_props( + *, + anchor: Anchor = None, + priority: int = None, + width: int = None, + max_height: int = None, + bg_color: str = None, + text_color: str = None, + accent_color: str = None, + opacity: float = None, + border_radius: int = None, + font_size: int = None, + font_family: str = None, + typewriter_effect: bool = None, + typewriter_speed: int = None, + fade_delay: float = None, + **kwargs +) -> Dict[str, Any]: + """Create a props dictionary for message windows. + + All parameters are optional - only provided values will be included. + + Returns: + Dictionary of properties ready to pass to create_group or show_message. + + Example: + props = message_props( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.ACCENT_ORANGE, + opacity=0.9 + ) + await client.create_group("notifications", props=props) + """ + props = MessageProps( + anchor=anchor.value if anchor else None, + priority=priority, + width=width, + max_height=max_height, + bg_color=bg_color.value if isinstance(bg_color, HudColor) else bg_color, + text_color=text_color.value if isinstance(text_color, HudColor) else text_color, + accent_color=accent_color.value if isinstance(accent_color, HudColor) else accent_color, + opacity=opacity, + border_radius=border_radius, + font_size=font_size, + font_family=font_family.value if isinstance(font_family, FontFamily) else font_family, + typewriter_effect=typewriter_effect, + typewriter_speed=typewriter_speed, + fade_delay=fade_delay, + **kwargs + ) + return props.to_dict() + + +def chat_window_props( + *, + anchor: Anchor = None, + priority: int = None, + width: int = None, + max_height: int = None, + bg_color: str = None, + text_color: str = None, + accent_color: str = None, + opacity: float = None, + border_radius: int = None, + font_size: int = None, + font_family: str = None, + auto_hide: bool = None, + auto_hide_delay: float = None, + max_messages: int = None, + fade_old_messages: bool = None, + sender_colors: Dict[str, str] = None, + **kwargs +) -> Dict[str, Any]: + """Create a props dictionary for chat windows. + + All parameters are optional - only provided values will be included. + + Returns: + Dictionary of properties ready to pass to create_chat_window. + + Example: + props = chat_window_props( + anchor=Anchor.BOTTOM_LEFT, + max_messages=100, + sender_colors={ + "User": HudColor.ACCENT_GREEN.value, + "AI": HudColor.ACCENT_BLUE.value + } + ) + await client.create_chat_window("chat", **props) + """ + props = ChatWindowProps( + anchor=anchor.value if anchor else None, + priority=priority, + width=width, + max_height=max_height, + bg_color=bg_color.value if isinstance(bg_color, HudColor) else bg_color, + text_color=text_color.value if isinstance(text_color, HudColor) else text_color, + accent_color=accent_color.value if isinstance(accent_color, HudColor) else accent_color, + opacity=opacity, + border_radius=border_radius, + font_size=font_size, + font_family=font_family.value if isinstance(font_family, FontFamily) else font_family, + auto_hide=auto_hide, + auto_hide_delay=auto_hide_delay, + max_messages=max_messages, + fade_old_messages=fade_old_messages, + sender_colors=sender_colors, + **kwargs + ) + return props.to_dict() + + +def persistent_props( + *, + anchor: Anchor = None, + priority: int = None, + width: int = None, + max_height: int = None, + bg_color: str = None, + text_color: str = None, + accent_color: str = None, + opacity: float = None, + border_radius: int = None, + font_size: int = None, + font_family: str = None, + **kwargs +) -> Dict[str, Any]: + """Create a props dictionary for persistent windows (info panels). + + All parameters are optional - only provided values will be included. + + Returns: + Dictionary of properties ready to pass to create_group. + + Example: + props = persistent_props( + anchor=Anchor.TOP_RIGHT, + width=300, + accent_color=HudColor.ACCENT_GREEN + ) + await client.create_group("status_panel", props=props) + """ + props = PersistentProps( + anchor=anchor.value if anchor else None, + priority=priority, + width=width, + max_height=max_height, + bg_color=bg_color.value if isinstance(bg_color, HudColor) else bg_color, + text_color=text_color.value if isinstance(text_color, HudColor) else text_color, + accent_color=accent_color.value if isinstance(accent_color, HudColor) else accent_color, + opacity=opacity, + border_radius=border_radius, + font_size=font_size, + font_family=font_family.value if isinstance(font_family, FontFamily) else font_family, + **kwargs + ) + return props.to_dict() + + +# ============================================================================= +# EXPORTS +# ============================================================================= + +__all__ = [ + # Enums + "Anchor", + "LayoutMode", + "FontFamily", + "HudColor", + "WindowType", + "FadeState", + # Property classes + "BaseProps", + "MessageProps", + "PersistentProps", + "ChatWindowProps", + "ProgressProps", + "TimerProps", + # Helper functions + "color", + "rgb", + # Convenience constructors + "message_props", + "chat_window_props", + "persistent_props", + # Defaults + "Defaults", +] + + diff --git a/skills/hud/default_config.yaml b/skills/hud/default_config.yaml index 6251afd95..d70dd7f66 100644 --- a/skills/hud/default_config.yaml +++ b/skills/hud/default_config.yaml @@ -120,7 +120,7 @@ custom_properties: - id: font_family name: Font Family - hint: Font family for HUD text. + hint: "Font family for HUD text. Available options: Segoe UI, Arial, Verdana, Tahoma, Trebuchet MS, Calibri, Consolas, Courier New, Open Sans." property_type: string value: "Segoe UI" required: false diff --git a/skills/hud/main.py b/skills/hud/main.py index e8360ae6b..0ac59bac8 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -27,6 +27,7 @@ from services.printr import Printr from skills.skill_base import Skill, tool from hud_server.http_client import HudHttpClient +from hud_server.types import Anchor, HudColor, FontFamily, LayoutMode, MessageProps, PersistentProps if TYPE_CHECKING: from wingmen.open_ai_wingman import OpenAiWingman @@ -41,6 +42,9 @@ class HUD(Skill): Uses the integrated HUD Server which must be enabled in global settings. """ + # Valid anchor values from the Anchor enum + VALID_ANCHORS = [a.value for a in Anchor] + def __init__( self, config: SkillConfig, @@ -93,13 +97,6 @@ async def validate(self) -> list[WingmanInitializationError]: ) ) - # Validate custom properties - valid_anchors = [ - "top_left", "top_center", "top_right", - "left_center", "center", "right_center", - "bottom_left", "bottom_center", "bottom_right" - ] - # Color validation helper - supports #RGB, #RRGGBB, or #RRGGBBAA formats def is_valid_hex_color(color: str) -> bool: if not isinstance(color, str): @@ -161,11 +158,11 @@ def is_valid_hex_color(color: str) -> bool: # Validate chat_anchor chat_anchor = self.retrieve_custom_property_value("chat_anchor", errors) - if chat_anchor not in valid_anchors: + if chat_anchor not in self.VALID_ANCHORS: errors.append( WingmanInitializationError( wingman_name=self.wingman.name, - message=f"Invalid chat_anchor: '{chat_anchor}'. Must be one of: {', '.join(valid_anchors)}.", + message=f"Invalid chat_anchor: '{chat_anchor}'. Must be one of: {', '.join(self.VALID_ANCHORS)}.", error_type=WingmanInitializationErrorType.INVALID_CONFIG ) ) @@ -205,11 +202,11 @@ def is_valid_hex_color(color: str) -> bool: # Validate persistent_anchor persistent_anchor = self.retrieve_custom_property_value("persistent_anchor", errors) - if persistent_anchor not in valid_anchors: + if persistent_anchor not in self.VALID_ANCHORS: errors.append( WingmanInitializationError( wingman_name=self.wingman.name, - message=f"Invalid persistent_anchor: '{persistent_anchor}'. Must be one of: {', '.join(valid_anchors)}.", + message=f"Invalid persistent_anchor: '{persistent_anchor}'. Must be one of: {', '.join(self.VALID_ANCHORS)}.", error_type=WingmanInitializationErrorType.INVALID_CONFIG ) ) @@ -353,41 +350,41 @@ def _get_prop(self, key: str, default): val = self.retrieve_custom_property_value(key, []) return val if val is not None else default - def _get_hud_props(self) -> dict: + def _get_hud_props(self) -> MessageProps: """Get all HUD visual properties as a dictionary.""" - return { - 'anchor': str(self._get_prop("chat_anchor", "top_left")), - 'priority': int(self._get_prop("chat_priority", 20)), - 'layout_mode': 'auto', - 'width': int(self._get_prop("hud_width", 400)), - 'max_height': int(self._get_prop("hud_max_height", 600)), - 'bg_color': str(self._get_prop("bg_color", "#1e212b")), - 'text_color': str(self._get_prop("text_color", "#f0f0f0")), - 'accent_color': str(self._get_prop("accent_color", "#00aaff")), - 'opacity': float(self._get_prop("opacity", 0.85)), - 'border_radius': int(self._get_prop("border_radius", 12)), - 'font_size': int(self._get_prop("font_size", 16)), - 'content_padding': int(self._get_prop("content_padding", 16)), - 'font_family': str(self._get_prop("font_family", "Segoe UI")), - 'typewriter_effect': bool(self._get_prop("typewriter_effect", True)), - } + return MessageProps( + anchor=str(self._get_prop("chat_anchor", Anchor.TOP_LEFT)), + priority=int(self._get_prop("chat_priority", 20)), + layout_mode=LayoutMode.AUTO, + width=int(self._get_prop("hud_width", 400)), + max_height=int(self._get_prop("hud_max_height", 600)), + bg_color=str(self._get_prop("bg_color", HudColor.BG_DARK)), + text_color=str(self._get_prop("text_color", HudColor.TEXT_PRIMARY)), + accent_color=str(self._get_prop("accent_color", HudColor.ACCENT_BLUE)), + opacity=float(self._get_prop("opacity", 0.85)), + border_radius=int(self._get_prop("border_radius", 12)), + font_size=int(self._get_prop("font_size", 16)), + content_padding=int(self._get_prop("content_padding", 16)), + font_family=str(self._get_prop("font_family", FontFamily.SEGOE_UI)), + typewriter_effect=bool(self._get_prop("typewriter_effect", True)), + ) - def _get_persistent_props(self) -> dict: + def _get_persistent_props(self) -> PersistentProps: """Get properties for persistent info panels.""" - return { - 'anchor': str(self._get_prop("persistent_anchor", "top_left")), - 'priority': int(self._get_prop("persistent_priority", 10)), - 'layout_mode': 'auto', - 'persistent_width': int(self._get_prop("persistent_width", 400)), - 'bg_color': str(self._get_prop("bg_color", "#1e212b")), - 'text_color': str(self._get_prop("text_color", "#f0f0f0")), - 'accent_color': str(self._get_prop("accent_color", "#00aaff")), - 'opacity': float(self._get_prop("opacity", 0.85)), - 'border_radius': int(self._get_prop("border_radius", 12)), - 'font_size': int(self._get_prop("font_size", 16)), - 'content_padding': int(self._get_prop("content_padding", 16)), - 'font_family': str(self._get_prop("font_family", "Segoe UI")), - } + return PersistentProps( + anchor=str(self._get_prop("persistent_anchor", Anchor.TOP_LEFT)), + priority=int(self._get_prop("persistent_priority", 10)), + layout_mode=LayoutMode.AUTO, + width=int(self._get_prop("persistent_width", 400)), + bg_color=str(self._get_prop("bg_color", HudColor.BG_DARK)), + text_color=str(self._get_prop("text_color", HudColor.TEXT_PRIMARY)), + accent_color=str(self._get_prop("accent_color", HudColor.ACCENT_BLUE)), + opacity=float(self._get_prop("opacity", 0.85)), + border_radius=int(self._get_prop("border_radius", 12)), + font_size=int(self._get_prop("font_size", 16)), + content_padding=int(self._get_prop("content_padding", 16)), + font_family=str(self._get_prop("font_family", FontFamily.SEGOE_UI)) + ) async def update_config(self, new_config) -> None: """Handle configuration updates - recreate HUD groups with new settings.""" @@ -700,14 +697,14 @@ async def _show_message( message: str, color: str, tools: list = None, - duration: float = 60.0 + duration: float = 180.0 ): """Show a message on the HUD.""" if not await self._ensure_connected(): return props = self._get_hud_props() - props['duration'] = duration + props.fade_delay = duration result = await self._client.show_message( group_name=self._messages_group, From cd02da2a6e78482e2944dbfecaab005361b6952f Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 10 Feb 2026 19:11:28 +0100 Subject: [PATCH 12/27] Refactor HUD server shutdown process to remove unnecessary await calls for xvasynth and pocket TTS --- wingman_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wingman_core.py b/wingman_core.py index 06b06d4cf..40d510d4e 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -1638,9 +1638,9 @@ async def shutdown(self): await self._stop_hud_server() if self.settings_service.settings.xvasynth.enable: - await self.stop_xvasynth() + self.stop_xvasynth() if self.settings_service.settings.pocket_tts.enable: - await self.stop_pocket_tts() + self.stop_pocket_tts() await self.unload_tower() self.printr.print( From 019658f87f15dfbeabf6fa6c05646bb071e2e7be Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 10 Feb 2026 21:35:27 +0100 Subject: [PATCH 13/27] Add persistent max height configuration for info panels and validate input --- hud_server/overlay/overlay.py | 7 +++++-- skills/hud/default_config.yaml | 7 +++++++ skills/hud/main.py | 27 ++++++++++++++++++--------- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index b8575e785..62662e713 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -967,6 +967,7 @@ def _draw_persistent_window(self, name: str, win: Dict): accent = self._hex_to_rgb(props.get('accent_color', '#00aaff')) width = int(props.get('width', 300)) + max_height = int(props.get('max_height', 600)) radius = int(props.get('border_radius', 12)) padding = int(props.get('content_padding', 16)) @@ -983,7 +984,7 @@ def _draw_persistent_window(self, name: str, win: Dict): # Include visual props in state hash for real-time config updates visual_props_hash = ( - width, radius, padding, + width, max_height, radius, padding, bg, bg_alpha, text_color, accent, props.get('opacity', 0.85), props.get('font_size', 16), @@ -1143,7 +1144,9 @@ def _draw_persistent_window(self, name: str, win: Dict): # Finalize canvas bottom_padding = padding - 4 - final_h = max(60, y + bottom_padding) + # Calculate final height - constrain to max_height if content exceeds it + calculated_height = max(60, y + bottom_padding) + final_h = min(calculated_height, max_height) # Create final canvas - ALWAYS create fresh to prevent ghosting old_canvas = win.get('canvas') diff --git a/skills/hud/default_config.yaml b/skills/hud/default_config.yaml index d70dd7f66..cd953135d 100644 --- a/skills/hud/default_config.yaml +++ b/skills/hud/default_config.yaml @@ -90,6 +90,13 @@ custom_properties: value: 400 required: false + - id: persistent_max_height + name: Info Panel Max Height + hint: Maximum height of persistent info panels in pixels. Content will be clipped if it exceeds this height. + property_type: number + value: 600 + required: false + - id: opacity name: Opacity hint: Transparency of HUD elements (0.0 to 1.0). diff --git a/skills/hud/main.py b/skills/hud/main.py index 0ac59bac8..aa1c94ba2 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -233,6 +233,17 @@ def is_valid_hex_color(color: str) -> bool: ) ) + # Validate persistent_max_height + persistent_max_height = self.retrieve_custom_property_value("persistent_max_height", errors) + if not isinstance(persistent_max_height, (int, float)) or persistent_max_height <= 0: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid persistent_max_height: '{persistent_max_height}'. Must be a positive number.", + error_type=WingmanInitializationErrorType.INVALID_CONFIG + ) + ) + # Validate opacity opacity = self.retrieve_custom_property_value("opacity", errors) if not isinstance(opacity, (int, float)) or not (0.0 <= opacity <= 1.0): @@ -376,6 +387,7 @@ def _get_persistent_props(self) -> PersistentProps: priority=int(self._get_prop("persistent_priority", 10)), layout_mode=LayoutMode.AUTO, width=int(self._get_prop("persistent_width", 400)), + max_height=int(self._get_prop("persistent_max_height", 600)), bg_color=str(self._get_prop("bg_color", HudColor.BG_DARK)), text_color=str(self._get_prop("text_color", HudColor.TEXT_PRIMARY)), accent_color=str(self._get_prop("accent_color", HudColor.ACCENT_BLUE)), @@ -613,7 +625,9 @@ async def unload(self) -> None: # Save state self._save_persistent_items() - self._persistent_items.clear() + self.hud_clear_all(False) + await self._client.delete_group(self._messages_group) + await self._client.delete_group(self._persistent_group) # Disconnect client if self._client: @@ -625,12 +639,6 @@ async def unload(self) -> None: self.active = False - # Reset prepared state so skill can be reactivated - # (base class doesn't do this, so we need to do it explicitly) - self.is_prepared = False - self.is_validated = False - self.is_unloaded = False # Allow unload to be called again on next deactivation - # ─────────────────────────────── Audio Monitor ─────────────────────────────── # async def _audio_monitor_loop(self): @@ -1026,7 +1034,7 @@ def hud_list_info(self) -> str: return json.dumps(active_items, indent=2) @tool() - def hud_clear_all(self) -> str: + def hud_clear_all(self, save: bool = True) -> str: """ Remove all information panels and progress bars from the HUD. """ @@ -1041,7 +1049,8 @@ def hud_clear_all(self) -> str: self._client.remove_item(group_name=self._persistent_group, title=title) ) - self._save_persistent_items() + if save: + self._save_persistent_items() return f"Cleared {cleared_count} item(s) from HUD." From 5208662ce6d6667149ae6df50771393f4a4fecf2 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 10 Feb 2026 22:52:02 +0100 Subject: [PATCH 14/27] Refactor HUD message properties to use type-safe enums and structured classes for improved clarity and maintainability --- hud_server/http_client.py | 10 +- hud_server/tests/debug_layout.py | 24 +- hud_server/tests/test_chat.py | 103 ++++---- hud_server/tests/test_layout_visual.py | 315 ++++++++++++------------- hud_server/tests/test_multiuser.py | 80 +++++-- hud_server/tests/test_session.py | 132 ++++++----- hud_server/tests/test_snake.py | 310 +++++++----------------- 7 files changed, 467 insertions(+), 507 deletions(-) diff --git a/hud_server/http_client.py b/hud_server/http_client.py index 18a18448f..d949da752 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -518,7 +518,7 @@ async def show_progress( description: str = "", color: Optional[Union[str, HudColor]] = None, auto_close: bool = False, - props: Optional[dict] = None + props: Optional[BaseProps] = None ) -> Optional[dict]: """Show or update a progress bar. @@ -569,7 +569,7 @@ async def show_timer( color: Optional[Union[str, HudColor]] = None, auto_close: bool = True, initial_progress: float = 0, - props: Optional[dict] = None + props: Optional[BaseProps] = None ) -> Optional[dict]: """Show a timer-based progress bar. @@ -947,7 +947,7 @@ def show_message( content: str, color: Optional[Union[str, HudColor]] = None, tools: Optional[list] = None, - props: Optional[dict] = None, + props: Optional[BaseProps] = None, duration: Optional[float] = None ): """Show a message. Color accepts HudColor enum or hex string.""" @@ -1011,7 +1011,7 @@ def show_progress( description: str = "", color: Optional[Union[str, HudColor]] = None, auto_close: bool = False, - props: Optional[dict] = None + props: Optional[BaseProps] = None ): """Show progress bar. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_progress( @@ -1027,7 +1027,7 @@ def show_timer( color: Optional[Union[str, HudColor]] = None, auto_close: bool = True, initial_progress: float = 0, - props: Optional[dict] = None + props: Optional[BaseProps] = None ): """Show timer. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_timer( diff --git a/hud_server/tests/debug_layout.py b/hud_server/tests/debug_layout.py index 46de689c6..2b0d99a58 100644 --- a/hud_server/tests/debug_layout.py +++ b/hud_server/tests/debug_layout.py @@ -7,6 +7,7 @@ sys.path.insert(0, ".") from hud_server.tests.test_runner import TestContext +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps async def debug_layout_test(session): @@ -19,22 +20,21 @@ async def debug_layout_test(session): # Create three groups with different priorities groups_config = [ - ("debug_red", 30, "#ff0000", "RED - Priority 30"), - ("debug_green", 20, "#00ff00", "GREEN - Priority 20"), - ("debug_blue", 10, "#0000ff", "BLUE - Priority 10"), + ("debug_red", 30, HudColor.RED, "RED - Priority 30"), + ("debug_green", 20, HudColor.GREEN, "GREEN - Priority 20"), + ("debug_blue", 10, HudColor.BLUE, "BLUE - Priority 10"), ] print("\n1. Creating groups...") for name, priority, color, label in groups_config: - await client.create_group(name, props={ - "anchor": "top_left", - "priority": priority, - "layout_mode": "auto", - "margin": 20, - "spacing": 15, - "width": 400, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=400, + accent_color=color.value, + ) + await client.create_group(name, props=props) print(f" Created: {name} (priority={priority})") await asyncio.sleep(0.5) diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index 9af761e59..db073157d 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -12,6 +12,7 @@ import asyncio from hud_server.tests.test_session import TestSession +from hud_server.types import Anchor, LayoutMode, HudColor, ChatWindowProps # Emoji constants using Unicode escape sequences (avoids file encoding issues) EMOJI_ROCKET = "\U0001F680" # 🚀 @@ -182,19 +183,23 @@ async def test_chat_basic(session: TestSession): chat_name = f"chat_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + # Get the anchor value from config + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=50, # High priority - appears first - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=session.config["hud_width"], max_height=300, auto_hide=False, - bg_color=session.config["bg_color"], - text_color=session.config["text_color"], - accent_color=session.config["accent_color"], + bg_color=session._get_color_value(session.config["bg_color"]), + text_color=session._get_color_value(session.config["text_color"]), + accent_color=session._get_color_value(session.config["accent_color"]), opacity=session.config["opacity"], ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) await session.send_chat_message(chat_name, "User", "Hello!") @@ -216,20 +221,23 @@ async def test_chat_markdown(session: TestSession): chat_name = f"md_chat_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=40, # Second priority - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=450, max_height=400, auto_hide=False, sender_colors={ - "User": session.config["user_color"], - session.name: session.config["accent_color"], - "System": "#888888", + "User": session._get_color_value(session.config["user_color"]), + session.name: session._get_color_value(session.config["accent_color"]), + "System": HudColor.GRAY.value, }, ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) # Test various markdown features (alternate senders so each renders separately) @@ -286,23 +294,26 @@ async def test_chat_conversation(session: TestSession, conversation: list = None # Determine unique senders for colors senders = list(set(msg[0] for msg in conversation)) - colors = ["#4cd964", "#00aaff", "#ff9500", "#9b59b6", "#888888"] + colors = [HudColor.SUCCESS.value, HudColor.ACCENT_BLUE.value, HudColor.ACCENT_ORANGE.value, HudColor.ACCENT_PURPLE.value, HudColor.GRAY.value] sender_colors = {s: colors[i % len(colors)] for i, s in enumerate(senders)} - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=30, # Third priority - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=450, max_height=400, auto_hide=True, auto_hide_delay=10.0, fade_old_messages=True, sender_colors=sender_colors, - bg_color=session.config["bg_color"], + bg_color=session._get_color_value(session.config["bg_color"]), opacity=0.92, ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) for sender, message, delay in conversation: @@ -322,16 +333,19 @@ async def test_chat_auto_hide(session: TestSession): chat_name = f"autohide_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=20, # Fourth priority - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=350, max_height=250, auto_hide=True, auto_hide_delay=3.0, # Short delay for testing ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) await session.send_chat_message(chat_name, "Test", "This will auto-hide in 3 seconds...") @@ -353,15 +367,18 @@ async def test_chat_overflow(session: TestSession): chat_name = f"overflow_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=10, # Fifth priority - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=400, max_height=200, # Small height to trigger overflow fade_old_messages=True, ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) # Send many messages to overflow (unique senders per message to prevent merging) @@ -380,19 +397,22 @@ async def test_chat_message_merging(session: TestSession): chat_name = f"merge_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=45, - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=400, max_height=300, auto_hide=False, sender_colors={ - "Alice": "#4cd964", - "Bob": "#00aaff", + "Alice": HudColor.SUCCESS.value, + "Bob": HudColor.ACCENT_BLUE.value, }, ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) # Same sender consecutive - should merge into one block @@ -445,19 +465,22 @@ async def test_chat_message_update(session: TestSession): chat_name = f"update_{session.session_id}" - await session.create_chat_window( - name=chat_name, - anchor=session.config.get("anchor", "top_left"), + anchor = session.config.get("anchor", Anchor.TOP_LEFT) + anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + + props = ChatWindowProps( + anchor=anchor_value, priority=35, - layout_mode="auto", + layout_mode=LayoutMode.AUTO.value, width=400, max_height=300, auto_hide=False, sender_colors={ - "Alice": "#4cd964", - "Bob": "#00aaff", + "Alice": HudColor.SUCCESS.value, + "Bob": HudColor.ACCENT_BLUE.value, }, ) + await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) # Send a message and get its ID diff --git a/hud_server/tests/test_layout_visual.py b/hud_server/tests/test_layout_visual.py index 77a40dded..4b4de15f6 100644 --- a/hud_server/tests/test_layout_visual.py +++ b/hud_server/tests/test_layout_visual.py @@ -17,6 +17,7 @@ sys.path.insert(0, ".") from hud_server.tests.test_runner import TestContext +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps # ============================================================================= @@ -24,54 +25,59 @@ # ============================================================================= ANCHOR_CONFIG = { - "top_left": { + Anchor.TOP_LEFT: { "label": "TOP LEFT", - "color": "#ff5555", + "color": HudColor.ERROR, "emoji_fallback": "[TL]", }, - "top_center": { + Anchor.TOP_CENTER: { "label": "TOP CENTER", - "color": "#ffaa00", + "color": HudColor.ACCENT_ORANGE, "emoji_fallback": "[TC]", }, - "top_right": { + Anchor.TOP_RIGHT: { "label": "TOP RIGHT", - "color": "#55ff55", + "color": HudColor.ACCENT_GREEN, "emoji_fallback": "[TR]", }, - "right_center": { + Anchor.RIGHT_CENTER: { "label": "RIGHT CENTER", - "color": "#55ffff", + "color": HudColor.CYAN, "emoji_fallback": "[RC]", }, - "bottom_right": { + Anchor.BOTTOM_RIGHT: { "label": "BOTTOM RIGHT", - "color": "#5555ff", + "color": HudColor.BLUE, "emoji_fallback": "[BR]", }, - "bottom_center": { + Anchor.BOTTOM_CENTER: { "label": "BOTTOM CENTER", - "color": "#ff55ff", + "color": HudColor.MAGENTA, "emoji_fallback": "[BC]", }, - "bottom_left": { + Anchor.BOTTOM_LEFT: { "label": "BOTTOM LEFT", - "color": "#ffff55", + "color": HudColor.YELLOW, "emoji_fallback": "[BL]", }, - "left_center": { + Anchor.LEFT_CENTER: { "label": "LEFT CENTER", "color": "#ff8855", "emoji_fallback": "[LC]", }, - "center": { + Anchor.CENTER: { "label": "CENTER", - "color": "#ffffff", + "color": HudColor.WHITE, "emoji_fallback": "[C]", }, } +def _get_value(val): + """Get the string value from an enum or return as-is.""" + return val.value if hasattr(val, 'value') else val + + async def cleanup_groups(client, group_names): """Helper to clean up groups.""" for name in group_names: @@ -93,23 +99,22 @@ async def test_all_nine_anchors(session): groups = [] for anchor, config in ANCHOR_CONFIG.items(): - group_name = f"anchor_{anchor}" + group_name = f"anchor_{_get_value(anchor)}" groups.append(group_name) - await client.create_group(group_name, props={ - "anchor": anchor, - "priority": 10, - "layout_mode": "auto", - "margin": 25, - "spacing": 10, - "width": 280, - "accent_color": config["color"], - }) + props = MessageProps( + anchor=_get_value(anchor), + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=280, + accent_color=_get_value(config["color"]), + ) + await client.create_group(group_name, props=props) await client.show_message( group_name, title=f"{config['emoji_fallback']} {config['label']}", - content=f"Anchor: **{anchor}**\n\nThis window is positioned at the {config['label'].lower()} of the screen.", + content=f"Anchor: **{_get_value(anchor)}**\n\nThis window is positioned at the {config['label'].lower()} of the screen.", duration=30.0 ) await asyncio.sleep(0.15) @@ -136,24 +141,23 @@ async def test_priority_stacking(session): # Test stacking at TOP_LEFT with 3 priority levels priorities = [ - ("stack_high", 30, "#ff3333", "HIGH Priority (30)"), - ("stack_med", 20, "#33ff33", "MEDIUM Priority (20)"), - ("stack_low", 10, "#3333ff", "LOW Priority (10)"), + ("stack_high", 30, HudColor.ERROR, "HIGH Priority (30)"), + ("stack_med", 20, HudColor.ACCENT_GREEN, "MEDIUM Priority (20)"), + ("stack_low", 10, HudColor.INFO, "LOW Priority (10)"), ] print("Creating 3 windows at TOP_LEFT with different priorities...") for name, priority, color, label in priorities: groups.append(name) - await client.create_group(name, props={ - "anchor": "top_left", - "priority": priority, - "layout_mode": "auto", - "margin": 20, - "spacing": 12, - "width": 380, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=380, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, @@ -173,17 +177,16 @@ async def test_priority_stacking(session): # Now add windows to TOP_RIGHT to show parallel stacking print("\nAdding 2 windows to TOP_RIGHT...") - for name, priority, color in [("right_a", 25, "#ff9900"), ("right_b", 15, "#9900ff")]: + for name, priority, color in [("right_a", 25, HudColor.ACCENT_ORANGE), ("right_b", 15, HudColor.ACCENT_PURPLE)]: groups.append(name) - await client.create_group(name, props={ - "anchor": "top_right", - "priority": priority, - "layout_mode": "auto", - "margin": 20, - "spacing": 12, - "width": 320, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.TOP_RIGHT.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=320, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, @@ -210,25 +213,23 @@ async def test_dynamic_height_changes(session): groups = ["dyn_top", "dyn_bottom"] # Create two stacked windows - await client.create_group("dyn_top", props={ - "anchor": "top_left", - "priority": 20, - "layout_mode": "auto", - "margin": 20, - "spacing": 15, - "width": 420, - "accent_color": "#ff6600", - }) - - await client.create_group("dyn_bottom", props={ - "anchor": "top_left", - "priority": 10, - "layout_mode": "auto", - "margin": 20, - "spacing": 15, - "width": 420, - "accent_color": "#0066ff", - }) + top_props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=20, + layout_mode=LayoutMode.AUTO.value, + width=420, + accent_color=HudColor.ACCENT_ORANGE.value, + ) + await client.create_group("dyn_top", props=top_props) + + bottom_props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=420, + accent_color=HudColor.ACCENT_BLUE.value, + ) + await client.create_group("dyn_bottom", props=bottom_props) # Phase 1: Short top window print("Phase 1: Top window is SHORT") @@ -310,19 +311,18 @@ async def test_visibility_reflow(session): client = session._client groups = ["vis_1", "vis_2", "vis_3"] - colors = ["#ff0000", "#00ff00", "#0000ff"] + colors = [HudColor.RED, HudColor.GREEN, HudColor.BLUE] labels = ["First (Red)", "Second (Green)", "Third (Blue)"] for i, (name, color, label) in enumerate(zip(groups, colors, labels)): - await client.create_group(name, props={ - "anchor": "top_left", - "priority": 30 - (i * 10), - "layout_mode": "auto", - "margin": 20, - "spacing": 12, - "width": 380, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=30 - (i * 10), + layout_mode=LayoutMode.AUTO.value, + width=380, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) # Show all three print("Phase 1: All 3 windows visible") @@ -365,29 +365,29 @@ async def test_opposite_anchors(session): groups = [] pairs = [ - ("diag_tl", "top_left", "#ff0000", "TOP-LEFT Corner"), - ("diag_br", "bottom_right", "#00ff00", "BOTTOM-RIGHT Corner"), - ("diag_tr", "top_right", "#0000ff", "TOP-RIGHT Corner"), - ("diag_bl", "bottom_left", "#ffff00", "BOTTOM-LEFT Corner"), + ("diag_tl", Anchor.TOP_LEFT, HudColor.RED, "TOP-LEFT Corner"), + ("diag_br", Anchor.BOTTOM_RIGHT, HudColor.GREEN, "BOTTOM-RIGHT Corner"), + ("diag_tr", Anchor.TOP_RIGHT, HudColor.BLUE, "TOP-RIGHT Corner"), + ("diag_bl", Anchor.BOTTOM_LEFT, HudColor.YELLOW, "BOTTOM-LEFT Corner"), ] print("Creating windows at all 4 corners...") for name, anchor, color, label in pairs: groups.append(name) - await client.create_group(name, props={ - "anchor": anchor, - "priority": 10, - "layout_mode": "auto", - "margin": 20, - "width": 320, - "accent_color": color, - }) + props = MessageProps( + anchor=_get_value(anchor), + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=320, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, title=label, - content=f"Anchor: **{anchor}**\n\nDiagonal positioning test.", + content=f"Anchor: **{_get_value(anchor)}**\n\nDiagonal positioning test.", duration=15.0 ) await asyncio.sleep(0.15) @@ -410,13 +410,14 @@ async def test_center_anchors(session): # First show center groups.append("center_main") - await client.create_group("center_main", props={ - "anchor": "center", - "priority": 10, - "layout_mode": "auto", - "width": 350, - "accent_color": "#ffffff", - }) + center_props = MessageProps( + anchor=Anchor.CENTER.value, + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=350, + accent_color=HudColor.WHITE.value, + ) + await client.create_group("center_main", props=center_props) await client.show_message( "center_main", @@ -430,29 +431,29 @@ async def test_center_anchors(session): # Add edge centers edge_centers = [ - ("edge_top", "top_center", "#ff9900", "TOP CENTER EDGE"), - ("edge_bottom", "bottom_center", "#9900ff", "BOTTOM CENTER EDGE"), - ("edge_left", "left_center", "#00ff99", "LEFT CENTER EDGE"), - ("edge_right", "right_center", "#ff0099", "RIGHT CENTER EDGE"), + ("edge_top", Anchor.TOP_CENTER, HudColor.ACCENT_ORANGE, "TOP CENTER EDGE"), + ("edge_bottom", Anchor.BOTTOM_CENTER, HudColor.ACCENT_PURPLE, "BOTTOM CENTER EDGE"), + ("edge_left", Anchor.LEFT_CENTER, HudColor.ACCENT_GREEN, "LEFT CENTER EDGE"), + ("edge_right", Anchor.RIGHT_CENTER, HudColor.ACCENT_PINK, "RIGHT CENTER EDGE"), ] print("Adding edge-center windows...") for name, anchor, color, label in edge_centers: groups.append(name) - await client.create_group(name, props={ - "anchor": anchor, - "priority": 10, - "layout_mode": "auto", - "margin": 20, - "width": 260, - "accent_color": color, - }) + props = MessageProps( + anchor=_get_value(anchor), + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=260, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, title=label, - content=f"Positioned at the {anchor.replace('_', ' ')}.", + content=f"Positioned at the {_get_value(anchor).replace('_', ' ')}.", duration=15.0 ) await asyncio.sleep(0.2) @@ -477,19 +478,18 @@ async def test_stacking_at_edge_centers(session): # Stack 3 windows at left_center print("Stacking 3 windows at LEFT_CENTER...") - for i, (priority, color) in enumerate([(30, "#ff5555"), (20, "#55ff55"), (10, "#5555ff")]): + for i, (priority, color) in enumerate([(30, HudColor.ERROR), (20, HudColor.SUCCESS), (10, HudColor.INFO)]): name = f"left_stack_{i}" groups.append(name) - await client.create_group(name, props={ - "anchor": "left_center", - "priority": priority, - "layout_mode": "auto", - "margin": 20, - "spacing": 10, - "width": 280, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.LEFT_CENTER.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=280, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, @@ -502,19 +502,18 @@ async def test_stacking_at_edge_centers(session): # Stack 2 windows at right_center print("Stacking 2 windows at RIGHT_CENTER...") - for i, (priority, color) in enumerate([(25, "#ff9900"), (15, "#9900ff")]): + for i, (priority, color) in enumerate([(25, HudColor.ACCENT_ORANGE), (15, HudColor.ACCENT_PURPLE)]): name = f"right_stack_{i}" groups.append(name) - await client.create_group(name, props={ - "anchor": "right_center", - "priority": priority, - "layout_mode": "auto", - "margin": 20, - "spacing": 10, - "width": 280, - "accent_color": color, - }) + props = MessageProps( + anchor=Anchor.RIGHT_CENTER.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=280, + accent_color=_get_value(color), + ) + await client.create_group(name, props=props) await client.show_message( name, @@ -541,15 +540,14 @@ async def test_mixed_content_with_progress(session): groups = ["msg_group", "progress_group"] # Message window at top - await client.create_group("msg_group", props={ - "anchor": "top_left", - "priority": 20, - "layout_mode": "auto", - "margin": 20, - "spacing": 15, - "width": 400, - "accent_color": "#00aaff", - }) + msg_props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=20, + layout_mode=LayoutMode.AUTO.value, + width=400, + accent_color=HudColor.ACCENT_BLUE.value, + ) + await client.create_group("msg_group", props=msg_props) await client.show_message( "msg_group", @@ -559,15 +557,14 @@ async def test_mixed_content_with_progress(session): ) # Progress window below - await client.create_group("progress_group", props={ - "anchor": "top_left", - "priority": 10, - "layout_mode": "auto", - "margin": 20, - "spacing": 15, - "width": 380, - "accent_color": "#ffaa00", - }) + progress_props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=10, + layout_mode=LayoutMode.AUTO.value, + width=380, + accent_color=HudColor.ACCENT_ORANGE.value, + ) + await client.create_group("progress_group", props=progress_props) # Add progress bar await client.show_progress( @@ -608,17 +605,17 @@ async def test_rapid_show_hide(session): client = session._client groups = ["rapid_1", "rapid_2", "rapid_3"] + colors = [HudColor.RED, HudColor.GREEN, HudColor.BLUE] for i, name in enumerate(groups): - await client.create_group(name, props={ - "anchor": "top_left", - "priority": 30 - (i * 10), - "layout_mode": "auto", - "margin": 20, - "spacing": 10, - "width": 350, - "accent_color": ["#ff0000", "#00ff00", "#0000ff"][i], - }) + props = MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=30 - (i * 10), + layout_mode=LayoutMode.AUTO.value, + width=350, + accent_color=colors[i].value, + ) + await client.create_group(name, props=props) print("Performing 5 rapid show/hide cycles...") diff --git a/hud_server/tests/test_multiuser.py b/hud_server/tests/test_multiuser.py index dd984a822..3d882b0dd 100644 --- a/hud_server/tests/test_multiuser.py +++ b/hud_server/tests/test_multiuser.py @@ -18,6 +18,10 @@ from dataclasses import dataclass, field from hud_server.http_client import HudHttpClient +from hud_server.types import ( + HudColor, LayoutMode, + MessageProps, PersistentProps, ChatWindowProps +) # ============================================================================= @@ -29,13 +33,14 @@ "display_name": "Alice", # Private HUD - top-left corner (blue theme) "private_hud": { + "layout_mode": LayoutMode.MANUAL.value, "x": 20, "y": 20, "width": 380, "max_height": 350, "bg_color": "#1a2332", "text_color": "#e8f4ff", - "accent_color": "#00aaff", + "accent_color": HudColor.ACCENT_BLUE.value, "opacity": 0.92, "border_radius": 10, "font_size": 15, @@ -45,13 +50,14 @@ }, # Private persistent panel - below main HUD "private_persistent": { + "layout_mode": LayoutMode.MANUAL.value, "x": 20, "y": 390, "width": 320, "max_height": 300, "bg_color": "#1a2332", "text_color": "#e8f4ff", - "accent_color": "#00d4aa", + "accent_color": HudColor.ACCENT_GREEN.value, "opacity": 0.85, "border_radius": 8, "font_size": 14, @@ -63,13 +69,14 @@ "display_name": "Bob", # Private HUD - top-right corner (orange theme) "private_hud": { + "layout_mode": LayoutMode.MANUAL.value, "x": 1500, "y": 20, "width": 400, "max_height": 380, "bg_color": "#2a1f1a", "text_color": "#fff5e8", - "accent_color": "#ff8c42", + "accent_color": HudColor.ACCENT_ORANGE.value, "opacity": 0.90, "border_radius": 14, "font_size": 16, @@ -79,13 +86,14 @@ }, # Private persistent panel - right side "private_persistent": { + "layout_mode": LayoutMode.MANUAL.value, "x": 1520, "y": 420, "width": 340, "max_height": 280, "bg_color": "#2a1f1a", "text_color": "#fff5e8", - "accent_color": "#ffa500", + "accent_color": HudColor.WARNING.value, "opacity": 0.82, "border_radius": 10, "font_size": 13, @@ -97,13 +105,14 @@ "display_name": "Charlie", # Private HUD - bottom-left corner (purple theme) "private_hud": { + "layout_mode": LayoutMode.MANUAL.value, "x": 20, "y": 720, "width": 420, "max_height": 320, "bg_color": "#1f1a2a", "text_color": "#f0e8ff", - "accent_color": "#9b59b6", + "accent_color": HudColor.ACCENT_PURPLE.value, "opacity": 0.88, "border_radius": 16, "font_size": 15, @@ -113,6 +122,7 @@ }, # Private persistent panel - bottom area "private_persistent": { + "layout_mode": LayoutMode.MANUAL.value, "x": 460, "y": 800, "width": 350, @@ -133,13 +143,14 @@ SHARED_CONFIGS = { "team_notifications": { "name": "Team Notifications", + "layout_mode": LayoutMode.MANUAL.value, "x": 800, "y": 20, "width": 450, "max_height": 400, "bg_color": "#1a1a2e", - "text_color": "#ffffff", - "accent_color": "#e74c3c", + "text_color": HudColor.WHITE.value, + "accent_color": HudColor.ERROR.value, "opacity": 0.95, "border_radius": 12, "font_size": 16, @@ -148,13 +159,14 @@ }, "team_chat": { "name": "Team Chat", + "layout_mode": LayoutMode.MANUAL.value, "x": 800, "y": 440, "width": 480, "max_height": 450, "bg_color": "#16213e", "text_color": "#e8e8e8", - "accent_color": "#3498db", + "accent_color": HudColor.INFO.value, "opacity": 0.90, "border_radius": 10, "font_size": 14, @@ -163,22 +175,23 @@ "max_messages": 100, "fade_old_messages": True, "sender_colors": { - "Alice": "#00aaff", - "Bob": "#ff8c42", - "Charlie": "#9b59b6", - "System": "#95a5a6", + "Alice": HudColor.ACCENT_BLUE.value, + "Bob": HudColor.ACCENT_ORANGE.value, + "Charlie": HudColor.ACCENT_PURPLE.value, + "System": HudColor.GRAY.value, }, "z_order": 15, }, "shared_status": { "name": "Shared Status", + "layout_mode": LayoutMode.MANUAL.value, "x": 1300, "y": 720, "width": 360, "max_height": 300, "bg_color": "#0d1b2a", "text_color": "#d0d0d0", - "accent_color": "#2ecc71", + "accent_color": HudColor.SUCCESS.value, "opacity": 0.85, "border_radius": 8, "font_size": 14, @@ -236,6 +249,25 @@ async def disconnect(self): self.connected = False print(f"[{self.display_name}] Disconnected") + def _config_to_props(self, config: dict) -> MessageProps: + """Convert a config dict to MessageProps.""" + return MessageProps( + layout_mode=config.get("layout_mode"), + x=config.get("x"), + y=config.get("y"), + width=config.get("width"), + max_height=config.get("max_height"), + bg_color=config.get("bg_color"), + text_color=config.get("text_color"), + accent_color=config.get("accent_color"), + opacity=config.get("opacity"), + border_radius=config.get("border_radius"), + font_size=config.get("font_size"), + typewriter_effect=config.get("typewriter_effect"), + fade_delay=config.get("fade_delay"), + z_order=config.get("z_order"), + ) + async def setup_private_groups(self): """Create the user's private HUD groups.""" if not self._client: @@ -244,14 +276,14 @@ async def setup_private_groups(self): # Create private HUD await self._client.create_group( self.private_hud_group, - props=self.config["private_hud"] + props=self._config_to_props(self.config["private_hud"]) ) print(f"[{self.display_name}] Created private HUD group") # Create private persistent panel await self._client.create_group( self.private_persistent_group, - props=self.config["private_persistent"] + props=self._config_to_props(self.config["private_persistent"]) ) print(f"[{self.display_name}] Created private persistent group") @@ -420,7 +452,23 @@ async def setup_shared_groups(self): fade_old_messages=config.get("fade_old_messages", True), ) else: - await self._client.create_group(group_id, props=config) + # Convert config dict to MessageProps + props = MessageProps( + layout_mode=config.get("layout_mode"), + x=config.get("x"), + y=config.get("y"), + width=config.get("width"), + max_height=config.get("max_height"), + bg_color=config.get("bg_color"), + text_color=config.get("text_color"), + accent_color=config.get("accent_color"), + opacity=config.get("opacity"), + border_radius=config.get("border_radius"), + font_size=config.get("font_size"), + typewriter_effect=config.get("typewriter_effect"), + z_order=config.get("z_order"), + ) + await self._client.create_group(group_id, props=props) print(f"[SharedGroupManager] Created shared group: {config['name']}") async def cleanup_shared_groups(self): diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py index 99ad43c75..f0d07579d 100644 --- a/hud_server/tests/test_session.py +++ b/hud_server/tests/test_session.py @@ -8,6 +8,10 @@ from typing import Optional, Any from hud_server.http_client import HudHttpClient +from hud_server.types import ( + Anchor, LayoutMode, HudColor, FontFamily, + MessageProps, PersistentProps, ChatWindowProps +) # ============================================================================= @@ -18,20 +22,20 @@ 1: { "name": "Atlas", # Layout (anchor-based) - "anchor": "top_left", + "anchor": Anchor.TOP_LEFT, "priority": 20, - "persistent_anchor": "top_left", + "persistent_anchor": Anchor.TOP_LEFT, "persistent_priority": 10, - "layout_mode": "auto", + "layout_mode": LayoutMode.AUTO, # Sizes "hud_width": 450, "persistent_width": 350, "hud_max_height": 500, # Visual - "bg_color": "#1e212b", - "text_color": "#f0f0f0", - "accent_color": "#00aaff", - "user_color": "#4cd964", + "bg_color": HudColor.BG_DARK, + "text_color": HudColor.TEXT_PRIMARY, + "accent_color": HudColor.ACCENT_BLUE, + "user_color": HudColor.SUCCESS, "opacity": 0.9, "border_radius": 12, "font_size": 16, @@ -41,11 +45,11 @@ 2: { "name": "Nova", # Layout (anchor-based) - "anchor": "top_right", + "anchor": Anchor.TOP_RIGHT, "priority": 20, - "persistent_anchor": "top_right", + "persistent_anchor": Anchor.TOP_RIGHT, "persistent_priority": 10, - "layout_mode": "auto", + "layout_mode": LayoutMode.AUTO, # Sizes "hud_width": 400, "persistent_width": 320, @@ -53,7 +57,7 @@ # Visual "bg_color": "#1a1f2e", "text_color": "#e8e8e8", - "accent_color": "#ff6b35", + "accent_color": HudColor.ACCENT_ORANGE, "user_color": "#ffd700", "opacity": 0.85, "border_radius": 8, @@ -64,11 +68,11 @@ 3: { "name": "Orion", # Layout (anchor-based) - "anchor": "bottom_left", + "anchor": Anchor.BOTTOM_LEFT, "priority": 20, - "persistent_anchor": "bottom_left", + "persistent_anchor": Anchor.BOTTOM_LEFT, "persistent_priority": 10, - "layout_mode": "auto", + "layout_mode": LayoutMode.AUTO, # Sizes "hud_width": 380, "persistent_width": 300, @@ -76,8 +80,8 @@ # Visual "bg_color": "#12161f", "text_color": "#d0d0d0", - "accent_color": "#9b59b6", - "user_color": "#2ecc71", + "accent_color": HudColor.ACCENT_PURPLE, + "user_color": HudColor.ACCENT_GREEN, "opacity": 0.88, "border_radius": 16, "font_size": 15, @@ -124,46 +128,52 @@ async def stop(self): self.running = False print(f"[Session {self.session_id} - {self.name}] Disconnected") - def _get_props(self) -> dict: - """Get display properties from config.""" - return { + def _get_props(self) -> MessageProps: + """Get display properties from config as MessageProps.""" + return MessageProps( # Layout (anchor-based) - "anchor": self.config.get("anchor", "top_left"), - "priority": self.config.get("priority", 20), - "layout_mode": self.config.get("layout_mode", "auto"), + anchor=self._get_color_value(self.config.get("anchor", Anchor.TOP_LEFT)), + priority=self.config.get("priority", 20), + layout_mode=self._get_color_value(self.config.get("layout_mode", LayoutMode.AUTO)), # Size - "width": self.config["hud_width"], - "max_height": self.config["hud_max_height"], + width=self.config["hud_width"], + max_height=self.config["hud_max_height"], # Visual - "bg_color": self.config["bg_color"], - "text_color": self.config["text_color"], - "accent_color": self.config["accent_color"], - "opacity": self.config["opacity"], - "border_radius": self.config["border_radius"], - "font_size": self.config["font_size"], - "content_padding": self.config["content_padding"], - "typewriter_effect": self.config["typewriter_effect"], - "duration": 8.0, - } - - def _get_persistent_props(self) -> dict: - """Get persistent panel properties from config.""" - return { + bg_color=self._get_color_value(self.config["bg_color"]), + text_color=self._get_color_value(self.config["text_color"]), + accent_color=self._get_color_value(self.config["accent_color"]), + opacity=self.config["opacity"], + border_radius=self.config["border_radius"], + font_size=self.config["font_size"], + content_padding=self.config["content_padding"], + typewriter_effect=self.config["typewriter_effect"], + fade_delay=8.0, + ) + + def _get_persistent_props(self) -> PersistentProps: + """Get persistent panel properties from config as PersistentProps.""" + return PersistentProps( # Layout (anchor-based) - "anchor": self.config.get("persistent_anchor", "top_left"), - "priority": self.config.get("persistent_priority", 10), - "layout_mode": self.config.get("layout_mode", "auto"), + anchor=self._get_color_value(self.config.get("persistent_anchor", Anchor.TOP_LEFT)), + priority=self.config.get("persistent_priority", 10), + layout_mode=self._get_color_value(self.config.get("layout_mode", LayoutMode.AUTO)), # Size - "width": self.config["persistent_width"], + width=self.config["persistent_width"], # Visual - "bg_color": self.config["bg_color"], - "text_color": self.config["text_color"], - "accent_color": self.config["accent_color"], - "opacity": self.config["opacity"], - "border_radius": self.config["border_radius"], - "font_size": self.config["font_size"], - "content_padding": self.config["content_padding"], - } + bg_color=self._get_color_value(self.config["bg_color"]), + text_color=self._get_color_value(self.config["text_color"]), + accent_color=self._get_color_value(self.config["accent_color"]), + opacity=self.config["opacity"], + border_radius=self.config["border_radius"], + font_size=self.config["font_size"], + content_padding=self.config["content_padding"], + ) + + def _get_color_value(self, value: Any) -> str: + """Get the string value from a color/enum or return as-is.""" + if hasattr(value, 'value'): + return value.value + return value # ========================================================================= # Message Commands @@ -174,22 +184,31 @@ async def draw_message(self, title: str, message: str, color: Optional[str] = No """Draw a message on the overlay.""" if not self._client: return + color_value = color or self.config["accent_color"] + if hasattr(color_value, 'value'): + color_value = color_value.value await self._client.show_message( group_name=self.group_name, title=title, content=message, - color=color or self.config["accent_color"], + color=color_value, tools=tools, - props=self._get_props(), + props=self._get_props().to_dict(), ) async def draw_user_message(self, message: str): """Draw a user message.""" - await self.draw_message("USER", message, self.config["user_color"]) + color_value = self.config["user_color"] + if hasattr(color_value, 'value'): + color_value = color_value.value + await self.draw_message("USER", message, color_value) async def draw_assistant_message(self, message: str, tools: Optional[list[dict]] = None): """Draw an assistant message.""" - await self.draw_message(self.name, message, self.config["accent_color"], tools) + color_value = self.config["accent_color"] + if hasattr(color_value, 'value'): + color_value = color_value.value + await self.draw_message(self.name, message, color_value, tools) async def hide(self): """Hide the current message.""" @@ -201,10 +220,13 @@ async def set_loading(self, state: bool): """Set loading indicator state.""" if not self._client: return + color_value = self.config["accent_color"] + if hasattr(color_value, 'value'): + color_value = color_value.value await self._client.show_loader( group_name=self.group_name, show=state, - color=self.config["accent_color"], + color=color_value, ) # ========================================================================= diff --git a/hud_server/tests/test_snake.py b/hud_server/tests/test_snake.py index 9dc2d4818..cb449eeb1 100644 --- a/hud_server/tests/test_snake.py +++ b/hud_server/tests/test_snake.py @@ -27,6 +27,7 @@ import random from enum import Enum from hud_server.tests.test_session import TestSession +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps try: import keyboard.keyboard as keyboard @@ -150,6 +151,43 @@ def get_snake_body_color(index: int, total_length: int) -> str: _current_border_color_index = 0 +def _menu_props(priority: int, accent_color: str = COLOR_GAME, bg_color: str = "#0a0e14", + width: int = 600, font_size: int = 14) -> MessageProps: + """Create MessageProps for menu screens.""" + return MessageProps( + anchor=Anchor.TOP_LEFT.value, + priority=priority, + layout_mode=LayoutMode.AUTO.value, + width=width, + bg_color=bg_color, + text_color="#f0f0f0", + accent_color=accent_color, + opacity=0.98, + border_radius=12, + font_size=font_size, + content_padding=20, + typewriter_effect=False, + ) + + +def _stats_props() -> MessageProps: + """Create MessageProps for stats display.""" + return MessageProps( + anchor=Anchor.TOP_RIGHT.value, + priority=100, + layout_mode=LayoutMode.AUTO.value, + width=350, + bg_color="#0a0e14", + text_color="#f0f0f0", + accent_color=COLOR_GAME, + opacity=0.95, + border_radius=8, + font_size=14, + content_padding=12, + typewriter_effect=False, + ) + + # ============================================================================= # Game Logic # ============================================================================= @@ -399,28 +437,26 @@ async def show_cell(session: TestSession, x: int, y: int, cell_type: str, color_ cell_color = color_override if color_override else COLORS[cell_type] # Special properties for golden food (pulsating effect) - props = { - "layout_mode": "manual", - "x": screen_x, - "y": screen_y, - "width": CELL_SIZE, - "height": CELL_SIZE, - "bg_color": cell_color, - "opacity": 1.0, - "border_radius": 4, - "font_size": 1, - "content_padding": 0, - "disable_animations": not pulsate, - "disable_transitions": not pulsate, - "duration": 999999, # Endless mode - very long duration - } + props = MessageProps( + layout_mode=LayoutMode.MANUAL.value, + x=screen_x, + y=screen_y, + width=CELL_SIZE, + max_height=CELL_SIZE, + bg_color=cell_color, + opacity=1.0, + border_radius=4, + font_size=1, + content_padding=0, + ) await session._client.show_message( group_name=group_name, title=" ", content=" ", # Need non-empty content to keep HUD visible color=cell_color, - props=props + props=props, + duration=3600 # Max allowed duration ) _active_cell_huds.add((x, y)) @@ -569,28 +605,27 @@ async def show_combo_flash(session: TestSession, combo: int): combo_text = f"{emoji} {message} {emoji}" + props = MessageProps( + anchor=Anchor.CENTER.value, + priority=150, + layout_mode=LayoutMode.AUTO.value, + width=400, + bg_color=HudColor.BLACK.value, + text_color=color, + accent_color=color, + opacity=0.95, + border_radius=20, + font_size=24, + content_padding=20, + typewriter_effect=False, + ) await session._client.show_message( group_name="snake_combo_flash", title=" ", content=combo_text, color=color, - props={ - "anchor": "center", - "priority": 150, - "layout_mode": "auto", - "width": 400, - "bg_color": "#000000", - "text_color": color, - "accent_color": color, - "opacity": 0.95, - "border_radius": 20, - "font_size": 24, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": False, - "disable_transitions": False, - "duration": 1.5, # Show for 1.5 seconds - } + props=props, + duration=1.5 # Show for 1.5 seconds ) @@ -609,23 +644,8 @@ async def show_start_screen(session: TestSession): title=" ", # Space to pass validation content="# 🐍 ENDLESS SNAKE GAME 🐍", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 250, - "layout_mode": "auto", - "width": 600, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 16, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(250, font_size=16), + duration=3600, ) # How to Play HUD @@ -640,23 +660,8 @@ async def show_start_screen(session: TestSession): - Avoid hitting the borders and yourself - **ENDLESS MODE** - No time limit, play until you lose!""", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 240, - "layout_mode": "auto", - "width": 600, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 14, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(240), + duration=3600, ) # Features HUD @@ -670,23 +675,8 @@ async def show_start_screen(session: TestSession): - ⚡ Multiple foods on screen - 🌟 Golden apples (disappear after 10s)""", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 230, - "layout_mode": "auto", - "width": 600, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 14, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(230), + duration=3600, ) # Controls HUD @@ -697,23 +687,8 @@ async def show_start_screen(session: TestSession): - **↑ ↓ ← →** : Move snake - **Grid Size:** {GRID_WIDTH} x {GRID_HEIGHT}""", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 220, - "layout_mode": "auto", - "width": 600, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 14, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(220), + duration=3600, ) # Start Button HUD @@ -722,23 +697,8 @@ async def show_start_screen(session: TestSession): title=" ", content="🎮 **Press SPACE to begin your endless journey!** 🎮", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 210, - "layout_mode": "auto", - "width": 600, - "bg_color": "#1a4d1a", - "text_color": "#ffffff", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 16, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(210, bg_color="#1a4d1a", font_size=16), + duration=3600, ) @@ -765,23 +725,8 @@ async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, spee title="🎮 Endless Snake", content=stats_message, color=COLOR_GAME, - props={ - "anchor": "top_right", - "priority": 100, - "layout_mode": "auto", - "width": 350, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME, - "opacity": 0.95, - "border_radius": 8, - "font_size": 14, - "content_padding": 12, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 999999, # Endless mode - } + props=_stats_props(), + duration=3600, # Max allowed duration ) @@ -819,23 +764,8 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: title=" ", content=f"# {result_emoji} GAME OVER {result_emoji}", color=COLOR_GAME_OVER, - props={ - "anchor": "top_left", - "priority": 250, - "layout_mode": "auto", - "width": 500, - "bg_color": "#1a0a0a", - "text_color": "#ff6666", - "accent_color": COLOR_GAME_OVER, - "opacity": 0.98, - "border_radius": 12, - "font_size": 18, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(250, accent_color=COLOR_GAME_OVER, bg_color="#1a0a0a", width=500, font_size=18), + duration=3600, ) # Rating HUD @@ -844,23 +774,8 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: title=" ", content=f"## {rating}", color=COLOR_GAME_OVER, - props={ - "anchor": "top_left", - "priority": 240, - "layout_mode": "auto", - "width": 500, - "bg_color": "#0a0e14", - "text_color": "#ffaa00", - "accent_color": COLOR_GAME_OVER, - "opacity": 0.98, - "border_radius": 12, - "font_size": 16, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(240, accent_color=COLOR_GAME_OVER, width=500, font_size=16), + duration=3600, ) # Stats HUD @@ -873,23 +788,8 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: - **Survival Time:** {time_str} - **Reason:** {game.game_over_reason}""", color=COLOR_GAME_OVER, - props={ - "anchor": "top_left", - "priority": 230, - "layout_mode": "auto", - "width": 500, - "bg_color": "#0a0e14", - "text_color": "#f0f0f0", - "accent_color": COLOR_GAME_OVER, - "opacity": 0.98, - "border_radius": 12, - "font_size": 14, - "content_padding": 20, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(230, accent_color=COLOR_GAME_OVER, width=500), + duration=3600, ) # Play Again Button HUD @@ -898,23 +798,8 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: title=" ", content="🔄 **Press SPACE to play again**", color=COLOR_GAME, - props={ - "anchor": "top_left", - "priority": 220, - "layout_mode": "auto", - "width": 500, - "bg_color": "#1a4d1a", - "text_color": "#ffffff", - "accent_color": COLOR_GAME, - "opacity": 0.98, - "border_radius": 12, - "font_size": 15, - "content_padding": 18, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(220, bg_color="#1a4d1a", width=500, font_size=15), + duration=3600, ) # Exit Button HUD @@ -923,23 +808,8 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: title=" ", content="👋 **Press ESC to exit**", color="#888888", - props={ - "anchor": "top_left", - "priority": 210, - "layout_mode": "auto", - "width": 500, - "bg_color": "#1a1a1a", - "text_color": "#cccccc", - "accent_color": "#888888", - "opacity": 0.98, - "border_radius": 12, - "font_size": 15, - "content_padding": 18, - "typewriter_effect": False, - "disable_animations": True, - "disable_transitions": True, - "duration": 3600, - } + props=_menu_props(210, accent_color="#888888", bg_color="#1a1a1a", width=500, font_size=15), + duration=3600, ) From 02485396735d0371fe1db1fd486570a660198496 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Thu, 12 Feb 2026 22:59:51 +0100 Subject: [PATCH 15/27] Enhance SSE connection management with automatic reconnection and state tracking --- hud_server/overlay/overlay.py | 1 - services/mcp_client.py | 65 +++++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 62662e713..c17c0ca60 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -2019,7 +2019,6 @@ def run(self): if self._foreground_changed.is_set(): self._foreground_changed.clear() self._reapply_topmost() - print("[HUD] Foreground change detected - moved HUD to absolute foreground") self._emit_heartbeat() diff --git a/services/mcp_client.py b/services/mcp_client.py index 3be9bc9c1..43587bfa3 100644 --- a/services/mcp_client.py +++ b/services/mcp_client.py @@ -88,6 +88,7 @@ class McpConnection: sse_shutdown_event: Optional[threading.Event] = None sse_ready_event: Optional[threading.Event] = None sse_error: Optional[str] = None + sse_connection_alive: bool = False # True when SSE stream is actually open class McpClient: @@ -379,6 +380,7 @@ async def _connect_sse( connection.sse_shutdown_event = threading.Event() connection.sse_ready_event = threading.Event() connection.sse_error = None + connection.sse_connection_alive = False # Storage for the session (will be set by the SSE thread) sse_session_holder: dict[str, Any] = {"session": None, "loop": None} @@ -430,6 +432,7 @@ async def maintain_sse_connection(): sse_session_holder["loop"] = loop connection.session = session connection.is_connected = True + connection.sse_connection_alive = True # Signal that we're ready connection.sse_ready_event.set() @@ -442,6 +445,9 @@ async def maintain_sse_connection(): connection.sse_error = str(e) connection.is_connected = False finally: + # Mark connection as no longer alive when SSE stream closes + connection.sse_connection_alive = False + connection.is_connected = False connection.sse_ready_event.set() # Unblock waiting caller sse_session_holder["session"] = None connection.session = None @@ -564,6 +570,7 @@ def join_thread(): connection.sse_shutdown_event = None connection.sse_ready_event = None connection.sse_error = None + connection.sse_connection_alive = False # Close session with timeout (for STDIO) if connection.session_context: @@ -666,6 +673,7 @@ async def _call_tool_sse( tool_name: str, arguments: dict[str, Any], timeout: float = 60.0, + _retry_on_closed: bool = True, ) -> str: """ Call a tool on an SSE MCP server using the persistent connection. @@ -673,9 +681,36 @@ async def _call_tool_sse( The SSE connection runs in a dedicated thread. We submit the tool call to that thread's event loop and wait for the result via a thread-safe future. + + If the connection has been closed (e.g., due to inactivity timeout), + we automatically attempt to reconnect before making the call. """ - if not connection.sse_loop or not connection.session: - raise RuntimeError("SSE connection is not active") + # Proactive check: if the SSE thread has exited, the connection is dead + if connection.sse_thread and not connection.sse_thread.is_alive(): + connection.sse_connection_alive = False + connection.is_connected = False + + # Check if SSE connection is still alive, attempt reconnect if not + if not connection.sse_connection_alive or not connection.sse_loop or not connection.session: + printr.print( + f"SSE connection to {connection.config.display_name} was closed, attempting reconnect...", + color=LogType.WARNING, + server_only=True, + ) + # Clean up the old connection + await self._cleanup_connection(connection) + # Reconnect + await self._connect_sse(connection, connection.merged_headers) + + if not connection.sse_connection_alive or not connection.sse_loop or not connection.session: + raise RuntimeError( + f"Failed to reconnect SSE connection to {connection.config.display_name}" + ) + printr.print( + f"SSE connection to {connection.config.display_name} reconnected successfully", + color=LogType.INFO, + server_only=True, + ) # Create a future to get the result from the SSE thread loop = asyncio.get_event_loop() @@ -700,6 +735,29 @@ async def call_in_sse_thread(): except concurrent.futures.TimeoutError: future.cancel() raise asyncio.TimeoutError(f"SSE tool call timed out: {tool_name}") + except Exception as e: + # Check if this is a ClosedResourceError or similar connection issue. + # Note: anyio.ClosedResourceError has an empty str() representation, + # so we must also check the exception type name. + error_str = str(e).lower() + error_type = type(e).__name__.lower() + if "closedresource" in error_type or "closed" in error_str: + # Mark connection as no longer alive + connection.sse_connection_alive = False + connection.is_connected = False + + # Retry once after reconnect if enabled + if _retry_on_closed: + printr.print( + f"SSE connection closed during tool call, will reconnect and retry...", + color=LogType.WARNING, + server_only=True, + ) + # Recursive call with retry disabled to avoid infinite loops + return await self._call_tool_sse( + connection, tool_name, arguments, timeout, _retry_on_closed=False + ) + raise def _parse_tool_result(self, result) -> str: """Parse a tool result into a string.""" @@ -740,7 +798,8 @@ async def call_tool( Returns: The tool result as a string """ - if not connection.is_connected: + # SSE has its own reconnection logic in _call_tool_sse, so let it through + if not connection.is_connected and connection.config.type != McpTransportType.SSE: return f"Error: Not connected to MCP server {connection.config.name}" try: From b627dc9e293d104ec8fa7d9e3ef1a84a7fe9659f Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Fri, 13 Feb 2026 22:48:39 +0100 Subject: [PATCH 16/27] Add HUD server settings management and platform checks for Windows support --- services/settings_service.py | 7 +++++++ wingman_core.py | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/services/settings_service.py b/services/settings_service.py index 25a2502f7..179b24f63 100644 --- a/services/settings_service.py +++ b/services/settings_service.py @@ -167,6 +167,13 @@ async def save_settings(self, settings: SettingsConfig): settings.cancel_tts_joystick_button ) + # HUD server + self.config_manager.settings_config.hud_server = settings.hud_server + if settings.hud_server != old.hud_server: + await self.settings_events.publish( + "hud_server_settings_changed", settings.hud_server + ) + # save the config file self.config_manager.save_settings_config() diff --git a/wingman_core.py b/wingman_core.py index 40d510d4e..bc28c4c3a 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -1,6 +1,7 @@ import asyncio from concurrent.futures import ThreadPoolExecutor import os +import platform import re import threading from typing import Optional @@ -385,6 +386,9 @@ def __init__( self.settings_service.settings_events.subscribe( "va_settings_changed", self.on_va_settings_changed ) + self.settings_service.settings_events.subscribe( + "hud_server_settings_changed", self._on_hud_server_settings_changed + ) self.whispercpp = Whispercpp( settings=self.settings_service.settings.voice_activation.whispercpp, @@ -435,6 +439,14 @@ async def _start_hud_server_if_enabled(self): if not hud_settings or not hud_settings.enabled: return + if platform.system() != "Windows": + self.printr.print( + "HUD Server is only supported on Windows.", + color=LogType.WARNING, + server_only=True, + ) + return + try: self._hud_server = HudServer() if not self._hud_server.start( @@ -458,6 +470,20 @@ async def _start_hud_server_if_enabled(self): ) self._hud_server = None + async def _on_hud_server_settings_changed(self, hud_settings): + """Handle HUD server settings changes — start or stop as needed.""" + should_run = ( + hud_settings is not None + and hud_settings.enabled + and platform.system() == "Windows" + ) + is_running = self._hud_server is not None and self._hud_server.is_running + + if should_run and not is_running: + await self._start_hud_server_if_enabled() + elif not should_run and is_running: + await self._stop_hud_server() + async def _stop_hud_server(self): """Stop the HUD server if running.""" if self._hud_server and self._hud_server.is_running: From f285c0596a4c8598150dac93af30bdffd923c105 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Fri, 13 Feb 2026 22:48:45 +0100 Subject: [PATCH 17/27] Implement retry logic for HTTP requests with timeout handling in the HUD HTTP client --- hud_server/http_client.py | 81 +++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/hud_server/http_client.py b/hud_server/http_client.py index d949da752..21bebf3ce 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -86,6 +86,7 @@ class HudHttpClient: DEFAULT_CONNECT_TIMEOUT = hud_const.HTTP_CONNECT_TIMEOUT DEFAULT_REQUEST_TIMEOUT = hud_const.HTTP_REQUEST_TIMEOUT RECONNECT_ATTEMPTS = 1 + MAX_TIMEOUT_RETRIES = 3 def __init__(self, base_url: str = f"http://{hud_const.DEFAULT_HOST}:{hud_const.DEFAULT_PORT}"): self.base_url = base_url.rstrip("/") @@ -198,45 +199,57 @@ async def _execute_request(): ) return None - try: - response = await _execute_request() - if response and 200 <= response.status_code < 300: - return response.json() - elif response: - # Log non-2xx responses for debugging + for attempt in range(1, self.MAX_TIMEOUT_RETRIES + 1): + try: + response = await _execute_request() + if response and 200 <= response.status_code < 300: + return response.json() + elif response: + # Log non-2xx responses for debugging + printr.print( + f"[HUD HTTP Client] Request {method} {path} failed with status {response.status_code}", + color=LogType.WARNING, + server_only=True + ) + return None + except RuntimeError as e: + # Handle "Event loop is closed" error by reconnecting + if "loop" in str(e).lower() or "closed" in str(e).lower(): + self._connected = False + self._client = None + # Try to reconnect and retry once + if await self.connect(): + try: + response = await _execute_request() + if response and 200 <= response.status_code < 300: + return response.json() + except Exception: + pass # Give up after retry + self._connected = False + return None + except httpx.ConnectError: + # Server not reachable - don't spam logs + self._connected = False + return None + except httpx.TimeoutException: + if attempt < self.MAX_TIMEOUT_RETRIES: + continue printr.print( - f"[HUD HTTP Client] Request {method} {path} failed with status {response.status_code}", + f"[HUD HTTP Client] Request {method} {path} timed out after {self.MAX_TIMEOUT_RETRIES} attempts", color=LogType.WARNING, server_only=True ) - return None - except RuntimeError as e: - # Handle "Event loop is closed" error by reconnecting - if "loop" in str(e).lower() or "closed" in str(e).lower(): self._connected = False - self._client = None - # Try to reconnect and retry once - if await self.connect(): - try: - response = await _execute_request() - if response and 200 <= response.status_code < 300: - return response.json() - except Exception: - pass # Give up after retry - self._connected = False - return None - except httpx.ConnectError: - # Server not reachable - don't spam logs - self._connected = False - return None - except Exception as e: - printr.print( - f"[HUD HTTP Client] Request {method} {path} error: {type(e).__name__}: {e}", - color=LogType.WARNING, - server_only=True - ) - self._connected = False - return None + return None + except Exception as e: + printr.print( + f"[HUD HTTP Client] Request {method} {path} error: {type(e).__name__}: {e}", + color=LogType.WARNING, + server_only=True + ) + self._connected = False + return None + return None # ─────────────────────────────── Health ─────────────────────────────── # From da4f7a4bb1542a38aa3070b544ab7f2283a1975b Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Sat, 14 Feb 2026 23:22:42 +0100 Subject: [PATCH 18/27] remove the user-call-message-block movement as it's no longer necessary with dummy tool responses. It was actually able to cause problems/corrupted message history under certain circumstances (like user calls during ongoing tool responses) --- wingmen/open_ai_wingman.py | 58 ++------------------------------------ 1 file changed, 2 insertions(+), 56 deletions(-) diff --git a/wingmen/open_ai_wingman.py b/wingmen/open_ai_wingman.py index 742447c73..929b5e291 100644 --- a/wingmen/open_ai_wingman.py +++ b/wingmen/open_ai_wingman.py @@ -1342,7 +1342,6 @@ async def _update_tool_response(self, tool_call_id, response) -> bool: if not tool_call_id: return False - completed = False index = len(self.messages) # go through message history to find and update the tool call @@ -1355,62 +1354,9 @@ async def _update_tool_response(self, tool_call_id, response) -> bool: message["content"] = str(response) if tool_call_id in self.pending_tool_calls: self.pending_tool_calls.remove(tool_call_id) - break - if not index: - return False - - # find the assistant message that triggered the tool call - for message in reversed(self.messages[:index]): - index -= 1 - if self.__get_message_role(message) == "assistant": - break - - # check if all tool calls are completed - completed = True - for tool_call in self.messages[index].tool_calls: - if tool_call.id in self.pending_tool_calls: - completed = False - break - if not completed: - return True - - # find the first user message(s) that triggered this assistant message - index -= 1 # skip the assistant message - for message in reversed(self.messages[:index]): - index -= 1 - if self.__get_message_role(message) != "user": - index += 1 - break - - # built message block to move - start_index = index - end_index = start_index - reached_tool_call = False - for message in self.messages[start_index:]: - if not reached_tool_call and self.__get_message_role(message) == "tool": - reached_tool_call = True - if reached_tool_call and self.__get_message_role(message) == "user": - end_index -= 1 - break - end_index += 1 - if end_index == len(self.messages): - end_index -= 1 # loop ended at the end of the message history, so we have to go back one index - message_block = self.messages[start_index : end_index + 1] - - # check if the message block is already at the end - if end_index == len(self.messages) - 1: - return True - - # move message block to the end - del self.messages[start_index : end_index + 1] - self.messages.extend(message_block) - - if self.settings.debug_mode: - await printr.print_async( - "Moved message block to the end.", color=LogType.INFO - ) + return True - return True + return False async def add_user_message(self, content: str): """Shortens the conversation history if needed and adds a user message to it. From 0624d5cc7dc77d8f34de3ff72690966b0b287efb Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Sun, 15 Feb 2026 18:39:32 +0100 Subject: [PATCH 19/27] Refactor HUD element management to use WindowType for improved clarity and consistency --- hud_server/constants.py | 6 +- hud_server/http_client.py | 268 ++++++++++++++++++------- hud_server/hud_manager.py | 251 +++++++++++++++-------- hud_server/models.py | 62 +++++- hud_server/overlay/overlay.py | 64 +++++- hud_server/server.py | 204 +++++++++++++++---- hud_server/tests/debug_layout.py | 12 +- hud_server/tests/test_layout_visual.py | 70 ++++--- hud_server/tests/test_multiuser.py | 18 +- hud_server/tests/test_session.py | 66 ++++-- hud_server/tests/test_snake.py | 61 +++--- skills/hud/main.py | 126 +++++++++--- 12 files changed, 872 insertions(+), 336 deletions(-) diff --git a/hud_server/constants.py b/hud_server/constants.py index 5eaddad4a..62edeef2a 100644 --- a/hud_server/constants.py +++ b/hud_server/constants.py @@ -97,8 +97,10 @@ PATH_TIMER = "/timer" PATH_CHAT_WINDOW = "/chat/window" PATH_CHAT_MESSAGE = "/chat/message" -PATH_CHAT_SHOW = "/chat/message/show" -PATH_CHAT_HIDE = "/chat/message/show" +PATH_CHAT_SHOW = "/chat/show" +PATH_CHAT_HIDE = "/chat/hide" +PATH_ELEMENT_SHOW = "/element/show" +PATH_ELEMENT_HIDE = "/element/hide" PATH_STATE = "/state" PATH_STATE_RESTORE = "/state/restore" diff --git a/hud_server/http_client.py b/hud_server/http_client.py index 21bebf3ce..090cdabcb 100644 --- a/hud_server/http_client.py +++ b/hud_server/http_client.py @@ -49,7 +49,7 @@ from api.enums import LogType from hud_server.constants import PATH_GROUPS, PATH_STATE, PATH_STATE_RESTORE, PATH_HEALTH, PATH_MESSAGE, \ PATH_MESSAGE_APPEND, PATH_MESSAGE_HIDE, PATH_LOADER, PATH_ITEMS, PATH_PROGRESS, PATH_TIMER, PATH_CHAT_WINDOW, \ - PATH_CHAT_MESSAGE, PATH_CHAT_SHOW, PATH_CHAT_HIDE + PATH_CHAT_MESSAGE, PATH_CHAT_SHOW, PATH_CHAT_HIDE, PATH_ELEMENT_SHOW, PATH_ELEMENT_HIDE from services.printr import Printr from hud_server import constants as hud_const from hud_server.types import ( @@ -57,7 +57,8 @@ LayoutMode, HudColor, FontFamily, - BaseProps + BaseProps, + WindowType ) printr = Printr() @@ -65,7 +66,7 @@ def _resolve_enum(value: Any) -> Any: """Convert enum values to their string representation.""" - if isinstance(value, (Anchor, LayoutMode, HudColor, FontFamily)): + if isinstance(value, (Anchor, LayoutMode, HudColor, FontFamily, WindowType)): return value.value return value @@ -267,12 +268,14 @@ async def get_status(self) -> Optional[dict]: async def create_group( self, group_name: str, + element: WindowType, props: Optional[BaseProps] = None ) -> Optional[dict]: """Create or update a HUD group. Args: - group_name: Unique identifier for the group + group_name: Unique identifier for the group (e.g., wingman name) + element: The element type for this group (message, persistent, or chat) props: Optional group properties (use types module for type-safe construction) Properties can include (see types.py for full list): @@ -289,22 +292,24 @@ async def create_group( Server response dict or None if failed Example: - from hud_server.types import Anchor, HudColor, message_props + from hud_server.types import Anchor, HudColor, WindowType, message_props props = message_props( anchor=Anchor.TOP_RIGHT, accent_color=HudColor.ACCENT_ORANGE ) - await client.create_group("alerts", props=props) + await client.create_group("Computer", WindowType.PERSISTENT, props=props) """ return await self._request("POST", PATH_GROUPS, { "group_name": group_name, + "element": _resolve_enum(element), "props": _resolve_props(props) }) async def update_group( self, group_name: str, + element: WindowType, props: BaseProps ) -> bool: """Update properties of an existing group. @@ -314,6 +319,7 @@ async def update_group( Args: group_name: Name of the group to update + element: The element type (message, persistent, or chat) props: Properties to update (use types module for type-safe construction) Returns: @@ -321,14 +327,15 @@ async def update_group( """ encoded_group = quote(group_name, safe='') result = await self._request("PATCH", f"{PATH_GROUPS}/{encoded_group}", { + "element": _resolve_enum(element), "props": _resolve_props(props) }) return result is not None - async def delete_group(self, group_name: str) -> Optional[dict]: + async def delete_group(self, group_name: str, element: WindowType) -> Optional[dict]: """Delete a HUD group.""" encoded_group = quote(group_name, safe='') - return await self._request("DELETE", f"{PATH_GROUPS}/{encoded_group}") + return await self._request("DELETE", f"{PATH_GROUPS}/{encoded_group}/{_resolve_enum(element)}") async def get_groups(self) -> Optional[dict]: """Get list of all group names.""" @@ -353,6 +360,7 @@ async def restore_state(self, group_name: str, state: dict) -> Optional[dict]: async def show_message( self, group_name: str, + element: WindowType, title: str, content: str, color: Optional[Union[str, HudColor]] = None, @@ -363,7 +371,8 @@ async def show_message( """Show a message in a HUD group. Args: - group_name: Name of the HUD group + group_name: Name of the HUD group (e.g., wingman name) + element: The element type (message, persistent, or chat) title: Message title (displayed prominently) content: Message content (supports Markdown) color: Optional accent color override (use HudColor enum or hex string) @@ -375,8 +384,10 @@ async def show_message( Server response dict or None if failed Example: + from hud_server.types import WindowType await client.show_message( - "notifications", + "Computer", + WindowType.MESSAGE, "Alert", "Something **important** happened!", color=HudColor.WARNING, @@ -385,6 +396,7 @@ async def show_message( """ data: dict[str, Any] = { "group_name": group_name, + "element": _resolve_enum(element), "title": title, "content": content } @@ -402,24 +414,27 @@ async def show_message( async def append_message( self, group_name: str, + element: WindowType, content: str ) -> Optional[dict]: """Append content to the current message (for streaming).""" return await self._request("POST", PATH_MESSAGE_APPEND, { "group_name": group_name, + "element": _resolve_enum(element), "content": content }) - async def hide_message(self, group_name: str) -> Optional[dict]: + async def hide_message(self, group_name: str, element: WindowType) -> Optional[dict]: """Hide the current message in a group.""" encoded_group = quote(group_name, safe='') - return await self._request("POST", f"{PATH_MESSAGE_HIDE}/{encoded_group}") + return await self._request("POST", f"{PATH_MESSAGE_HIDE}/{encoded_group}/{_resolve_enum(element)}") # ─────────────────────────────── Loader ─────────────────────────────── # async def show_loader( self, group_name: str, + element: WindowType, show: bool = True, color: Optional[Union[str, HudColor]] = None ) -> Optional[dict]: @@ -427,13 +442,18 @@ async def show_loader( Args: group_name: Name of the HUD group + element: The element type (message, persistent, or chat) show: True to show, False to hide color: Optional loader color (use HudColor enum or hex string) Returns: Server response dict or None if failed """ - data = {"group_name": group_name, "show": show} + data = { + "group_name": group_name, + "element": _resolve_enum(element), + "show": show + } if color: data["color"] = _resolve_enum(color) return await self._request("POST", PATH_LOADER, data) @@ -443,6 +463,7 @@ async def show_loader( async def add_item( self, group_name: str, + element: WindowType, title: str, description: str = "", color: Optional[Union[str, HudColor]] = None, @@ -451,7 +472,8 @@ async def add_item( """Add a persistent item to a group. Args: - group_name: Name of the HUD group + group_name: Name of the HUD group (e.g., wingman name) + element: The element type (must be WindowType.PERSISTENT) title: Item title (unique identifier within group) description: Item description text color: Optional item color (use HudColor enum or hex string) @@ -461,8 +483,10 @@ async def add_item( Server response dict or None if failed Example: + from hud_server.types import WindowType await client.add_item( - "status", + "Computer", + WindowType.PERSISTENT, "Shield Status", "Shields at 100%", color=HudColor.SHIELD @@ -470,6 +494,7 @@ async def add_item( """ data: dict[str, Any] = { "group_name": group_name, + "element": _resolve_enum(element), "title": title, "description": description } @@ -483,6 +508,7 @@ async def add_item( async def update_item( self, group_name: str, + element: WindowType, title: str, description: Optional[str] = None, color: Optional[Union[str, HudColor]] = None, @@ -492,6 +518,7 @@ async def update_item( Args: group_name: Name of the HUD group + element: The element type (must be WindowType.PERSISTENT) title: Item title to update description: New description (None to keep current) color: New color (use HudColor enum or hex string, None to keep current) @@ -500,7 +527,11 @@ async def update_item( Returns: Server response dict or None if failed """ - data: dict[str, Any] = {"group_name": group_name, "title": title} + data: dict[str, Any] = { + "group_name": group_name, + "element": _resolve_enum(element), + "title": title + } if description is not None: data["description"] = description if color is not None: @@ -510,21 +541,22 @@ async def update_item( return await self._request("PUT", PATH_ITEMS, data) - async def remove_item(self, group_name: str, title: str) -> Optional[dict]: + async def remove_item(self, group_name: str, element: WindowType, title: str) -> Optional[dict]: """Remove an item from a group.""" encoded_title = quote(title, safe='') - return await self._request("DELETE", f"{PATH_ITEMS}/{group_name}/{encoded_title}") + return await self._request("DELETE", f"{PATH_ITEMS}/{group_name}/{_resolve_enum(element)}/{encoded_title}") - async def clear_items(self, group_name: str) -> Optional[dict]: + async def clear_items(self, group_name: str, element: WindowType) -> Optional[dict]: """Clear all items from a group.""" encoded_group = quote(group_name, safe='') - return await self._request("DELETE", f"{PATH_ITEMS}/{encoded_group}") + return await self._request("DELETE", f"{PATH_ITEMS}/{encoded_group}/{_resolve_enum(element)}") # ─────────────────────────────── Progress ─────────────────────────────── # async def show_progress( self, group_name: str, + element: WindowType, title: str, current: float, maximum: float = 100, @@ -537,6 +569,7 @@ async def show_progress( Args: group_name: Name of the HUD group + element: The element type (must be WindowType.PERSISTENT) title: Progress bar title current: Current progress value maximum: Maximum progress value (default: 100) @@ -549,8 +582,10 @@ async def show_progress( Server response dict or None if failed Example: + from hud_server.types import WindowType await client.show_progress( - "downloads", + "Computer", + WindowType.PERSISTENT, "Downloading...", current=45, maximum=100, @@ -560,6 +595,7 @@ async def show_progress( """ data: dict[str, Any] = { "group_name": group_name, + "element": _resolve_enum(element), "title": title, "current": current, "maximum": maximum, @@ -576,6 +612,7 @@ async def show_progress( async def show_timer( self, group_name: str, + element: WindowType, title: str, duration: float, description: str = "", @@ -588,6 +625,7 @@ async def show_timer( Args: group_name: Name of the HUD group + element: The element type (must be WindowType.PERSISTENT) title: Timer title duration: Timer duration in seconds description: Optional description text @@ -600,8 +638,10 @@ async def show_timer( Server response dict or None if failed Example: + from hud_server.types import WindowType await client.show_timer( - "cooldowns", + "Computer", + WindowType.PERSISTENT, "Quantum Cooldown", duration=30.0, color=HudColor.QUANTUM, @@ -610,6 +650,7 @@ async def show_timer( """ data: dict[str, Any] = { "group_name": group_name, + "element": _resolve_enum(element), "title": title, "duration": duration, "description": description, @@ -627,7 +668,8 @@ async def show_timer( async def create_chat_window( self, - name: str, + group_name: str, + element: WindowType, # Layout (anchor-based) - preferred anchor: Union[str, Anchor] = Anchor.TOP_LEFT, priority: int = 5, @@ -713,7 +755,8 @@ async def create_chat_window( props.update(extra_props) data = { - "name": name, + "group_name": group_name, + "element": _resolve_enum(element), # Layout "anchor": _resolve_enum(anchor), "priority": priority, @@ -734,14 +777,15 @@ async def create_chat_window( } return await self._request("POST", PATH_CHAT_WINDOW, data) - async def delete_chat_window(self, name: str) -> Optional[dict]: + async def delete_chat_window(self, group_name: str, element: WindowType) -> Optional[dict]: """Delete a chat window.""" - encoded_name = quote(name, safe='') - return await self._request("DELETE", f"{PATH_CHAT_WINDOW}/{encoded_name}") + encoded_group = quote(group_name, safe='') + return await self._request("DELETE", f"{PATH_CHAT_WINDOW}/{encoded_group}/{_resolve_enum(element)}") async def send_chat_message( self, - window_name: str, + group_name: str, + element: WindowType, sender: str, text: str, color: Optional[Union[str, HudColor]] = None @@ -749,7 +793,8 @@ async def send_chat_message( """Send a message to a chat window. Args: - window_name: Name of the chat window + group_name: Name of the HUD group + element: The element type (must be WindowType.CHAT) sender: Sender name displayed with the message text: Message text content color: Optional sender color override (use HudColor enum or hex string) @@ -767,7 +812,8 @@ async def send_chat_message( message_id = result["message_id"] # For later updates """ data = { - "window_name": window_name, + "group_name": group_name, + "element": _resolve_enum(element), "sender": sender, "text": text } @@ -778,32 +824,87 @@ async def send_chat_message( async def update_chat_message( self, - window_name: str, + group_name: str, + element: WindowType, message_id: str, text: str ) -> Optional[dict]: """Update an existing chat message's text content by its ID.""" data = { - "window_name": window_name, + "group_name": group_name, + "element": _resolve_enum(element), "message_id": message_id, "text": text } return await self._request("PUT", PATH_CHAT_MESSAGE, data) - async def clear_chat_window(self, name: str) -> Optional[dict]: + async def clear_chat_window(self, group_name: str, element: WindowType) -> Optional[dict]: """Clear all messages from a chat window.""" - encoded_name = quote(name, safe='') - return await self._request("DELETE", f"{PATH_CHAT_MESSAGE}/{encoded_name}") + encoded_group = quote(group_name, safe='') + return await self._request("DELETE", f"{PATH_CHAT_MESSAGE}/{encoded_group}/{_resolve_enum(element)}") - async def show_chat_window(self, name: str) -> Optional[dict]: + async def show_chat_window(self, group_name: str, element: WindowType) -> Optional[dict]: """Show a hidden chat window.""" - encoded_name = quote(name, safe='') - return await self._request("POST", f"{PATH_CHAT_SHOW}/{encoded_name}") + encoded_group = quote(group_name, safe='') + return await self._request("POST", f"{PATH_CHAT_SHOW}/{encoded_group}/{_resolve_enum(element)}") - async def hide_chat_window(self, name: str) -> Optional[dict]: + async def hide_chat_window(self, group_name: str, element: WindowType) -> Optional[dict]: """Hide a chat window.""" - encoded_name = quote(name, safe='') - return await self._request("POST", f"{PATH_CHAT_HIDE}/{encoded_name}") + encoded_group = quote(group_name, safe='') + return await self._request("POST", f"{PATH_CHAT_HIDE}/{encoded_group}/{_resolve_enum(element)}") + + # ─────────────────────────────── Element Visibility ─────────────────────────────── # + + async def show_element( + self, + group_name: str, + element: WindowType + ) -> Optional[dict]: + """Show a hidden HUD element (message, persistent, or chat). + + Args: + group_name: Name of the HUD group + element: Element type to show - must be WindowType enum + + Returns: + Server response dict or None if failed + + Example: + # Show the persistent info panel for a wingman group + from hud_server.types import WindowType + await client.show_element("Computer", WindowType.PERSISTENT) + """ + return await self._request("POST", PATH_ELEMENT_SHOW, { + "group_name": group_name, + "element": _resolve_enum(element) + }) + + async def hide_element( + self, + group_name: str, + element: WindowType + ) -> Optional[dict]: + """Hide a HUD element (message, persistent, or chat). + + The element will no longer be displayed but will still receive updates + and perform all logic (timers, auto-hide, updates) in the background. + + Args: + group_name: Name of the HUD group + element: Element type to hide - must be WindowType enum + + Returns: + Server response dict or None if failed + + Example: + # Hide the persistent info panel but keep receiving updates + from hud_server.types import WindowType + await client.hide_element("Computer", WindowType.PERSISTENT) + """ + return await self._request("POST", PATH_ELEMENT_HIDE, { + "group_name": group_name, + "element": _resolve_enum(element) + }) @@ -933,16 +1034,16 @@ def health_check(self) -> bool: def get_status(self) -> Optional[dict]: return self._run_coro(self._client.get_status()) if self._client else None - def create_group(self, group_name: str, props: Optional[BaseProps] = None): + def create_group(self, group_name: str, element: WindowType, props: Optional[BaseProps] = None): """Create or update a HUD group. Props can contain enum values.""" - return self._run_coro(self._client.create_group(group_name, props)) if self._client else None + return self._run_coro(self._client.create_group(group_name, element, props)) if self._client else None - def update_group(self, group_name: str, props: BaseProps) -> bool: + def update_group(self, group_name: str, element: WindowType, props: BaseProps) -> bool: """Update properties for an existing group. Props can contain enum values.""" - return self._run_coro(self._client.update_group(group_name, props)) if self._client else False + return self._run_coro(self._client.update_group(group_name, element, props)) if self._client else False - def delete_group(self, group_name: str): - return self._run_coro(self._client.delete_group(group_name)) if self._client else None + def delete_group(self, group_name: str, element: WindowType): + return self._run_coro(self._client.delete_group(group_name, element)) if self._client else None def get_groups(self): return self._run_coro(self._client.get_groups()) if self._client else None @@ -956,6 +1057,7 @@ def restore_state(self, group_name: str, state: dict): def show_message( self, group_name: str, + element: WindowType, title: str, content: str, color: Optional[Union[str, HudColor]] = None, @@ -965,27 +1067,29 @@ def show_message( ): """Show a message. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_message( - group_name, title, content, color, tools, props, duration + group_name, element, title, content, color, tools, props, duration )) if self._client else None - def append_message(self, group_name: str, content: str): - return self._run_coro(self._client.append_message(group_name, content)) if self._client else None + def append_message(self, group_name: str, element: WindowType, content: str): + return self._run_coro(self._client.append_message(group_name, element, content)) if self._client else None - def hide_message(self, group_name: str): - return self._run_coro(self._client.hide_message(group_name)) if self._client else None + def hide_message(self, group_name: str, element: WindowType): + return self._run_coro(self._client.hide_message(group_name, element)) if self._client else None def show_loader( self, group_name: str, + element: WindowType, show: bool = True, color: Optional[Union[str, HudColor]] = None ): """Show/hide loader. Color accepts HudColor enum or hex string.""" - return self._run_coro(self._client.show_loader(group_name, show, color)) if self._client else None + return self._run_coro(self._client.show_loader(group_name, element, show, color)) if self._client else None def add_item( self, group_name: str, + element: WindowType, title: str, description: str = "", color: Optional[Union[str, HudColor]] = None, @@ -993,12 +1097,13 @@ def add_item( ): """Add persistent item. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.add_item( - group_name, title, description, color, duration + group_name, element, title, description, color, duration )) if self._client else None def update_item( self, group_name: str, + element: WindowType, title: str, description: Optional[str] = None, color: Optional[Union[str, HudColor]] = None, @@ -1006,18 +1111,19 @@ def update_item( ): """Update item. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.update_item( - group_name, title, description, color, duration + group_name, element, title, description, color, duration )) if self._client else None - def remove_item(self, group_name: str, title: str): - return self._run_coro(self._client.remove_item(group_name, title)) if self._client else None + def remove_item(self, group_name: str, element: WindowType, title: str): + return self._run_coro(self._client.remove_item(group_name, element, title)) if self._client else None - def clear_items(self, group_name: str): - return self._run_coro(self._client.clear_items(group_name)) if self._client else None + def clear_items(self, group_name: str, element: WindowType): + return self._run_coro(self._client.clear_items(group_name, element)) if self._client else None def show_progress( self, group_name: str, + element: WindowType, title: str, current: float, maximum: float = 100, @@ -1028,12 +1134,13 @@ def show_progress( ): """Show progress bar. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_progress( - group_name, title, current, maximum, description, color, auto_close, props + group_name, element, title, current, maximum, description, color, auto_close, props )) if self._client else None def show_timer( self, group_name: str, + element: WindowType, title: str, duration: float, description: str = "", @@ -1044,12 +1151,13 @@ def show_timer( ): """Show timer. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.show_timer( - group_name, title, duration, description, color, auto_close, initial_progress, props + group_name, element, title, duration, description, color, auto_close, initial_progress, props )) if self._client else None def create_chat_window( self, - name: str, + group_name: str, + element: WindowType, # Layout (anchor-based) - preferred anchor: Union[str, Anchor] = Anchor.TOP_LEFT, priority: int = 5, @@ -1079,7 +1187,8 @@ def create_chat_window( ): """Create chat window. Accepts Anchor, LayoutMode, HudColor, FontFamily enums.""" return self._run_coro(self._client.create_chat_window( - name=name, + group_name=group_name, + element=element, anchor=anchor, priority=priority, layout_mode=layout_mode, @@ -1099,31 +1208,40 @@ def create_chat_window( **extra_props )) if self._client else None - def delete_chat_window(self, name: str): - return self._run_coro(self._client.delete_chat_window(name)) if self._client else None + def delete_chat_window(self, group_name: str, element: WindowType): + return self._run_coro(self._client.delete_chat_window(group_name, element)) if self._client else None def send_chat_message( self, - window_name: str, + group_name: str, + element: WindowType, sender: str, text: str, color: Optional[Union[str, HudColor]] = None ): """Send chat message. Color accepts HudColor enum or hex string.""" return self._run_coro(self._client.send_chat_message( - window_name, sender, text, color + group_name, element, sender, text, color )) if self._client else None - def update_chat_message(self, window_name: str, message_id: str, text: str): + def update_chat_message(self, group_name: str, element: WindowType, message_id: str, text: str): return self._run_coro(self._client.update_chat_message( - window_name, message_id, text + group_name, element, message_id, text )) if self._client else None - def clear_chat_window(self, name: str): - return self._run_coro(self._client.clear_chat_window(name)) if self._client else None + def clear_chat_window(self, group_name: str, element: WindowType): + return self._run_coro(self._client.clear_chat_window(group_name, element)) if self._client else None + + def show_chat_window(self, group_name: str, element: WindowType): + return self._run_coro(self._client.show_chat_window(group_name, element)) if self._client else None + + def hide_chat_window(self, group_name: str, element: WindowType): + return self._run_coro(self._client.hide_chat_window(group_name, element)) if self._client else None - def show_chat_window(self, name: str): - return self._run_coro(self._client.show_chat_window(name)) if self._client else None + def show_element(self, group_name: str, element: WindowType): + """Show a hidden HUD element (message, persistent, or chat).""" + return self._run_coro(self._client.show_element(group_name, element)) if self._client else None - def hide_chat_window(self, name: str): - return self._run_coro(self._client.hide_chat_window(name)) if self._client else None + def hide_element(self, group_name: str, element: WindowType): + """Hide a HUD element (message, persistent, or chat).""" + return self._run_coro(self._client.hide_element(group_name, element)) if self._client else None diff --git a/hud_server/hud_manager.py b/hud_server/hud_manager.py index 8788d7970..2824cce52 100644 --- a/hud_server/hud_manager.py +++ b/hud_server/hud_manager.py @@ -74,6 +74,10 @@ class GroupState: loader_color: Optional[str] = None is_chat_window: bool = False visible: bool = True + # Element visibility: tracks which elements are manually hidden + # Keys: "message", "persistent", "chat" + # Values: True if hidden, False if visible + element_hidden: dict[str, bool] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: """Convert state to dictionary for persistence.""" @@ -226,45 +230,54 @@ def _notify_callbacks(self, command: dict[str, Any]) -> None: # ─────────────────────────────── Group Management ─────────────────────────────── # - def create_group(self, group_name: str, props: Optional[dict[str, Any]] = None) -> bool: + def _make_key(self, group_name: str, element: str) -> str: + """Create internal key from group_name and element.""" + return f"{element}_{group_name}" + + def create_group(self, group_name: str, element: str, props: Optional[dict[str, Any]] = None) -> bool: """Create or update a HUD group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self._groups[group_name] = GroupState() + if key not in self._groups: + self._groups[key] = GroupState() if props: - self._groups[group_name].props.update(props) - self._groups[group_name].is_chat_window = props.get("is_chat_window", False) + self._groups[key].props.update(props) + self._groups[key].is_chat_window = props.get("is_chat_window", False) self._notify_callbacks({ "type": "create_group", "group": group_name, + "element": element, "props": props or {} }) return True - def update_group(self, group_name: str, props: dict[str, Any]) -> bool: + def update_group(self, group_name: str, element: str, props: dict[str, Any]) -> bool: """Update properties of an existing group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - self._groups[group_name].props.update(props) + self._groups[key].props.update(props) self._notify_callbacks({ "type": "update_group", "group": group_name, + "element": element, "props": props }) return True - def delete_group(self, group_name: str) -> bool: + def delete_group(self, group_name: str, element: str) -> bool: """Delete a HUD group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name in self._groups: - del self._groups[group_name] + if key in self._groups: + del self._groups[key] self._notify_callbacks({ "type": "delete_group", @@ -305,6 +318,7 @@ def restore_group_state(self, group_name: str, state: dict[str, Any]) -> bool: def show_message( self, group_name: str, + element: str, title: str, content: str, color: Optional[str] = None, @@ -313,11 +327,12 @@ def show_message( duration: Optional[float] = None ) -> bool: """Show a message in a group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self.create_group(group_name) + if key not in self._groups: + self.create_group(group_name, element) - self._groups[group_name].current_message = HudMessage( + self._groups[key].current_message = HudMessage( title=title, content=content, color=color, @@ -327,7 +342,7 @@ def show_message( ) # Build props dict for overlay - overlay_props = dict(self._groups[group_name].props) + overlay_props = dict(self._groups[key].props) if props: overlay_props.update(props) if duration is not None: @@ -345,13 +360,14 @@ def show_message( return True - def append_message(self, group_name: str, content: str) -> bool: + def append_message(self, group_name: str, element: str, content: str) -> bool: """Append content to the current message (for streaming).""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - state = self._groups[group_name] + state = self._groups[key] if state.current_message: state.current_message.content += content @@ -374,13 +390,14 @@ def append_message(self, group_name: str, content: str) -> bool: return True - def hide_message(self, group_name: str) -> bool: + def hide_message(self, group_name: str, element: str) -> bool: """Hide/fade out the current message.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - self._groups[group_name].current_message = None + self._groups[key].current_message = None self._notify_callbacks({ "type": "hide_message", @@ -391,15 +408,16 @@ def hide_message(self, group_name: str) -> bool: # ─────────────────────────────── Loader ─────────────────────────────── # - def set_loader(self, group_name: str, show: bool, color: Optional[str] = None) -> bool: + def set_loader(self, group_name: str, element: str, show: bool, color: Optional[str] = None) -> bool: """Show or hide the loader animation.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self.create_group(group_name) + if key not in self._groups: + self.create_group(group_name, element) - self._groups[group_name].loader_visible = show + self._groups[key].loader_visible = show if color: - self._groups[group_name].loader_color = color + self._groups[key].loader_color = color self._notify_callbacks({ "type": "set_loader", @@ -415,17 +433,19 @@ def set_loader(self, group_name: str, show: bool, color: Optional[str] = None) - def add_item( self, group_name: str, + element: str, title: str, description: str = "", color: Optional[str] = None, duration: Optional[float] = None ) -> bool: """Add a persistent item to a group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self.create_group(group_name) + if key not in self._groups: + self.create_group(group_name, element) - self._groups[group_name].items[title] = HudItem( + self._groups[key].items[title] = HudItem( title=title, description=description, color=color, @@ -446,20 +466,22 @@ def add_item( def update_item( self, group_name: str, + element: str, title: str, description: Optional[str] = None, color: Optional[str] = None, duration: Optional[float] = None ) -> bool: """Update an existing item.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - if title not in self._groups[group_name].items: + if title not in self._groups[key].items: return False - item = self._groups[group_name].items[title] + item = self._groups[key].items[title] if description is not None: item.description = description if color is not None: @@ -478,14 +500,15 @@ def update_item( return True - def remove_item(self, group_name: str, title: str) -> bool: + def remove_item(self, group_name: str, element: str, title: str) -> bool: """Remove an item from a group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - if title in self._groups[group_name].items: - del self._groups[group_name].items[title] + if title in self._groups[key].items: + del self._groups[key].items[title] self._notify_callbacks({ "type": "remove_item", @@ -496,13 +519,14 @@ def remove_item(self, group_name: str, title: str) -> bool: return True return False - def clear_items(self, group_name: str) -> bool: + def clear_items(self, group_name: str, element: str) -> bool: """Clear all items from a group.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: + if key not in self._groups: return False - self._groups[group_name].items.clear() + self._groups[key].items.clear() self._notify_callbacks({ "type": "clear_items", @@ -516,6 +540,7 @@ def clear_items(self, group_name: str) -> bool: def show_progress( self, group_name: str, + element: str, title: str, current: float, maximum: float = 100, @@ -524,11 +549,12 @@ def show_progress( auto_close: bool = False ) -> bool: """Show or update a progress bar.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self.create_group(group_name) + if key not in self._groups: + self.create_group(group_name, element) - items = self._groups[group_name].items + items = self._groups[key].items if title in items: item = items[title] item.progress_current = current @@ -564,6 +590,7 @@ def show_progress( def show_timer( self, group_name: str, + element: str, title: str, duration: float, description: str = "", @@ -572,12 +599,13 @@ def show_timer( initial_progress: float = 0 ) -> bool: """Show a timer-based progress bar.""" + key = self._make_key(group_name, element) with self._lock: - if group_name not in self._groups: - self.create_group(group_name) + if key not in self._groups: + self.create_group(group_name, element) now = time.time() - self._groups[group_name].items[title] = HudItem( + self._groups[key].items[title] = HudItem( title=title, description=description, is_progress=True, @@ -605,33 +633,10 @@ def show_timer( # ─────────────────────────────── Chat Window ─────────────────────────────── # - def create_chat_window( - self, - name: str, - props: Optional[dict[str, Any]] = None - ) -> bool: - """Create a chat window group.""" - with self._lock: - final_props = props or {} - final_props["is_chat_window"] = True - - if name not in self._groups: - self._groups[name] = GroupState() - self._groups[name].props.update(final_props) - self._groups[name].is_chat_window = True - - # Use 'create_chat_window' command for overlay compatibility - self._notify_callbacks({ - "type": "create_chat_window", - "name": name, - "props": final_props - }) - - return True - def send_chat_message( self, - window_name: str, + group_name: str, + element: str, sender: str, text: str, color: Optional[str] = None @@ -642,11 +647,12 @@ def send_chat_message( If the message is merged with the previous message from the same sender, the existing message's ID is returned. """ + key = self._make_key(group_name, element) with self._lock: - if window_name not in self._groups: + if key not in self._groups: return None - state = self._groups[window_name] + state = self._groups[key] # Append to last message if same sender if ( @@ -671,7 +677,8 @@ def send_chat_message( self._notify_callbacks({ "type": "chat_message", - "name": window_name, + "group": group_name, + "element": element, "id": message_id, "sender": sender, "text": text, @@ -682,7 +689,8 @@ def send_chat_message( def update_chat_message( self, - window_name: str, + group_name: str, + element: str, message_id: str, text: str ) -> bool: @@ -693,18 +701,19 @@ def update_chat_message( Returns True if the message was found and updated, False otherwise. """ + key = self._make_key(group_name, element) with self._lock: - if window_name not in self._groups: + if key not in self._groups: return False - state = self._groups[window_name] + state = self._groups[key] for msg in state.chat_messages: if msg.id == message_id: msg.text = text self._notify_callbacks({ "type": "update_chat_message", - "name": window_name, + "group": group_name, "id": message_id, "text": text }) @@ -712,47 +721,111 @@ def update_chat_message( return False - def clear_chat_window(self, name: str) -> bool: + def clear_chat_window(self, group_name: str, element: str) -> bool: """Clear all messages from a chat window.""" + key = self._make_key(group_name, element) with self._lock: - if name not in self._groups: + if key not in self._groups: return False - self._groups[name].chat_messages.clear() + self._groups[key].chat_messages.clear() self._notify_callbacks({ "type": "clear_chat_window", - "name": name + "group": group_name }) return True - def show_chat_window(self, name: str) -> bool: + def show_chat_window(self, group_name: str, element: str) -> bool: """Show a hidden chat window.""" + key = self._make_key(group_name, element) with self._lock: - if name not in self._groups: + if key not in self._groups: return False - self._groups[name].visible = True + self._groups[key].visible = True self._notify_callbacks({ "type": "show_chat_window", - "name": name + "group": group_name }) return True - def hide_chat_window(self, name: str) -> bool: + def hide_chat_window(self, group_name: str, element: str) -> bool: """Hide a chat window.""" + key = self._make_key(group_name, element) with self._lock: - if name not in self._groups: + if key not in self._groups: return False - self._groups[name].visible = False + self._groups[key].visible = False self._notify_callbacks({ "type": "hide_chat_window", - "name": name + "group": group_name + }) + + return True + + def show_element(self, group_name: str, element: str) -> bool: + """Show a hidden HUD element. + + The element will continue to receive updates and perform all logic, + but will now be displayed again. + + Args: + group_name: Name of the HUD group + element: Element type - "message", "persistent", or "chat" + + Returns: + True if successful, False if group not found + """ + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + group = self._groups[key] + # Clear the hidden flag for this element + if element in group.element_hidden: + group.element_hidden[element] = False + + self._notify_callbacks({ + "type": "show_element", + "group": group_name, + "element": element + }) + + return True + + def hide_element(self, group_name: str, element: str) -> bool: + """Hide a HUD element. + + The element will no longer be displayed but will still receive updates + and perform all logic (timers, auto-hide, updates) in the background. + + Args: + group_name: Name of the HUD group + element: Element type - "message", "persistent", or "chat" + + Returns: + True if successful, False if group not found + """ + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + group = self._groups[key] + # Set the hidden flag for this element + group.element_hidden[element] = True + + self._notify_callbacks({ + "type": "hide_element", + "group": group_name, + "element": element }) return True diff --git a/hud_server/models.py b/hud_server/models.py index 0461bd0bb..ba95bb808 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -6,6 +6,7 @@ from typing import Optional, Any from pydantic import BaseModel, Field, field_validator +from hud_server.types import WindowType # ─────────────────────────────── Configuration ─────────────────────────────── # @@ -130,7 +131,10 @@ class CreateGroupRequest(BaseModel): """Request to create a new HUD group.""" group_name: str - """Unique name for this group.""" + """Unique name for the group (e.g., wingman name).""" + + element: WindowType + """The element type for this group (message, persistent, or chat).""" props: Optional[dict[str, Any]] = None """Optional properties for the group.""" @@ -142,6 +146,9 @@ class UpdateGroupRequest(BaseModel): group_name: str """Name of the group to update.""" + element: WindowType + """The element type.""" + props: dict[str, Any] """Properties to update.""" @@ -150,7 +157,10 @@ class MessageRequest(BaseModel): """Request to show a message in a group.""" group_name: str = Field(..., min_length=1, max_length=100) - """Name of the HUD group.""" + """Name of the HUD group (e.g., wingman name).""" + + element: WindowType + """The element type (message, persistent, or chat).""" title: str = Field(..., min_length=1, max_length=200) """Message title.""" @@ -175,6 +185,7 @@ class AppendMessageRequest(BaseModel): """Request to append content to current message (streaming).""" group_name: str + element: WindowType content: str @@ -182,6 +193,7 @@ class LoaderRequest(BaseModel): """Request to show/hide loader animation.""" group_name: str + element: WindowType show: bool = True color: Optional[str] = None @@ -190,7 +202,10 @@ class ItemRequest(BaseModel): """Request to add/update a persistent item.""" group_name: str - """Name of the HUD group.""" + """Name of the HUD group (e.g., wingman name).""" + + element: WindowType + """The element type (must be persistent).""" title: str """Item title/identifier (unique within group).""" @@ -209,6 +224,7 @@ class UpdateItemRequest(BaseModel): """Request to update an existing item.""" group_name: str + element: WindowType title: str description: Optional[str] = None color: Optional[str] = None @@ -219,6 +235,7 @@ class RemoveItemRequest(BaseModel): """Request to remove an item.""" group_name: str + element: WindowType title: str @@ -226,6 +243,7 @@ class ProgressRequest(BaseModel): """Request to show/update a progress bar.""" group_name: str + element: WindowType title: str current: float maximum: float = 100 @@ -239,6 +257,7 @@ class TimerRequest(BaseModel): """Request to show a timer-based progress bar.""" group_name: str + element: WindowType title: str duration: float description: str = "" @@ -251,8 +270,11 @@ class TimerRequest(BaseModel): class ChatMessageRequest(BaseModel): """Request to send a chat message.""" - window_name: str - """Name of the chat window.""" + group_name: str + """Name of the HUD group.""" + + element: WindowType + """The element type (must be chat).""" sender: str """Sender name.""" @@ -267,8 +289,11 @@ class ChatMessageRequest(BaseModel): class ChatMessageUpdateRequest(BaseModel): """Request to update an existing chat message.""" - window_name: str - """Name of the chat window containing the message.""" + group_name: str + """Name of the HUD group.""" + + element: WindowType + """The element type.""" message_id: str """ID of the message to update (returned by send_chat_message).""" @@ -280,11 +305,32 @@ class ChatMessageUpdateRequest(BaseModel): class CreateChatWindowRequest(BaseModel): """Request to create a chat window.""" - name: str + group_name: str + """Name of the HUD group (e.g., wingman name).""" + + element: WindowType + """The element type (must be chat).""" + + anchor: Optional[str] = "top_left" + """Screen anchor point.""" + + priority: int = 5 + """Stacking priority within anchor zone.""" + + layout_mode: str = "auto" + """Layout mode (auto or manual).""" + x: int = 20 y: int = 20 width: int = 400 max_height: int = 400 + bg_color: Optional[str] = None + text_color: Optional[str] = None + accent_color: Optional[str] = None + opacity: Optional[float] = None + font_size: Optional[int] = None + font_family: Optional[str] = None + border_radius: Optional[int] = None auto_hide: bool = False auto_hide_delay: float = 10.0 max_messages: int = 50 diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index c17c0ca60..f738f3530 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -293,6 +293,7 @@ def _create_window_state(self, window_type: str, group: str, props: dict = None) 'opacity': 0, 'target_opacity': int(merged_props.get('opacity', 0.85) * 255), 'last_render_state': None, + 'hidden': False, # manually hidden flag } # Type-specific initialization @@ -490,23 +491,31 @@ def _update_all_windows(self): try: win_type = win.get('type') group = win.get('group', 'default') + # Check if window is manually hidden + is_hidden = win.get('hidden', False) if win_type == self.WINDOW_TYPE_MESSAGE: message_windows[group] = win self._update_message_window(name, win) - self._draw_message_window(name, win) - self._blit_window(name, win) + # Only draw and blit if not hidden + if not is_hidden: + self._draw_message_window(name, win) + self._blit_window(name, win) elif win_type == self.WINDOW_TYPE_PERSISTENT: persistent_windows[group] = win self._update_persistent_window(name, win) - self._draw_persistent_window(name, win) + # Only draw if not hidden (blit happens in second pass) + if not is_hidden: + self._draw_persistent_window(name, win) # Don't blit yet - wait for collision check elif win_type == self.WINDOW_TYPE_CHAT: self._update_chat_window(name, win) - self._draw_chat_window(name, win) - self._blit_window(name, win) + # Only draw and blit if not hidden + if not is_hidden: + self._draw_chat_window(name, win) + self._blit_window(name, win) except Exception as e: self._report_exception(f"update_window_{name}", e) @@ -514,10 +523,13 @@ def _update_all_windows(self): # Second pass: check collisions and update persistent windows for group, pers_win in persistent_windows.items(): try: - msg_win = message_windows.get(group) - collision = self._check_window_collision(msg_win, pers_win) - self._update_persistent_fade(pers_win, collision) - self._blit_window(self._get_window_name(self.WINDOW_TYPE_PERSISTENT, group), pers_win) + is_hidden = pers_win.get('hidden', False) + # Only blit if not hidden + if not is_hidden: + msg_win = message_windows.get(group) + collision = self._check_window_collision(msg_win, pers_win) + self._update_persistent_fade(pers_win, collision) + self._blit_window(self._get_window_name(self.WINDOW_TYPE_PERSISTENT, group), pers_win) except Exception as e: self._report_exception(f"persistent_collision_{group}", e) @@ -535,6 +547,10 @@ def _update_all_window_positions(self): if not hwnd: continue + # Skip windows that are manually hidden + if win.get('hidden', False): + continue + # Skip windows that are completely hidden (fade_state 0 AND opacity 0) fade_state = win.get('fade_state', 0) opacity = win.get('opacity', 0) @@ -667,6 +683,10 @@ def _update_window_fade(self, win: Dict, has_content: bool): if not hwnd: return + # If manually hidden, force has_content to False so fade out completes + if win.get('hidden', False): + has_content = False + key = 0x00FF00FF fade_amount = int(1080 * self.dt) if fade_amount < 1: @@ -2651,6 +2671,32 @@ def _handle_message(self, msg): # Layout slot will be released when fade completes (opacity reaches 0) win['canvas_dirty'] = True + # ===================================================================== + # Element Visibility Commands + # ===================================================================== + elif t == 'show_element': + group_name = msg.get('group') + element = msg.get('element') + if group_name and element: + window_name = self._get_window_name(element, group_name) + if window_name in self._windows: + win = self._windows[window_name] + win['hidden'] = False + win['fade_state'] = 1 # fade in + self._layout_manager.set_window_visible(window_name, True) + win['canvas_dirty'] = True + + elif t == 'hide_element': + group_name = msg.get('group') + element = msg.get('element') + if group_name and element: + window_name = self._get_window_name(element, group_name) + if window_name in self._windows: + win = self._windows[window_name] + win['hidden'] = True + win['fade_state'] = 3 # fade out + win['canvas_dirty'] = True + except Exception as e: self._report_exception("handle_message", e) diff --git a/hud_server/server.py b/hud_server/server.py index 960a82bc3..1bbe96ebd 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -25,6 +25,7 @@ from services.printr import Printr from hud_server.hud_manager import HudManager from hud_server import constants as hud_const +from hud_server.types import WindowType from hud_server.models import ( CreateGroupRequest, UpdateGroupRequest, @@ -213,28 +214,40 @@ async def root(): @app.post("/groups", response_model=OperationResponse, tags=["groups"]) async def create_group(request: CreateGroupRequest): """Create or update a HUD group.""" - self.manager.create_group(request.group_name, request.props) + self.manager.create_group(request.group_name, request.element.value, request.props) return OperationResponse(status="ok", message=f"Group '{request.group_name}' created") - @app.put("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) - async def update_group(group_name: str, request: UpdateGroupRequest): + @app.put("/groups/{group_name}/{element}", response_model=OperationResponse, tags=["groups"]) + async def update_group(group_name: str, element: str, request: UpdateGroupRequest): """Update properties of an existing group.""" - if not self.manager.update_group(group_name, request.props): + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.update_group(group_name, element_type.value, request.props): raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok", message=f"Group '{group_name}' updated") - @app.patch("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) - async def patch_group(group_name: str, request: UpdateGroupRequest): + @app.patch("/groups/{group_name}/{element}", response_model=OperationResponse, tags=["groups"]) + async def patch_group(group_name: str, element: str, request: UpdateGroupRequest): """Update properties of an existing group (PATCH).""" - result = self.manager.update_group(group_name, request.props) + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + result = self.manager.update_group(group_name, element_type.value, request.props) if not result: raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok", message=f"Group '{group_name}' updated") - @app.delete("/groups/{group_name}", response_model=OperationResponse, tags=["groups"]) - async def delete_group(group_name: str): + @app.delete("/groups/{group_name}/{element}", response_model=OperationResponse, tags=["groups"]) + async def delete_group(group_name: str, element: str): """Delete a HUD group.""" - if not self.manager.delete_group(group_name): + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.delete_group(group_name, element_type.value): raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok", message=f"Group '{group_name}' deleted") @@ -266,6 +279,7 @@ async def show_message(request: MessageRequest): """Show a message in a HUD group.""" self.manager.show_message( group_name=request.group_name, + element=request.element.value, title=request.title, content=request.content, color=request.color, @@ -278,14 +292,18 @@ async def show_message(request: MessageRequest): @app.post("/message/append", response_model=OperationResponse, tags=["messages"]) async def append_message(request: AppendMessageRequest): """Append content to the current message (for streaming).""" - if not self.manager.append_message(request.group_name, request.content): + if not self.manager.append_message(request.group_name, request.element.value, request.content): raise HTTPException(status_code=404, detail=f"Group '{request.group_name}' not found") return OperationResponse(status="ok") - @app.post("/message/hide/{group_name}", response_model=OperationResponse, tags=["messages"]) - async def hide_message(group_name: str): + @app.post("/message/hide/{group_name}/{element}", response_model=OperationResponse, tags=["messages"]) + async def hide_message(group_name: str, element: str): """Hide the current message in a group.""" - if not self.manager.hide_message(group_name): + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.hide_message(group_name, element_type.value): raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok") @@ -294,7 +312,7 @@ async def hide_message(group_name: str): @app.post("/loader", response_model=OperationResponse, tags=["loader"]) async def set_loader(request: LoaderRequest): """Show or hide the loader animation.""" - self.manager.set_loader(request.group_name, request.show, request.color) + self.manager.set_loader(request.group_name, request.element.value, request.show, request.color) return OperationResponse(status="ok") # ─────────────────────────────── Items ─────────────────────────────── # @@ -304,6 +322,7 @@ async def add_item(request: ItemRequest): """Add a persistent item to a group.""" self.manager.add_item( group_name=request.group_name, + element=request.element.value, title=request.title, description=request.description, color=request.color, @@ -316,6 +335,7 @@ async def update_item(request: UpdateItemRequest): """Update an existing item.""" if not self.manager.update_item( group_name=request.group_name, + element=request.element.value, title=request.title, description=request.description, color=request.color, @@ -324,17 +344,25 @@ async def update_item(request: UpdateItemRequest): raise HTTPException(status_code=404, detail="Item not found") return OperationResponse(status="ok") - @app.delete("/items/{group_name}/{title}", response_model=OperationResponse, tags=["items"]) - async def remove_item(group_name: str, title: str): + @app.delete("/items/{group_name}/{element}/{title}", response_model=OperationResponse, tags=["items"]) + async def remove_item(group_name: str, element: str, title: str): """Remove an item from a group.""" - if not self.manager.remove_item(group_name, title): + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.remove_item(group_name, element_type.value, title): raise HTTPException(status_code=404, detail="Item not found") return OperationResponse(status="ok") - @app.delete("/items/{group_name}", response_model=OperationResponse, tags=["items"]) - async def clear_items(group_name: str): + @app.delete("/items/{group_name}/{element}", response_model=OperationResponse, tags=["items"]) + async def clear_items(group_name: str, element: str): """Clear all items from a group.""" - if not self.manager.clear_items(group_name): + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.clear_items(group_name, element_type.value): raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok") @@ -345,6 +373,7 @@ async def show_progress(request: ProgressRequest): """Show or update a progress bar.""" self.manager.show_progress( group_name=request.group_name, + element=request.element.value, title=request.title, current=request.current, maximum=request.maximum, @@ -359,6 +388,7 @@ async def show_timer(request: TimerRequest): """Show a timer-based progress bar.""" self.manager.show_timer( group_name=request.group_name, + element=request.element.value, title=request.title, duration=request.duration, description=request.description, @@ -374,10 +404,20 @@ async def show_timer(request: TimerRequest): async def create_chat_window(request: CreateChatWindowRequest): """Create a new chat window.""" props = { + "anchor": request.anchor, + "priority": request.priority, + "layout_mode": request.layout_mode, "x": request.x, "y": request.y, "width": request.width, "max_height": request.max_height, + "bg_color": request.bg_color, + "text_color": request.text_color, + "accent_color": request.accent_color, + "opacity": request.opacity, + "font_size": request.font_size, + "font_family": request.font_family, + "border_radius": request.border_radius, "auto_hide": request.auto_hide, "auto_hide_delay": request.auto_hide_delay, "max_messages": request.max_messages, @@ -385,62 +425,140 @@ async def create_chat_window(request: CreateChatWindowRequest): "fade_old_messages": request.fade_old_messages, "is_chat_window": True, } + # Remove None values + props = {k: v for k, v in props.items() if v is not None} if request.props: props.update(request.props) - self.manager.create_chat_window(request.name, props) - return OperationResponse(status="ok", message=f"Chat window '{request.name}' created") + self.manager.create_group(request.group_name, request.element.value, props) + return OperationResponse(status="ok", message=f"Chat window '{request.group_name}' created") - @app.delete("/chat/window/{name}", response_model=OperationResponse, tags=["chat"]) - async def delete_chat_window(name: str): + @app.delete("/chat/window/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def delete_chat_window(group_name: str, element: str): """Delete a chat window.""" - if not self.manager.delete_group(name): - raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.delete_group(group_name, element_type.value): + raise HTTPException(status_code=404, detail=f"Chat window '{group_name}' not found") return OperationResponse(status="ok") @app.post("/chat/message", response_model=ChatMessageResponse, tags=["chat"]) async def send_chat_message(request: ChatMessageRequest): """Send a message to a chat window. Returns the message ID.""" message_id = self.manager.send_chat_message( - window_name=request.window_name, + group_name=request.group_name, + element=request.element.value, sender=request.sender, text=request.text, color=request.color ) if message_id is None: - raise HTTPException(status_code=404, detail=f"Chat window '{request.window_name}' not found") + raise HTTPException(status_code=404, detail=f"Chat window '{request.group_name}' not found") return ChatMessageResponse(status="ok", message_id=message_id) @app.put("/chat/message", response_model=OperationResponse, tags=["chat"]) async def update_chat_message(request: ChatMessageUpdateRequest): """Update an existing chat message's text content by its ID.""" if not self.manager.update_chat_message( - window_name=request.window_name, + group_name=request.group_name, + element=request.element.value, message_id=request.message_id, text=request.text ): - raise HTTPException(status_code=404, detail=f"Message '{request.message_id}' not found in window '{request.window_name}'") + raise HTTPException(status_code=404, detail=f"Message '{request.message_id}' not found in window '{request.group_name}'") return OperationResponse(status="ok") - @app.delete("/chat/messages/{window_name}", response_model=OperationResponse, tags=["chat"]) - async def clear_chat_messages(window_name: str): + @app.delete("/chat/messages/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def clear_chat_messages(group_name: str, element: str): """Clear all messages from a chat window.""" - if not self.manager.clear_chat_window(window_name): - raise HTTPException(status_code=404, detail=f"Chat window '{window_name}' not found") + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.clear_chat_window(group_name, element_type.value): + raise HTTPException(status_code=404, detail=f"Chat window '{group_name}' not found") return OperationResponse(status="ok") - @app.post("/chat/show/{name}", response_model=OperationResponse, tags=["chat"]) - async def show_chat_window(name: str): + @app.post("/chat/show/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def show_chat_window(group_name: str, element: str): """Show a hidden chat window.""" - if not self.manager.show_chat_window(name): - raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.show_chat_window(group_name, element_type.value): + raise HTTPException(status_code=404, detail=f"Chat window '{group_name}' not found") return OperationResponse(status="ok") - @app.post("/chat/hide/{name}", response_model=OperationResponse, tags=["chat"]) - async def hide_chat_window(name: str): + @app.post("/chat/hide/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def hide_chat_window(group_name: str, element: str): """Hide a chat window.""" - if not self.manager.hide_chat_window(name): - raise HTTPException(status_code=404, detail=f"Chat window '{name}' not found") + try: + element_type = WindowType(element) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid element type: {element}") + if not self.manager.hide_chat_window(group_name, element_type.value): + raise HTTPException(status_code=404, detail=f"Chat window '{group_name}' not found") + return OperationResponse(status="ok") + + # ─────────────────────────────── Element Visibility ─────────────────────────────── # + + @app.post("/element/show", response_model=OperationResponse, tags=["element"]) + async def show_element(request: Request): + """Show a hidden HUD element (message, persistent, or chat). + + The element will continue to receive updates and perform all logic, + but will now be displayed again. + """ + body = await request.json() + group_name = body.get("group_name") + element_str = body.get("element") + + if not group_name or not element_str: + raise HTTPException(status_code=400, detail="group_name and element are required") + + # Validate element is a valid WindowType enum value + try: + element = WindowType(element_str) + except ValueError: + valid_values = [e.value for e in WindowType] + raise HTTPException( + status_code=400, + detail=f"element must be one of: {', '.join(valid_values)}" + ) + + if not self.manager.show_element(group_name, element.value): + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") + return OperationResponse(status="ok") + + @app.post("/element/hide", response_model=OperationResponse, tags=["element"]) + async def hide_element(request: Request): + """Hide a HUD element (message, persistent, or chat). + + The element will no longer be displayed but will still receive updates + and perform all logic (timers, auto-hide, updates) in the background. + """ + body = await request.json() + group_name = body.get("group_name") + element_str = body.get("element") + + if not group_name or not element_str: + raise HTTPException(status_code=400, detail="group_name and element are required") + + # Validate element is a valid WindowType enum value + try: + element = WindowType(element_str) + except ValueError: + valid_values = [e.value for e in WindowType] + raise HTTPException( + status_code=400, + detail=f"element must be one of: {', '.join(valid_values)}" + ) + + if not self.manager.hide_element(group_name, element.value): + raise HTTPException(status_code=404, detail=f"Group '{group_name}' not found") return OperationResponse(status="ok") diff --git a/hud_server/tests/debug_layout.py b/hud_server/tests/debug_layout.py index 2b0d99a58..6fb75f7d8 100644 --- a/hud_server/tests/debug_layout.py +++ b/hud_server/tests/debug_layout.py @@ -7,7 +7,7 @@ sys.path.insert(0, ".") from hud_server.tests.test_runner import TestContext -from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType async def debug_layout_test(session): @@ -34,14 +34,14 @@ async def debug_layout_test(session): width=400, accent_color=color.value, ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) print(f" Created: {name} (priority={priority})") await asyncio.sleep(0.5) print("\n2. Showing all three messages...") for name, priority, color, label in groups_config: - await client.show_message(name, title=label, content=f"Priority: {priority}", duration=60.0) + await client.show_message(name, WindowType.MESSAGE, title=label, content=f"Priority: {priority}", duration=60.0) print(f" Shown: {name}") await asyncio.sleep(0.2) @@ -50,14 +50,14 @@ async def debug_layout_test(session): await asyncio.sleep(3) print("\n3. HIDING GREEN (middle)...") - await client.hide_message("debug_green") + await client.hide_message("debug_green", WindowType.MESSAGE) print(" Expected stack: RED, BLUE (GREEN hidden)") print(" BLUE should move UP to where GREEN was") print(" Waiting 3 seconds - verify visually...") await asyncio.sleep(3) print("\n4. SHOWING GREEN again...") - await client.show_message("debug_green", title="GREEN - BACK!", content="I should be in the MIDDLE!", duration=60.0) + await client.show_message("debug_green", WindowType.MESSAGE, title="GREEN - BACK!", content="I should be in the MIDDLE!", duration=60.0) print(" Expected stack: RED, GREEN, BLUE") print(" GREEN should appear BETWEEN RED and BLUE") print(" BLUE should move DOWN") @@ -66,7 +66,7 @@ async def debug_layout_test(session): print("\n5. Cleanup...") for name, _, _, _ in groups_config: - await client.hide_message(name) + await client.hide_message(name, WindowType.MESSAGE) await asyncio.sleep(1) print("\n[DONE] Check the console output above and visual behavior") diff --git a/hud_server/tests/test_layout_visual.py b/hud_server/tests/test_layout_visual.py index 4b4de15f6..94e24cef6 100644 --- a/hud_server/tests/test_layout_visual.py +++ b/hud_server/tests/test_layout_visual.py @@ -17,7 +17,7 @@ sys.path.insert(0, ".") from hud_server.tests.test_runner import TestContext -from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType # ============================================================================= @@ -82,7 +82,7 @@ async def cleanup_groups(client, group_names): """Helper to clean up groups.""" for name in group_names: try: - await client.hide_message(name) + await client.hide_message(name, WindowType.MESSAGE) except: pass await asyncio.sleep(0.5) @@ -109,10 +109,11 @@ async def test_all_nine_anchors(session): width=280, accent_color=_get_value(config["color"]), ) - await client.create_group(group_name, props=props) + await client.create_group(group_name, WindowType.MESSAGE, props=props) await client.show_message( group_name, + WindowType.MESSAGE, title=f"{config['emoji_fallback']} {config['label']}", content=f"Anchor: **{_get_value(anchor)}**\n\nThis window is positioned at the {config['label'].lower()} of the screen.", duration=30.0 @@ -157,10 +158,11 @@ async def test_priority_stacking(session): width=380, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=label, content=f"Priority value: **{priority}**\n\nHigher priority = closer to anchor point (top).", duration=20.0 @@ -186,10 +188,11 @@ async def test_priority_stacking(session): width=320, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=f"Right Side (P:{priority})", content=f"Independent stack on right side.\nPriority: {priority}", duration=15.0 @@ -220,7 +223,7 @@ async def test_dynamic_height_changes(session): width=420, accent_color=HudColor.ACCENT_ORANGE.value, ) - await client.create_group("dyn_top", props=top_props) + await client.create_group("dyn_top", WindowType.MESSAGE, props=top_props) bottom_props = MessageProps( anchor=Anchor.TOP_LEFT.value, @@ -229,12 +232,13 @@ async def test_dynamic_height_changes(session): width=420, accent_color=HudColor.ACCENT_BLUE.value, ) - await client.create_group("dyn_bottom", props=bottom_props) + await client.create_group("dyn_bottom", WindowType.MESSAGE, props=bottom_props) # Phase 1: Short top window print("Phase 1: Top window is SHORT") await client.show_message( "dyn_top", + WindowType.MESSAGE, title="Top Window - SHORT", content="This is a short message.", duration=30.0 @@ -243,6 +247,7 @@ async def test_dynamic_height_changes(session): await client.show_message( "dyn_bottom", + WindowType.MESSAGE, title="Bottom Window", content="Watch me move as the top window changes height!", duration=30.0 @@ -253,6 +258,7 @@ async def test_dynamic_height_changes(session): print("Phase 2: Top window GROWS - bottom should move DOWN") await client.show_message( "dyn_top", + WindowType.MESSAGE, title="Top Window - TALL", content="""This window has grown significantly! @@ -282,6 +288,7 @@ async def test_dynamic_height_changes(session): print("Phase 3: Top window SHRINKS - bottom should move UP") await client.show_message( "dyn_top", + WindowType.MESSAGE, title="Top Window - SHORT again", content="Shrunk back down.", duration=20.0 @@ -292,6 +299,7 @@ async def test_dynamic_height_changes(session): print("Phase 4: Top window MEDIUM height") await client.show_message( "dyn_top", + WindowType.MESSAGE, title="Top Window - MEDIUM", content="Now at a medium height.\n\nWith a bit more content.\n\nJust enough to demonstrate.", duration=15.0 @@ -322,33 +330,33 @@ async def test_visibility_reflow(session): width=380, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) # Show all three print("Phase 1: All 3 windows visible") for name, label in zip(groups, labels): - await client.show_message(name, title=label, content=f"Window: {label}", duration=30.0) + await client.show_message(name, WindowType.MESSAGE, title=label, content=f"Window: {label}", duration=30.0) await asyncio.sleep(0.2) await asyncio.sleep(3) # Hide middle (green) print("Phase 2: HIDING middle (Green) - Blue should move UP") - await client.hide_message("vis_2") + await client.hide_message("vis_2", WindowType.MESSAGE) await asyncio.sleep(3) # Show middle again print("Phase 3: SHOWING middle (Green) - Blue should move DOWN") - await client.show_message("vis_2", title="Second (Green) - BACK!", content="I'm back in the stack!", duration=20.0) + await client.show_message("vis_2", WindowType.MESSAGE, title="Second (Green) - BACK!", content="I'm back in the stack!", duration=20.0) await asyncio.sleep(3) # Hide first (red) print("Phase 4: HIDING first (Red) - Both should move UP") - await client.hide_message("vis_1") + await client.hide_message("vis_1", WindowType.MESSAGE) await asyncio.sleep(3) # Hide all except blue print("Phase 5: Only Blue remains") - await client.hide_message("vis_2") + await client.hide_message("vis_2", WindowType.MESSAGE) await asyncio.sleep(2) await cleanup_groups(client, groups) @@ -382,10 +390,11 @@ async def test_opposite_anchors(session): width=320, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=label, content=f"Anchor: **{_get_value(anchor)}**\n\nDiagonal positioning test.", duration=15.0 @@ -417,10 +426,11 @@ async def test_center_anchors(session): width=350, accent_color=HudColor.WHITE.value, ) - await client.create_group("center_main", props=center_props) + await client.create_group("center_main", WindowType.MESSAGE, props=center_props) await client.show_message( "center_main", + WindowType.MESSAGE, title="CENTER", content="This window is in the absolute center of the screen.", duration=20.0 @@ -448,10 +458,11 @@ async def test_center_anchors(session): width=260, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=label, content=f"Positioned at the {_get_value(anchor).replace('_', ' ')}.", duration=15.0 @@ -489,10 +500,11 @@ async def test_stacking_at_edge_centers(session): width=280, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=f"Left Stack (P:{priority})", content=f"Priority: {priority}\nVertically centered stack.", duration=20.0 @@ -513,10 +525,11 @@ async def test_stacking_at_edge_centers(session): width=280, accent_color=_get_value(color), ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) await client.show_message( name, + WindowType.MESSAGE, title=f"Right Stack (P:{priority})", content=f"Priority: {priority}\nMirrored stack on right.", duration=20.0 @@ -547,10 +560,11 @@ async def test_mixed_content_with_progress(session): width=400, accent_color=HudColor.ACCENT_BLUE.value, ) - await client.create_group("msg_group", props=msg_props) + await client.create_group("msg_group", WindowType.MESSAGE, props=msg_props) await client.show_message( "msg_group", + WindowType.MESSAGE, title="System Status", content="Active operations are displayed below.\n\nProgress bars update in real-time.", duration=30.0 @@ -564,11 +578,12 @@ async def test_mixed_content_with_progress(session): width=380, accent_color=HudColor.ACCENT_ORANGE.value, ) - await client.create_group("progress_group", props=progress_props) + await client.create_group("progress_group", WindowType.MESSAGE, props=progress_props) # Add progress bar await client.show_progress( "progress_group", + WindowType.MESSAGE, title="Download Progress", current=0, maximum=100, @@ -582,6 +597,7 @@ async def test_mixed_content_with_progress(session): for i in range(0, 101, 5): await client.show_progress( "progress_group", + WindowType.MESSAGE, title="Download Progress", current=i, maximum=100, @@ -593,7 +609,7 @@ async def test_mixed_content_with_progress(session): await asyncio.sleep(2) await cleanup_groups(client, groups) - await client.remove_item("progress_group", "Download Progress") + await client.remove_item("progress_group", WindowType.MESSAGE, "Download Progress") print("[OK] Test 8 complete\n") @@ -615,7 +631,7 @@ async def test_rapid_show_hide(session): width=350, accent_color=colors[i].value, ) - await client.create_group(name, props=props) + await client.create_group(name, WindowType.MESSAGE, props=props) print("Performing 5 rapid show/hide cycles...") @@ -624,25 +640,25 @@ async def test_rapid_show_hide(session): # Show all for name in groups: - await client.show_message(name, title=f"Window {name}", content=f"Cycle {cycle + 1}", duration=10.0) + await client.show_message(name, WindowType.MESSAGE, title=f"Window {name}", content=f"Cycle {cycle + 1}", duration=10.0) await asyncio.sleep(0.05) await asyncio.sleep(0.5) # Hide middle - await client.hide_message("rapid_2") + await client.hide_message("rapid_2", WindowType.MESSAGE) await asyncio.sleep(0.3) # Show middle - await client.show_message("rapid_2", title="Window rapid_2", content=f"Back! Cycle {cycle + 1}", duration=10.0) + await client.show_message("rapid_2", WindowType.MESSAGE, title="Window rapid_2", content=f"Back! Cycle {cycle + 1}", duration=10.0) await asyncio.sleep(0.3) # Hide first - await client.hide_message("rapid_1") + await client.hide_message("rapid_1", WindowType.MESSAGE) await asyncio.sleep(0.3) # Show first - await client.show_message("rapid_1", title="Window rapid_1", content=f"Back! Cycle {cycle + 1}", duration=10.0) + await client.show_message("rapid_1", WindowType.MESSAGE, title="Window rapid_1", content=f"Back! Cycle {cycle + 1}", duration=10.0) await asyncio.sleep(0.2) print("Stress test complete - checking final state...") diff --git a/hud_server/tests/test_multiuser.py b/hud_server/tests/test_multiuser.py index 3d882b0dd..cc8d0cebb 100644 --- a/hud_server/tests/test_multiuser.py +++ b/hud_server/tests/test_multiuser.py @@ -20,7 +20,7 @@ from hud_server.http_client import HudHttpClient from hud_server.types import ( HudColor, LayoutMode, - MessageProps, PersistentProps, ChatWindowProps + MessageProps, PersistentProps, ChatWindowProps, WindowType ) @@ -276,6 +276,7 @@ async def setup_private_groups(self): # Create private HUD await self._client.create_group( self.private_hud_group, + WindowType.MESSAGE, props=self._config_to_props(self.config["private_hud"]) ) print(f"[{self.display_name}] Created private HUD group") @@ -283,6 +284,7 @@ async def setup_private_groups(self): # Create private persistent panel await self._client.create_group( self.private_persistent_group, + WindowType.PERSISTENT, props=self._config_to_props(self.config["private_persistent"]) ) print(f"[{self.display_name}] Created private persistent group") @@ -340,6 +342,7 @@ async def show_private_message(self, title: str, content: str, return await self._client.show_message( self.private_hud_group, + WindowType.MESSAGE, title=title, content=content, color=color or self.config["private_hud"]["accent_color"], @@ -349,7 +352,7 @@ async def show_private_loader(self, show: bool = True): """Show/hide loader in private HUD.""" if not self._client: return - await self._client.show_loader(self.private_hud_group, show) + await self._client.show_loader(self.private_hud_group, WindowType.MESSAGE, show) async def add_private_item(self, title: str, description: str, duration: Optional[float] = None): @@ -358,6 +361,7 @@ async def add_private_item(self, title: str, description: str, return await self._client.add_item( self.private_persistent_group, + WindowType.PERSISTENT, title=title, description=description, duration=duration, @@ -369,6 +373,7 @@ async def update_private_item(self, title: str, description: str): return await self._client.update_item( self.private_persistent_group, + WindowType.PERSISTENT, title=title, description=description, ) @@ -377,7 +382,7 @@ async def remove_private_item(self, title: str): """Remove a persistent item from private panel.""" if not self._client: return - await self._client.remove_item(self.private_persistent_group, title) + await self._client.remove_item(self.private_persistent_group, WindowType.PERSISTENT, title) async def show_private_progress(self, title: str, current: float, maximum: float = 100, description: str = ""): @@ -386,6 +391,7 @@ async def show_private_progress(self, title: str, current: float, return await self._client.show_progress( self.private_persistent_group, + WindowType.PERSISTENT, title=title, current=current, maximum=maximum, @@ -399,6 +405,7 @@ async def show_private_timer(self, title: str, duration: float, return await self._client.show_timer( self.private_persistent_group, + WindowType.PERSISTENT, title=title, duration=duration, description=description, @@ -468,7 +475,7 @@ async def setup_shared_groups(self): typewriter_effect=config.get("typewriter_effect"), z_order=config.get("z_order"), ) - await self._client.create_group(group_id, props=props) + await self._client.create_group(group_id, WindowType.MESSAGE, props=props) print(f"[SharedGroupManager] Created shared group: {config['name']}") async def cleanup_shared_groups(self): @@ -490,6 +497,7 @@ async def send_team_notification(self, title: str, content: str, return await self._client.show_message( "team_notifications", + WindowType.MESSAGE, title=title, content=content, color=color, @@ -502,6 +510,7 @@ async def send_team_chat(self, sender: str, text: str, return await self._client.send_chat_message( "team_chat", + WindowType.CHAT, sender=sender, text=text, color=color, @@ -513,6 +522,7 @@ async def update_shared_status(self, title: str, description: str): return await self._client.add_item( "shared_status", + WindowType.PERSISTENT, title=title, description=description, ) diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py index f0d07579d..db3a38ba2 100644 --- a/hud_server/tests/test_session.py +++ b/hud_server/tests/test_session.py @@ -10,7 +10,7 @@ from hud_server.http_client import HudHttpClient from hud_server.types import ( Anchor, LayoutMode, HudColor, FontFamily, - MessageProps, PersistentProps, ChatWindowProps + MessageProps, PersistentProps, ChatWindowProps, WindowType ) @@ -102,9 +102,8 @@ def __init__(self, session_id: int, config: dict[str, Any], base_url: str = "htt self._client: Optional[HudHttpClient] = None self.running = False - # Group name for this session + # Group name for this session (just the identifier, element passed separately) self.group_name = f"session_{session_id}_{self.name.lower()}" - self.persistent_group = f"persistent_{session_id}" async def start(self) -> bool: """Connect to the HUD server.""" @@ -189,6 +188,7 @@ async def draw_message(self, title: str, message: str, color: Optional[str] = No color_value = color_value.value await self._client.show_message( group_name=self.group_name, + element=WindowType.MESSAGE, title=title, content=message, color=color_value, @@ -214,7 +214,7 @@ async def hide(self): """Hide the current message.""" if not self._client: return - await self._client.hide_message(group_name=self.group_name) + await self._client.hide_message(group_name=self.group_name, element=WindowType.MESSAGE) async def set_loading(self, state: bool): """Set loading indicator state.""" @@ -225,6 +225,7 @@ async def set_loading(self, state: bool): color_value = color_value.value await self._client.show_loader( group_name=self.group_name, + element=WindowType.MESSAGE, show=state, color=color_value, ) @@ -238,7 +239,8 @@ async def add_persistent_info(self, title: str, description: str, duration: Opti if not self._client: return await self._client.add_item( - group_name=self.persistent_group, + group_name=self.group_name, + element=WindowType.PERSISTENT, title=title, description=description, duration=duration, @@ -249,7 +251,8 @@ async def update_persistent_info(self, title: str, description: str): if not self._client: return await self._client.update_item( - group_name=self.persistent_group, + group_name=self.group_name, + element=WindowType.PERSISTENT, title=title, description=description, ) @@ -258,13 +261,38 @@ async def remove_persistent_info(self, title: str): """Remove persistent information.""" if not self._client: return - await self._client.remove_item(group_name=self.persistent_group, title=title) + await self._client.remove_item(group_name=self.group_name, element=WindowType.PERSISTENT, title=title) async def clear_all_persistent_info(self): """Clear all persistent information.""" if not self._client: return - await self._client.clear_items(group_name=self.persistent_group) + await self._client.clear_items(group_name=self.group_name, element=WindowType.PERSISTENT) + + # ========================================================================= + # Element Visibility Commands + # ========================================================================= + + async def hide_element(self, element: WindowType): + """Hide a HUD element (message, persistent, or chat).""" + if not self._client: + return + await self._client.hide_element(group_name=self.group_name, element=element) + + async def show_element(self, element: WindowType): + """Show a HUD element (message, persistent, or chat).""" + if not self._client: + return + group = self.group_name + await self._client.show_element(group_name=group, element=element) + + async def hide_persistent(self): + """Hide the persistent info panel.""" + await self.hide_element(WindowType.PERSISTENT) + + async def show_persistent(self): + """Show the persistent info panel.""" + await self.show_element(WindowType.PERSISTENT) # ========================================================================= # Progress Commands @@ -277,7 +305,8 @@ async def show_progress(self, title: str, current: float, maximum: float, if not self._client: return await self._client.show_progress( - group_name=self.persistent_group, + group_name=self.group_name, + element=WindowType.PERSISTENT, title=title, current=current, maximum=maximum, @@ -292,7 +321,8 @@ async def show_timer(self, title: str, duration: float, description: str = "", if not self._client: return await self._client.show_timer( - group_name=self.persistent_group, + group_name=self.group_name, + element=WindowType.PERSISTENT, title=title, duration=duration, description=description, @@ -308,7 +338,7 @@ async def create_chat_window(self, name: str, **props): """Create a chat window.""" if not self._client: return - await self._client.create_chat_window(name=name, **props) + await self._client.create_chat_window(group_name=name, element=WindowType.CHAT, **props) async def send_chat_message(self, window_name: str, sender: str, text: str, color: Optional[str] = None) -> Optional[str]: @@ -316,7 +346,8 @@ async def send_chat_message(self, window_name: str, sender: str, text: str, if not self._client: return None result = await self._client.send_chat_message( - window_name=window_name, + group_name=window_name, + element=WindowType.CHAT, sender=sender, text=text, color=color, @@ -330,7 +361,8 @@ async def update_chat_message(self, window_name: str, message_id: str, text: str if not self._client: return await self._client.update_chat_message( - window_name=window_name, + group_name=window_name, + element=WindowType.CHAT, message_id=message_id, text=text, ) @@ -339,25 +371,25 @@ async def clear_chat_window(self, name: str): """Clear a chat window.""" if not self._client: return - await self._client.clear_chat_window(name) + await self._client.clear_chat_window(group_name=name, element=WindowType.CHAT) async def delete_chat_window(self, name: str): """Delete a chat window.""" if not self._client: return - await self._client.delete_chat_window(name) + await self._client.delete_chat_window(group_name=name, element=WindowType.CHAT) async def show_chat_window(self, name: str): """Show a chat window.""" if not self._client: return - await self._client.show_chat_window(name) + await self._client.show_chat_window(group_name=name, element=WindowType.CHAT) async def hide_chat_window(self, name: str): """Hide a chat window.""" if not self._client: return - await self._client.hide_chat_window(name) + await self._client.hide_chat_window(group_name=name, element=WindowType.CHAT) # ========================================================================= # State Management diff --git a/hud_server/tests/test_snake.py b/hud_server/tests/test_snake.py index cb449eeb1..a35b8a021 100644 --- a/hud_server/tests/test_snake.py +++ b/hud_server/tests/test_snake.py @@ -27,7 +27,7 @@ import random from enum import Enum from hud_server.tests.test_session import TestSession -from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType try: import keyboard.keyboard as keyboard @@ -452,6 +452,7 @@ async def show_cell(session: TestSession, x: int, y: int, cell_type: str, color_ await session._client.show_message( group_name=group_name, + element=WindowType.MESSAGE, title=" ", content=" ", # Need non-empty content to keep HUD visible color=cell_color, @@ -468,7 +469,7 @@ async def hide_cell(session: TestSession, x: int, y: int): if (x, y) in _active_cell_huds: group_name = get_cell_group_name(x, y) - await session._client.delete_group(group_name) + await session._client.delete_group(group_name, WindowType.MESSAGE) _active_cell_huds.discard((x, y)) @@ -479,12 +480,12 @@ async def cleanup_all_cells(session: TestSession): for (x, y) in list(_active_cell_huds): group_name = get_cell_group_name(x, y) - await session._client.delete_group(group_name) + await session._client.delete_group(group_name, WindowType.MESSAGE) _active_cell_huds.clear() # Also clean up stats - await session._client.delete_group("snake_stats") + await session._client.delete_group("snake_stats", WindowType.MESSAGE) async def render_initial_state(session: TestSession, game: SnakeGame): @@ -621,6 +622,7 @@ async def show_combo_flash(session: TestSession, combo: int): ) await session._client.show_message( group_name="snake_combo_flash", + element=WindowType.MESSAGE, title=" ", content=combo_text, color=color, @@ -641,6 +643,7 @@ async def show_start_screen(session: TestSession): # Title HUD - Highest priority await session._client.show_message( group_name="snake_menu_title", + element=WindowType.MESSAGE, title=" ", # Space to pass validation content="# 🐍 ENDLESS SNAKE GAME 🐍", color=COLOR_GAME, @@ -651,6 +654,7 @@ async def show_start_screen(session: TestSession): # How to Play HUD await session._client.show_message( group_name="snake_menu_howto", + element=WindowType.MESSAGE, title=" ", content="""## How to Play - Use **Arrow Keys** to control the snake @@ -667,6 +671,7 @@ async def show_start_screen(session: TestSession): # Features HUD await session._client.show_message( group_name="snake_menu_features", + element=WindowType.MESSAGE, title=" ", content="""## Features - 🌈 Snake body gradient (head to tail) @@ -682,6 +687,7 @@ async def show_start_screen(session: TestSession): # Controls HUD await session._client.show_message( group_name="snake_menu_controls", + element=WindowType.MESSAGE, title=" ", content=f"""## Controls - **↑ ↓ ← →** : Move snake @@ -694,6 +700,7 @@ async def show_start_screen(session: TestSession): # Start Button HUD await session._client.show_message( group_name="snake_menu_start", + element=WindowType.MESSAGE, title=" ", content="🎮 **Press SPACE to begin your endless journey!** 🎮", color=COLOR_GAME, @@ -722,6 +729,7 @@ async def show_stats(session: TestSession, game: SnakeGame, elapsed: float, spee await session._client.show_message( group_name="snake_stats", + element=WindowType.MESSAGE, title="🎮 Endless Snake", content=stats_message, color=COLOR_GAME, @@ -761,6 +769,7 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: # Game Over Title HUD await session._client.show_message( group_name="snake_gameover_title", + element=WindowType.MESSAGE, title=" ", content=f"# {result_emoji} GAME OVER {result_emoji}", color=COLOR_GAME_OVER, @@ -771,6 +780,7 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: # Rating HUD await session._client.show_message( group_name="snake_gameover_rating", + element=WindowType.MESSAGE, title=" ", content=f"## {rating}", color=COLOR_GAME_OVER, @@ -781,6 +791,7 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: # Stats HUD await session._client.show_message( group_name="snake_gameover_stats", + element=WindowType.MESSAGE, title=" ", content=f"""### Final Stats - **Score:** {game.score} @@ -795,6 +806,7 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: # Play Again Button HUD await session._client.show_message( group_name="snake_gameover_playagain", + element=WindowType.MESSAGE, title=" ", content="🔄 **Press SPACE to play again**", color=COLOR_GAME, @@ -805,6 +817,7 @@ async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: # Exit Button HUD await session._client.show_message( group_name="snake_gameover_exit", + element=WindowType.MESSAGE, title=" ", content="👋 **Press ESC to exit**", color="#888888", @@ -842,11 +855,11 @@ async def test_snake_game(session: TestSession): # Hide start menu before game starts if session._client: - await session._client.delete_group("snake_menu_title") - await session._client.delete_group("snake_menu_howto") - await session._client.delete_group("snake_menu_features") - await session._client.delete_group("snake_menu_controls") - await session._client.delete_group("snake_menu_start") + await session._client.delete_group("snake_menu_title", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_howto", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_features", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_controls", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_start", WindowType.MESSAGE) print(f"[{session.name}] Game started!") @@ -959,11 +972,11 @@ def on_arrow_right(e): # Hide game over menu if session._client: - await session._client.delete_group("snake_gameover_title") - await session._client.delete_group("snake_gameover_rating") - await session._client.delete_group("snake_gameover_stats") - await session._client.delete_group("snake_gameover_playagain") - await session._client.delete_group("snake_gameover_exit") + await session._client.delete_group("snake_gameover_title", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_rating", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_stats", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_playagain", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_exit", WindowType.MESSAGE) # Cleanup keyboard hooks before returning keyboard.unhook_all() @@ -976,17 +989,17 @@ def on_arrow_right(e): # Cleanup all menu HUDs if session._client: # Start menu - await session._client.delete_group("snake_menu_title") - await session._client.delete_group("snake_menu_howto") - await session._client.delete_group("snake_menu_features") - await session._client.delete_group("snake_menu_controls") - await session._client.delete_group("snake_menu_start") + await session._client.delete_group("snake_menu_title", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_howto", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_features", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_controls", WindowType.MESSAGE) + await session._client.delete_group("snake_menu_start", WindowType.MESSAGE) # Game over menu - await session._client.delete_group("snake_gameover_title") - await session._client.delete_group("snake_gameover_rating") - await session._client.delete_group("snake_gameover_stats") - await session._client.delete_group("snake_gameover_playagain") - await session._client.delete_group("snake_gameover_exit") + await session._client.delete_group("snake_gameover_title", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_rating", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_stats", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_playagain", WindowType.MESSAGE) + await session._client.delete_group("snake_gameover_exit", WindowType.MESSAGE) return False diff --git a/skills/hud/main.py b/skills/hud/main.py index aa1c94ba2..c19be7d73 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -27,7 +27,7 @@ from services.printr import Printr from skills.skill_base import Skill, tool from hud_server.http_client import HudHttpClient -from hud_server.types import Anchor, HudColor, FontFamily, LayoutMode, MessageProps, PersistentProps +from hud_server.types import Anchor, HudColor, FontFamily, LayoutMode, MessageProps, PersistentProps, WindowType if TYPE_CHECKING: from wingmen.open_ai_wingman import OpenAiWingman @@ -75,9 +75,8 @@ def __init__( self._monitor_task: Optional[asyncio.Task] = None self._main_loop: Optional[asyncio.AbstractEventLoop] = None - # Groups configuration - self._messages_group = "messages" - self._persistent_group = "persistent" + # Group name for HUD (just wingman identifier, element passed separately) + self._group_name = None # ─────────────────────────────── Configuration ─────────────────────────────── # @@ -415,12 +414,12 @@ async def update_config(self, new_config) -> None: pers_props = self._get_persistent_props() # Delete and recreate message group - await self._client.delete_group(self._messages_group) - await self._client.create_group(self._messages_group, props=msg_props) + await self._client.delete_group(self._group_name, WindowType.MESSAGE) + await self._client.create_group(self._group_name, WindowType.MESSAGE, props=msg_props) # Delete and recreate persistent group, then restore items - await self._client.delete_group(self._persistent_group) - await self._client.create_group(self._persistent_group, props=pers_props) + await self._client.delete_group(self._group_name, WindowType.PERSISTENT) + await self._client.create_group(self._group_name, WindowType.PERSISTENT, props=pers_props) # Re-add all persistent items with the new group settings if self._persistent_items: @@ -461,10 +460,10 @@ async def _ensure_connected(self) -> bool: pass # Setup group names if not done - if self._messages_group == "messages": + if self._group_name == "messages": sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) - self._messages_group = f"messages_{sanitized_name}" - self._persistent_group = f"persistent_{sanitized_name}" + self._group_name = f"messages_{sanitized_name}" + self._group_name = f"persistent_{sanitized_name}" if not self._client.connected: # Try to connect/reconnect @@ -480,8 +479,8 @@ async def _ensure_connected(self) -> bool: # Create/update groups after connect msg_props = self._get_hud_props() pers_props = self._get_persistent_props() - await self._client.create_group(self._messages_group, props=msg_props) - await self._client.create_group(self._persistent_group, props=pers_props) + await self._client.create_group(self._group_name, WindowType.MESSAGE, props=msg_props) + await self._client.create_group(self._group_name, WindowType.PERSISTENT, props=pers_props) # Start audio monitor if not running if not self._monitor_task or self._monitor_task.done(): @@ -530,8 +529,8 @@ async def prepare(self) -> None: # Setup groups with unique names per wingman sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) - self._messages_group = f"messages_{sanitized_name}" - self._persistent_group = f"persistent_{sanitized_name}" + self._group_name = f"messages_{sanitized_name}" + self._group_name = f"persistent_{sanitized_name}" try: if await self._client.connect(timeout=3.0): @@ -546,8 +545,8 @@ async def prepare(self) -> None: try: msg_props = self._get_hud_props() pers_props = self._get_persistent_props() - await self._client.create_group(self._messages_group, props=msg_props) - await self._client.create_group(self._persistent_group, props=pers_props) + await self._client.create_group(self._group_name, WindowType.MESSAGE, props=msg_props) + await self._client.create_group(self._group_name, WindowType.PERSISTENT, props=pers_props) except Exception: pass else: @@ -626,8 +625,8 @@ async def unload(self) -> None: # Save state self._save_persistent_items() self.hud_clear_all(False) - await self._client.delete_group(self._messages_group) - await self._client.delete_group(self._persistent_group) + await self._client.delete_group(self._group_name, WindowType.MESSAGE) + await self._client.delete_group(self._group_name, WindowType.PERSISTENT) # Disconnect client if self._client: @@ -715,7 +714,8 @@ async def _show_message( props.fade_delay = duration result = await self._client.show_message( - group_name=self._messages_group, + group_name=self._group_name, + element=WindowType.MESSAGE, title=title, content=message, color=color, @@ -735,13 +735,13 @@ async def _hide_message(self): if not await self._ensure_connected(): return - await self._client.hide_message(group_name=self._messages_group) + await self._client.hide_message(group_name=self._group_name, element=WindowType.MESSAGE) async def _show_loader(self, show: bool, color: str = None): """Show or hide the loading animation.""" if not await self._ensure_connected(): return - await self._client.show_loader(group_name=self._messages_group, show=show, color=color) + await self._client.show_loader(group_name=self._group_name, element=WindowType.MESSAGE, show=show, color=color) def _send_command_sync(self, coro): """Send a command synchronously (for @tool methods).""" @@ -817,7 +817,8 @@ async def _restore_persistent_items(self): self._persistent_items[title] = item self._send_command_sync( self._client.show_timer( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, duration=item['timer_duration'], description=item.get('description', ''), @@ -831,7 +832,8 @@ async def _restore_persistent_items(self): self._persistent_items[title] = item self._send_command_sync( self._client.show_progress( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, current=item.get('current', 0), maximum=item.get('maximum', 100), @@ -851,7 +853,8 @@ async def _restore_persistent_items(self): self._persistent_items[title] = item self._send_command_sync( self._client.add_item( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, description=item.get('description', ''), duration=remaining_duration @@ -973,7 +976,8 @@ def hud_add_info( if self._client: self._send_command_sync( self._client.add_item( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, description=description_markdown, duration=valid_duration @@ -994,7 +998,7 @@ def hud_remove_info(self, title: str) -> str: if self._client: self._send_command_sync( - self._client.remove_item(group_name=self._persistent_group, title=title) + self._client.remove_item(group_name=self._group_name, element=WindowType.PERSISTENT, title=title) ) self._save_persistent_items() @@ -1046,7 +1050,7 @@ def hud_clear_all(self, save: bool = True) -> str: self._persistent_items.pop(title, None) if self._client: self._send_command_sync( - self._client.remove_item(group_name=self._persistent_group, title=title) + self._client.remove_item(group_name=self._group_name, element=WindowType.PERSISTENT, title=title) ) if save: @@ -1092,7 +1096,8 @@ def hud_show_progress( if self._client: self._send_command_sync( self._client.show_progress( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, current=current, maximum=maximum, @@ -1142,7 +1147,8 @@ def hud_show_timer( if self._client: self._send_command_sync( self._client.show_timer( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, duration=duration_seconds, description=description_markdown or '', @@ -1194,7 +1200,8 @@ def hud_update_progress( if self._client: self._send_command_sync( self._client.show_progress( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, current=current, maximum=maximum, @@ -1239,7 +1246,8 @@ def hud_update_info( if self._client: self._send_command_sync( self._client.update_item( - group_name=self._persistent_group, + group_name=self._group_name, + element=WindowType.PERSISTENT, title=title, description=description_markdown, duration=send_duration @@ -1248,3 +1256,57 @@ def hud_update_info( self._save_persistent_items() return f"Updated info panel: {title}" + + @tool() + def hud_hide(self) -> str: + """ + Hide the HUD elements (message window and persistent info panel). + + The HUD elements will no longer be displayed but will still receive updates + and perform all logic (timers, auto-hide, item updates) in the background. + Use hud_show to display them again. + """ + print(f"[HUD SKILL] hud_hide called, _group_name={self._group_name}") + if self._client: + print(f"[HUD SKILL] Calling hide_element for PERSISTENT") + self._send_command_sync( + self._client.hide_element( + group_name=self._group_name, + element=WindowType.PERSISTENT + ) + ) + print(f"[HUD SKILL] Calling hide_element for MESSAGE") + self._send_command_sync( + self._client.hide_element( + group_name=self._group_name, + element=WindowType.MESSAGE + ) + ) + else: + print(f"[HUD SKILL] No client!") + + return "HUD is now hidden." + + @tool() + def hud_show(self) -> str: + """ + Show the HUD elements (message window and persistent info panel). + + The HUD elements will continue to receive updates and perform all logic, + and will now be displayed again. + """ + if self._client: + self._send_command_sync( + self._client.show_element( + group_name=self._group_name, + element=WindowType.PERSISTENT + ) + ) + self._send_command_sync( + self._client.show_element( + group_name=self._group_name, + element=WindowType.MESSAGE + ) + ) + + return "HUD is now visible." From 5b33144a93cad031ba02626b18ec95106d138b69 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Sun, 15 Feb 2026 18:54:49 +0100 Subject: [PATCH 20/27] Refactor window positioning logic to ensure immediate layout updates - even during fade transitions --- hud_server/overlay/overlay.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index f738f3530..6a6897358 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -561,24 +561,23 @@ def _update_all_window_positions(self): if not canvas: continue - # For windows that are visible or fading in, use layout position - # For windows fading out (state 3), keep their current position (don't move during fade) - if fade_state in (1, 2): # Fading in or fully visible - pos = positions.get(name) - if pos: - x, y = pos - w, h = canvas.size - - # Check if position actually changed - old_x = win.get('_last_x', -1) - old_y = win.get('_last_y', -1) - - if x != old_x or y != old_y: - # Position changed - move window and mark for reblit - user32.MoveWindow(hwnd, x, y, w, h, True) # True = repaint - win['_last_x'] = x - win['_last_y'] = y - win['canvas_dirty'] = True # Force reblit after move + # Get position from layout - windows should be repositioned even when fading out + # so that layout updates are immediate when other windows change size + pos = positions.get(name) + if pos: + x, y = pos + w, h = canvas.size + + # Check if position actually changed + old_x = win.get('_last_x', -1) + old_y = win.get('_last_y', -1) + + if x != old_x or y != old_y: + # Position changed - move window and mark for reblit + user32.MoveWindow(hwnd, x, y, w, h, True) # True = repaint + win['_last_x'] = x + win['_last_y'] = y + win['canvas_dirty'] = True # Force reblit after move def _update_message_window(self, name: str, win: Dict): """Update message window state (typewriter, fade, etc.).""" From e9cd648d96025e98f4cca54dbab9e33a0c411e02 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Sun, 15 Feb 2026 22:19:25 +0100 Subject: [PATCH 21/27] Enhance chat window overflow handling with fade effects for clipped content --- hud_server/overlay/overlay.py | 158 +++++++++++++++++++++++++++++- hud_server/tests/test_chat.py | 29 ++++-- hud_server/tests/test_messages.py | 60 ++++++++++++ hud_server/tests/test_session.py | 21 ++++ 4 files changed, 257 insertions(+), 11 deletions(-) diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index 6a6897358..d6af8104c 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -915,14 +915,22 @@ def _draw_message_window(self, name: str, win: Dict): self._draw_loading(draw, temp, padding, y, width - padding * 2, loading_color) y += 24 + # Loading animation - reserve 30px at bottom when loading + loader_space = 30 if win.get('is_loading') else 0 + # Calculate final height - use fixed height if specified fixed_height = props.get('height') bottom_padding = padding - 4 if fixed_height is not None: final_h = int(fixed_height) else: + # Calculate content height without loader space reservation + # (loader is already included in y, we just need to cap at max_height) final_h = min(max(60, y + bottom_padding), max_height) + # Determine if content is clipped (overflows the final window height) + content_clipped = y > final_h + # Create final canvas - ALWAYS create fresh to prevent ghosting old_canvas = win.get('canvas') if old_canvas is None or old_canvas.width != width or old_canvas.height != final_h: @@ -941,15 +949,66 @@ def _draw_message_window(self, name: str, win: Dict): final_draw.rounded_rectangle([0, 0, width - 1, final_h - 1], radius=radius, fill=bg + (bg_alpha,), outline=(55, 62, 74)) - crop_height = min(final_h, temp.height) - crop = temp.crop((0, 0, width, crop_height)) + # Crop content - show bottom portion when clipped (newest content), top when fits + if content_clipped: + # Content overflows - crop from bottom to show newest content + # Account for loader space: ensure loader is within visible area + crop_top = max(0, y - final_h) + crop = temp.crop((0, crop_top, width, crop_top + final_h)) + else: + # Content fits - crop from top + crop = temp.crop((0, 0, width, min(final_h, temp.height))) + # Composite the text onto the background properly # Use Image.alpha_composite to blend correctly without leaving ghost pixels # First, create a version of the canvas portion and composite - canvas_region = canvas.crop((0, 0, width, crop_height)) + canvas_region = canvas.crop((0, 0, width, final_h)) composited = Image.alpha_composite(canvas_region, crop) canvas.paste(composited, (0, 0)) + # Apply fade gradient at top when content is clipped to indicate more content above + if content_clipped: + fade_height = int(props.get('scroll_fade_height', 40)) + if fade_height > 0: + # Fade at top to indicate more content above + top_region = canvas.crop((0, 0, width, fade_height)) + + # Create a mask identifying magenta (color key) pixels to preserve rounded corners + top_data = top_region.load() + corner_mask = Image.new('L', (width, fade_height), 0) + corner_mask_data = corner_mask.load() + for py in range(fade_height): + for px in range(width): + r, g, b, a = top_data[px, py] + # Check if pixel is magenta (color key for transparency) + if r == 255 and g == 0 and b == 255: + corner_mask_data[px, py] = 255 # Mark as corner pixel + + # Create a gradient mask that fades from opaque bg at top to transparent at bottom + gradient = Image.new('L', (width, fade_height), 0) + for fade_y in range(fade_height): + # Fade: 255 (full bg) at top, 0 (no bg) at bottom + alpha = int(255 * (1.0 - fade_y / fade_height)) + ImageDraw.Draw(gradient).line([(0, fade_y), (width, fade_y)], fill=alpha) + + # Create background layer for fade + bg_layer = Image.new('RGBA', (width, fade_height), bg + (255,)) + + # Apply gradient as alpha to bg layer + bg_layer.putalpha(gradient) + + # Composite fade over content + faded_top = Image.alpha_composite(top_region, bg_layer) + + # Restore magenta pixels for corners (color key transparency) + faded_data = faded_top.load() + for py in range(fade_height): + for px in range(width): + if corner_mask_data[px, py] == 255: + faded_data[px, py] = (255, 0, 255, 255) + + canvas.paste(faded_top, (0, 0)) + # Update layout manager with new height and get position self._layout_manager.update_window_height(name, final_h) @@ -1167,6 +1226,9 @@ def _draw_persistent_window(self, name: str, win: Dict): calculated_height = max(60, y + bottom_padding) final_h = min(calculated_height, max_height) + # Determine if content is clipped (overflows the final window height) + content_clipped = y > final_h + # Create final canvas - ALWAYS create fresh to prevent ghosting old_canvas = win.get('canvas') if old_canvas is None or old_canvas.width != width or old_canvas.height != final_h: @@ -1190,6 +1252,96 @@ def _draw_persistent_window(self, name: str, win: Dict): composited = Image.alpha_composite(canvas_region, crop) canvas.paste(composited, (0, 0)) + # Apply fade gradient at top when content is clipped to indicate more content above + if content_clipped: + fade_height = int(props.get('scroll_fade_height', 40)) + if fade_height > 0: + # Get the top portion of the canvas before applying fade + top_region = canvas.crop((0, 0, width, fade_height)) + + # Create a mask identifying magenta (color key) pixels to preserve rounded corners + # Magenta = (255, 0, 255) is used as transparency color key + top_data = top_region.load() + corner_mask = Image.new('L', (width, fade_height), 0) + corner_mask_data = corner_mask.load() + for py in range(fade_height): + for px in range(width): + r, g, b, a = top_data[px, py] + # Check if pixel is magenta (color key for transparency) + if r == 255 and g == 0 and b == 255: + corner_mask_data[px, py] = 255 # Mark as corner pixel + + # Create a gradient mask that fades from opaque bg at top to transparent at bottom + gradient = Image.new('L', (width, fade_height), 0) + for fade_y in range(fade_height): + # Fade: 255 (full bg) at top, 0 (no bg) at bottom + alpha = int(255 * (1.0 - fade_y / fade_height)) + ImageDraw.Draw(gradient).line([(0, fade_y), (width, fade_y)], fill=alpha) + + # Create background layer for fade + bg_layer = Image.new('RGBA', (width, fade_height), bg + (255,)) + + # Apply gradient as alpha to bg layer + bg_layer.putalpha(gradient) + + # Composite fade over content + faded_top = Image.alpha_composite(top_region, bg_layer) + + # Restore magenta pixels for corners (color key transparency) + faded_data = faded_top.load() + for py in range(fade_height): + for px in range(width): + if corner_mask_data[px, py] == 255: + faded_data[px, py] = (255, 0, 255, 255) + + canvas.paste(faded_top, (0, 0)) + + # Apply fade gradient at bottom when content is clipped to indicate more content below + if content_clipped: + fade_height = int(props.get('scroll_fade_height', 40)) + if fade_height > 0: + # Get the bottom portion of the canvas + fade_y = max(0, final_h - fade_height) + fade_actual = min(fade_height, final_h - fade_y) + if fade_actual > 0: + bottom_region = canvas.crop((0, fade_y, width, fade_y + fade_actual)) + + # Create a mask identifying magenta (color key) pixels to preserve rounded corners + bottom_data = bottom_region.load() + corner_mask = Image.new('L', (width, fade_actual), 0) + corner_mask_data = corner_mask.load() + for py in range(fade_actual): + for px in range(width): + r, g, b, a = bottom_data[px, py] + # Check if pixel is magenta (color key for transparency) + if r == 255 and g == 0 and b == 255: + corner_mask_data[px, py] = 255 # Mark as corner pixel + + # Create a gradient mask that fades from transparent at bottom to opaque at top + gradient = Image.new('L', (width, fade_actual), 0) + for fade_y_idx in range(fade_actual): + # Fade: 0 (transparent) at bottom, 255 (opaque) at top of fade region + alpha = int(255 * fade_y_idx / fade_actual) + ImageDraw.Draw(gradient).line([(0, fade_y_idx), (width, fade_y_idx)], fill=alpha) + + # Create background layer for fade + bg_layer = Image.new('RGBA', (width, fade_actual), bg + (255,)) + + # Apply gradient as alpha to bg layer + bg_layer.putalpha(gradient) + + # Composite fade over content + faded_bottom = Image.alpha_composite(bottom_region, bg_layer) + + # Restore magenta pixels for corners (color key transparency) + faded_data = faded_bottom.load() + for py in range(fade_actual): + for px in range(width): + if corner_mask_data[px, py] == 255: + faded_data[px, py] = (255, 0, 255, 255) + + canvas.paste(faded_bottom, (0, fade_y)) + # Update layout manager with new height and get position self._layout_manager.update_window_height(name, final_h) diff --git a/hud_server/tests/test_chat.py b/hud_server/tests/test_chat.py index db073157d..c62138c02 100644 --- a/hud_server/tests/test_chat.py +++ b/hud_server/tests/test_chat.py @@ -362,7 +362,7 @@ async def test_chat_auto_hide(session: TestSession): async def test_chat_overflow(session: TestSession): - """Test message overflow and fade effect.""" + """Test message overflow and fade effect with long messages and typewriter effect.""" print(f"[{session.name}] Testing chat overflow...") chat_name = f"overflow_{session.session_id}" @@ -370,23 +370,36 @@ async def test_chat_overflow(session: TestSession): anchor = session.config.get("anchor", Anchor.TOP_LEFT) anchor_value = anchor.value if hasattr(anchor, 'value') else anchor + # Very small height to trigger overflow fade props = ChatWindowProps( anchor=anchor_value, - priority=10, # Fifth priority + priority=10, layout_mode=LayoutMode.AUTO.value, width=400, - max_height=200, # Small height to trigger overflow + max_height=180, # Very small to ensure overflow with long messages fade_old_messages=True, ) await session.create_chat_window(name=chat_name, **props.to_dict()) await asyncio.sleep(0.5) - # Send many messages to overflow (unique senders per message to prevent merging) - for i in range(15): - await session.send_chat_message(chat_name, f"User{i}", f"Message #{i+1}: Testing overflow behavior") - await asyncio.sleep(0.4) + # Long messages with markdown to test fade out + long_messages = [ + ("Alice", "# This is a very long message title\n\nThis is a long paragraph with **bold text** and *italic text* and some `code` inline. The message continues with more content to make it really long and trigger the fade effect at the bottom of the chat window.\n\n- List item 1\n- List item 2\n- List item 3\n\nAnother paragraph with even more text to ensure we definitely overflow the small max_height."), + ("Bob", "This is another long message with **formatting** and some longer content. It should help test the bottom fade effect when messages pile up and exceed the maximum height.\n\nHere's a code block:\n```python\ndef hello():\n print('Hello, World!')\n```\n\nEnd of message."), + ("Charlie", "Short msg"), + ("Diana", "## Header in message\n\nThis is a moderately long message with multiple lines of content. It has **bold**, *italic*, and some regular text to test rendering.\n\nLet's add more lines to make it even longer and ensure we trigger the overflow behavior.\n\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10"), + ("Eve", "Final short message"), + ] + + # Send each long message and wait for typewriter effect + for sender, message in long_messages: + await session.send_chat_message(chat_name, sender, message) + # Wait longer for typewriter effect to complete + await asyncio.sleep(2.5) + + # Wait to observe the final result + await asyncio.sleep(3) - await asyncio.sleep(2) await session.delete_chat_window(chat_name) print(f"[{session.name}] Overflow test complete") diff --git a/hud_server/tests/test_messages.py b/hud_server/tests/test_messages.py index fe1f49aca..3d4f68550 100644 --- a/hud_server/tests/test_messages.py +++ b/hud_server/tests/test_messages.py @@ -179,6 +179,64 @@ async def test_sequential_messages(session: TestSession, delay: float = 1.5): print(f"[{session.name}] Sequential messages test complete") +async def test_message_bottom_fade(session: TestSession, delay: float = 2.0): + """Test bottom fade effect when message content overflows.""" + print(f"[{session.name}] Testing message bottom fade...") + + # Very long message that will overflow a small window + long_message = """# Comprehensive Status Report + +## Navigation Systems +All navigation systems are **fully operational**. Current heading: `045.7 deg` + +## Communication Array +Minor interference detected on channels 4-6. Switching to backup frequencies. + +## Resource Status +| Resource | Level | Rate | +|----------|-------|------| +| Fuel | 67% | -2%/h | +| O2 | 98% | -0.1%/h | +| Power | 85% | +5%/h | + +## Recommendations +1. Refuel at next station +2. Run diagnostics on comm array +3. Continue current heading + +## Additional Intel +- Sector scan complete +- No hostile contacts detected +- Friendly vessels in vicinity: 3 + +> *ETA to destination: 4h 32m* + +## Mission Details +- Objective: Survey nebula region +- Timeline: 48 hours +- Support: Available on demand + +## Final Notes +All systems nominal. Ready for next assignment. +""" + + # Use a small max_height to force overflow and trigger bottom fade + await session.draw_message_with_props( + "Wingman", + long_message, + custom_props={"max_height": 150, "scroll_fade_height": 30} + ) + await asyncio.sleep(delay) + + # Also test with loading indicator (should reserve 30px at bottom) + await session.set_loading(True) + await asyncio.sleep(delay) + await session.set_loading(False) + + await session.hide() + print(f"[{session.name}] Bottom fade test complete") + + # ============================================================================= # Run All Tests # ============================================================================= @@ -196,6 +254,8 @@ async def run_all_message_tests(session: TestSession): await test_loader_only(session) await asyncio.sleep(1) await test_sequential_messages(session) + await asyncio.sleep(1) + await test_message_bottom_fade(session) if __name__ == "__main__": diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py index db3a38ba2..eaa2ad752 100644 --- a/hud_server/tests/test_session.py +++ b/hud_server/tests/test_session.py @@ -196,6 +196,27 @@ async def draw_message(self, title: str, message: str, color: Optional[str] = No props=self._get_props().to_dict(), ) + async def draw_message_with_props(self, title: str, message: str, custom_props: dict): + """Draw a message with custom properties (e.g., smaller max_height to trigger overflow).""" + if not self._client: + return + # Start with default props and merge custom props + base_props = self._get_props().to_dict() + base_props.update(custom_props) + + color_value = self.config["accent_color"] + if hasattr(color_value, 'value'): + color_value = color_value.value + + await self._client.show_message( + group_name=self.group_name, + element=WindowType.MESSAGE, + title=title, + content=message, + color=color_value, + props=base_props, + ) + async def draw_user_message(self, message: str): """Draw a user message.""" color_value = self.config["user_color"] From 14617d937c5186dec6cae71718abd3d9c65ffd68 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Mon, 16 Feb 2026 18:29:31 +0100 Subject: [PATCH 22/27] Add multi-monitor support for HUD rendering with screen selection --- api/interface.py | 3 + hud_server/constants.py | 23 ++++-- hud_server/layout/manager.py | 29 +++++++- hud_server/models.py | 3 + hud_server/overlay/overlay.py | 12 ++- hud_server/platform/win32.py | 128 ++++++++++++++++++++++++++++++++ hud_server/server.py | 8 +- templates/configs/settings.yaml | 1 + wingman_core.py | 1 + 9 files changed, 193 insertions(+), 15 deletions(-) diff --git a/api/interface.py b/api/interface.py index a4759b5ce..d3f0c79fe 100644 --- a/api/interface.py +++ b/api/interface.py @@ -1071,6 +1071,9 @@ class HudServerSettings(BaseModel): framerate: int = Field(default=60, ge=1) """HUD overlay rendering framerate. Higher = smoother but more CPU. Minimum 1.""" + screen: int = Field(default=1, ge=1, le=10) + """Which screen/monitor to render the HUD on (1 = primary, 2 = secondary, etc.).""" + class SettingsConfig(BaseModel): audio: Optional[AudioSettings] = None diff --git a/hud_server/constants.py b/hud_server/constants.py index 62edeef2a..1f5026af1 100644 --- a/hud_server/constants.py +++ b/hud_server/constants.py @@ -138,11 +138,18 @@ HTTP_INTERNAL_ERROR = 500 # Log Messages -LOG_SERVER_STARTED = "HUD Server started on http://{}:{}" -LOG_SERVER_STOPPED = "HUD Server stopped" -LOG_SERVER_STARTUP_TIMEOUT = "Failed to start within {}s timeout" -LOG_SERVER_ALREADY_RUNNING = "Server already running" -LOG_OVERLAY_STARTED = "Overlay renderer started" -LOG_OVERLAY_STOPPED = "Overlay renderer stopped" -LOG_OVERLAY_NOT_AVAILABLE = "Overlay not available (PIL or HeadsUpOverlay missing)" -LOG_OVERLAY_ALREADY_RUNNING = "Overlay already running" +LOG_SERVER_STARTED = "[HUD] Server started on http://{}:{}" +LOG_SERVER_STOPPED = "[HUD] Server stopped" +LOG_SERVER_STARTUP_TIMEOUT = "[HUD] Failed to start within {}s timeout" +LOG_SERVER_ALREADY_RUNNING = "[HUD] Server already running" +LOG_OVERLAY_STARTED = "[HUD] Overlay renderer started" +LOG_OVERLAY_STOPPED = "[HUD] Overlay renderer stopped" +LOG_OVERLAY_NOT_AVAILABLE = "[HUD] Overlay not available (PIL or HeadsUpOverlay missing)" +LOG_OVERLAY_ALREADY_RUNNING = "[HUD] Overlay already running" +LOG_MONITORS_AVAILABLE = "[HUD] Available monitors: {}" +LOG_MONITOR_NONE = "[HUD] No monitors detected via EnumDisplayMonitors, using GetSystemMetrics" +LOG_MONITOR_SELECTED = "[HUD] Screen {} selected: {}x{}" +LOG_MONITOR_FALLBACK_GETSYSTEMMETRICS = "[HUD] Screen {} requested, falling back to GetSystemMetrics: {}x{}" +LOG_MONITOR_FALLBACK_UNAVAILABLE = "[HUD] Screen {} requested but not available, falling back to screen {}: {}x{}" +LOG_MONITOR_NONE_AVAILABLE = "[HUD] No monitors available, using hardcoded fallback: 1920x1080" +LOG_MONITOR_ERROR = "[HUD] Error enumerating monitors: {}" diff --git a/hud_server/layout/manager.py b/hud_server/layout/manager.py index f57d41089..271c03229 100644 --- a/hud_server/layout/manager.py +++ b/hud_server/layout/manager.py @@ -96,12 +96,16 @@ def __init__( self, screen_width: int = 1920, screen_height: int = 1080, + screen_offset_x: int = 0, + screen_offset_y: int = 0, default_margin: int = 20, default_spacing: int = 10 ): self._lock = threading.RLock() self._screen_width = screen_width self._screen_height = screen_height + self._screen_offset_x = screen_offset_x + self._screen_offset_y = screen_offset_y self._default_margin = default_margin self._default_spacing = default_spacing @@ -120,11 +124,24 @@ def set_screen_size(self, width: int, height: int): self._screen_height = height self._invalidate_cache() + def set_screen_offset(self, offset_x: int, offset_y: int): + """Update screen offset and invalidate cache.""" + with self._lock: + if self._screen_offset_x != offset_x or self._screen_offset_y != offset_y: + self._screen_offset_x = offset_x + self._screen_offset_y = offset_y + self._invalidate_cache() + @property def screen_size(self) -> Tuple[int, int]: """Get current screen dimensions.""" return (self._screen_width, self._screen_height) + @property + def screen_offset(self) -> Tuple[int, int]: + """Get current screen offset (position of monitor on desktop).""" + return (self._screen_offset_x, self._screen_offset_y) + def register_window( self, name: str, @@ -288,9 +305,15 @@ def compute_positions(self, force: bool = False) -> Dict[str, Tuple[int, int]]: self._windows[name].computed_x = x self._windows[name].computed_y = y - self._position_cache = positions + # Add screen offset to all positions + positions_with_offset = { + name: (x + self._screen_offset_x, y + self._screen_offset_y) + for name, (x, y) in positions.items() + } + + self._position_cache = positions_with_offset self._cache_valid = True - return positions + return positions_with_offset def _compute_anchor_positions( self, @@ -390,7 +413,7 @@ def get_position(window: WindowInfo, auto_x: int, auto_y: int) -> Tuple[int, int return positions def get_position(self, name: str) -> Optional[Tuple[int, int]]: - """Get the computed position for a window.""" + """Get the computed position for a window (offset already included via compute_positions).""" positions = self.compute_positions() return positions.get(name) diff --git a/hud_server/models.py b/hud_server/models.py index ba95bb808..952b8e7a3 100644 --- a/hud_server/models.py +++ b/hud_server/models.py @@ -33,6 +33,9 @@ class HudServerSettings(BaseModel): layout_spacing: int = Field(default=15, ge=0, le=100) """Spacing between stacked HUD windows in pixels. Between 0 and 100.""" + screen: int = Field(default=1, ge=1, le=10) + """Which screen/monitor to render the HUD on (1 = primary, 2 = secondary, etc.).""" + @field_validator('host') @classmethod def validate_host(cls, v: str) -> str: diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index d6af8104c..cd72afd2d 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -40,6 +40,7 @@ _ensure_window_class, _class_name, force_on_top, WINEVENTPROC, EVENT_SYSTEM_FOREGROUND, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, + get_monitor_dimensions, ) from hud_server.layout import LayoutManager, Anchor, LayoutMode from hud_server.constants import ( @@ -65,7 +66,7 @@ class HeadsUpOverlay: WINDOW_TYPE_CHAT = 'chat' def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, - layout_margin: int = 20, layout_spacing: int = 15): + layout_margin: int = 20, layout_spacing: int = 15, screen: int = 1): self.running = True self.msg_queue = command_queue if command_queue else queue.Queue() self.error_queue = error_queue @@ -76,6 +77,7 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, self._global_framerate = max(1, framerate) self._layout_margin = layout_margin self._layout_spacing = layout_spacing + self._screen = screen # Reactive foreground management self._foreground_changed = threading.Event() @@ -198,9 +200,13 @@ def __init__(self, command_queue=None, error_queue=None, framerate: int = 60, # LAYOUT MANAGER # ===================================================================== # Automatic positioning and stacking to prevent window overlap + # Get screen dimensions and offset based on selected monitor + screen_width, screen_height, screen_offset_x, screen_offset_y = get_monitor_dimensions(self._screen) self._layout_manager = LayoutManager( - screen_width=user32.GetSystemMetrics(0) if hasattr(user32, 'GetSystemMetrics') else 1920, - screen_height=user32.GetSystemMetrics(1) if hasattr(user32, 'GetSystemMetrics') else 1080, + screen_width=screen_width, + screen_height=screen_height, + screen_offset_x=screen_offset_x, + screen_offset_y=screen_offset_y, default_margin=self._layout_margin, default_spacing=self._layout_spacing, ) diff --git a/hud_server/platform/win32.py b/hud_server/platform/win32.py index 3ab9916ff..54b9e7491 100644 --- a/hud_server/platform/win32.py +++ b/hud_server/platform/win32.py @@ -1,6 +1,20 @@ import ctypes from ctypes import wintypes +from api.enums import LogType +from services.printr import Printr +from hud_server.constants import ( + LOG_MONITORS_AVAILABLE, + LOG_MONITOR_NONE, + LOG_MONITOR_SELECTED, + LOG_MONITOR_FALLBACK_GETSYSTEMMETRICS, + LOG_MONITOR_FALLBACK_UNAVAILABLE, + LOG_MONITOR_NONE_AVAILABLE, + LOG_MONITOR_ERROR, +) + +printr = Printr() + # Windows API Constants GWL_EXSTYLE = -20 WS_POPUP = 0x80000000 @@ -79,6 +93,33 @@ class BITMAPINFO(ctypes.Structure): wintypes.COLORREF, ctypes.POINTER(RGBQUAD), wintypes.DWORD ] +# Multi-monitor support - define types first +MONITORINFOF_PRIMARY = 1 + + +class MONITORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", wintypes.DWORD), + ("rcMonitor", wintypes.RECT), + ("rcWork", wintypes.RECT), + ("dwFlags", wintypes.DWORD), + ] + + +MONITORENUMPROC = ctypes.WINFUNCTYPE( + wintypes.BOOL, + wintypes.HMONITOR, + wintypes.HDC, + ctypes.POINTER(wintypes.RECT), + LPARAM +) + +# Setup function prototypes for multi-monitor APIs +user32.EnumDisplayMonitors.argtypes = [wintypes.HDC, ctypes.POINTER(wintypes.RECT), MONITORENUMPROC, LPARAM] +user32.EnumDisplayMonitors.restype = wintypes.BOOL +user32.GetMonitorInfoW.argtypes = [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)] +user32.GetMonitorInfoW.restype = wintypes.BOOL + # Basic Win32 message structures for a non-blocking pump class POINT(ctypes.Structure): _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] @@ -162,3 +203,90 @@ def _ensure_window_class(): def force_on_top(hwnd): user32.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) + + +# ─────────────────────────────── Multi-Monitor Support ─────────────────────────────── # + + +# Store callback globally to prevent garbage collection +_enum_callback = None + + +def get_all_monitors(): + """Get information about all connected monitors. + + Returns: + list: List of monitor info dicts with keys: left, top, right, bottom, width, height, is_primary + """ + global _enum_callback + + monitors = [] + try: + # Use a closure to capture monitors list + def callback(hmonitor, hdc, lprect, lparam): + mi = MONITORINFO() + mi.cbSize = ctypes.sizeof(MONITORINFO) + if user32.GetMonitorInfoW(hmonitor, ctypes.byref(mi)): + monitors.append({ + 'left': mi.rcMonitor.left, + 'top': mi.rcMonitor.top, + 'right': mi.rcMonitor.right, + 'bottom': mi.rcMonitor.bottom, + 'width': mi.rcMonitor.right - mi.rcMonitor.left, + 'height': mi.rcMonitor.bottom - mi.rcMonitor.top, + 'is_primary': bool(mi.dwFlags & MONITORINFOF_PRIMARY), + }) + return True + + _enum_callback = MONITORENUMPROC(callback) + user32.EnumDisplayMonitors(None, None, _enum_callback, 0) + except Exception as e: + printr.print(LOG_MONITOR_ERROR.format(e), color=LogType.ERROR, server_only=True) + return monitors + + +def get_monitor_dimensions(screen_index: int = 1): + """Get the dimensions and offset of a specific monitor by index. + + Args: + screen_index: Monitor index (1 = primary, 2 = secondary, etc.) + + Returns: + tuple: (width, height, offset_x, offset_y) of the requested monitor + """ + monitors = get_all_monitors() + + # Log available monitors + if monitors: + monitor_list = ", ".join( + f"{i+1}: {m['width']}x{m['height']}{' (primary)' if m['is_primary'] else ''}" + for i, m in enumerate(monitors) + ) + printr.print(LOG_MONITORS_AVAILABLE.format(monitor_list), color=LogType.INFO, server_only=True) + else: + printr.print(LOG_MONITOR_NONE, color=LogType.WARNING, server_only=True) + + if not monitors: + # Fallback to primary monitor using GetSystemMetrics + width = user32.GetSystemMetrics(0) if hasattr(user32, 'GetSystemMetrics') else 1920 + height = user32.GetSystemMetrics(1) if hasattr(user32, 'GetSystemMetrics') else 1080 + printr.print(LOG_MONITOR_FALLBACK_GETSYSTEMMETRICS.format(screen_index, width, height), color=LogType.WARNING, server_only=True) + return width, height, 0, 0 + + # Adjust index to 0-based + index = screen_index - 1 + + if index < len(monitors): + monitor = monitors[index] + printr.print(LOG_MONITOR_SELECTED.format(screen_index, monitor['width'], monitor['height']), color=LogType.INFO, server_only=True) + return monitor['width'], monitor['height'], monitor['left'], monitor['top'] + + # If the requested screen doesn't exist, return the last available monitor + if monitors: + monitor = monitors[-1] + printr.print(LOG_MONITOR_FALLBACK_UNAVAILABLE.format(screen_index, len(monitors), monitor['width'], monitor['height']), color=LogType.WARNING, server_only=True) + return monitor['width'], monitor['height'], monitor['left'], monitor['top'] + + # Ultimate fallback + printr.print(LOG_MONITOR_NONE_AVAILABLE, color=LogType.WARNING, server_only=True) + return 1920, 1080, 0, 0 diff --git a/hud_server/server.py b/hud_server/server.py index 1bbe96ebd..c42399557 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -80,6 +80,7 @@ class HudServer: DEFAULT_FRAMERATE = hud_const.DEFAULT_FRAMERATE DEFAULT_LAYOUT_MARGIN = hud_const.DEFAULT_LAYOUT_MARGIN DEFAULT_LAYOUT_SPACING = hud_const.DEFAULT_LAYOUT_SPACING + DEFAULT_SCREEN = 1 # Server startup timeout STARTUP_TIMEOUT_SECONDS = hud_const.SERVER_STARTUP_TIMEOUT @@ -95,6 +96,7 @@ def __init__(self): self._framerate = self.DEFAULT_FRAMERATE self._layout_margin = self.DEFAULT_LAYOUT_MARGIN self._layout_spacing = self.DEFAULT_LAYOUT_SPACING + self._screen = self.DEFAULT_SCREEN # HUD state manager self.manager = HudManager() @@ -592,6 +594,7 @@ def _start_overlay(self): framerate=self._framerate, layout_margin=self._layout_margin, layout_spacing=self._layout_spacing, + screen=self._screen, ) # Register callback to send commands to overlay @@ -676,7 +679,8 @@ def _send_to_overlay(self, command: dict[str, Any]): # ─────────────────────────────── Server Lifecycle ─────────────────────────────── # def start(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, framerate: int = DEFAULT_FRAMERATE, - layout_margin: int = DEFAULT_LAYOUT_MARGIN, layout_spacing: int = DEFAULT_LAYOUT_SPACING) -> bool: + layout_margin: int = DEFAULT_LAYOUT_MARGIN, layout_spacing: int = DEFAULT_LAYOUT_SPACING, + screen: int = DEFAULT_SCREEN) -> bool: """ Start the HUD server in a background thread. @@ -686,6 +690,7 @@ def start(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, framerate: i framerate: HUD overlay rendering framerate (min 1) layout_margin: Margin from screen edges in pixels layout_spacing: Spacing between stacked windows in pixels + screen: Which monitor to render the HUD on (1 = primary, 2 = secondary, etc.) Returns: True if server started successfully @@ -703,6 +708,7 @@ def start(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, framerate: i self._framerate = max(1, framerate) self._layout_margin = layout_margin self._layout_spacing = layout_spacing + self._screen = max(1, screen) self._thread = threading.Thread( target=self._run_server, diff --git a/templates/configs/settings.yaml b/templates/configs/settings.yaml index 6cfdb4424..aa07dec81 100644 --- a/templates/configs/settings.yaml +++ b/templates/configs/settings.yaml @@ -49,3 +49,4 @@ hud_server: host: "127.0.0.1" port: 7862 framerate: 60 + screen: 1 diff --git a/wingman_core.py b/wingman_core.py index bc28c4c3a..14a26ead5 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -455,6 +455,7 @@ async def _start_hud_server_if_enabled(self): framerate=getattr(hud_settings, 'framerate', 60), layout_margin=getattr(hud_settings, 'layout_margin', 20), layout_spacing=getattr(hud_settings, 'layout_spacing', 15), + screen=getattr(hud_settings, 'screen', 1), ): self.printr.print( f"HUD Server failed to start on port {hud_settings.port}", From 60363f82b05328ee9b02b877431112b0afd1ff1a Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Mon, 16 Feb 2026 21:05:05 +0100 Subject: [PATCH 23/27] Add dynamic HUD settings update functionality for framerate, layout margin, spacing, and screen selection --- api/interface.py | 6 ++ hud_server/overlay/overlay.py | 98 ++++++++++++++++++++++++++++++++ hud_server/server.py | 48 ++++++++++++++++ hud_server/tests/run_tests.py | 9 ++- hud_server/tests/test_session.py | 43 ++++++++++++++ templates/configs/settings.yaml | 2 + wingman_core.py | 15 +++++ 7 files changed, 220 insertions(+), 1 deletion(-) diff --git a/api/interface.py b/api/interface.py index d3f0c79fe..376ff9453 100644 --- a/api/interface.py +++ b/api/interface.py @@ -1071,6 +1071,12 @@ class HudServerSettings(BaseModel): framerate: int = Field(default=60, ge=1) """HUD overlay rendering framerate. Higher = smoother but more CPU. Minimum 1.""" + layout_margin: int = Field(default=20, ge=0, le=200) + """Margin from screen edges in pixels for HUD elements. Between 0 and 200.""" + + layout_spacing: int = Field(default=15, ge=0, le=100) + """Spacing between stacked HUD windows in pixels. Between 0 and 100.""" + screen: int = Field(default=1, ge=1, le=10) """Which screen/monitor to render the HUD on (1 = primary, 2 = secondary, etc.).""" diff --git a/hud_server/overlay/overlay.py b/hud_server/overlay/overlay.py index cd72afd2d..504cf8060 100644 --- a/hud_server/overlay/overlay.py +++ b/hud_server/overlay/overlay.py @@ -2854,9 +2854,107 @@ def _handle_message(self, msg): win['fade_state'] = 3 # fade out win['canvas_dirty'] = True + elif t == 'update_settings': + self._handle_settings_update(msg) + except Exception as e: self._report_exception("handle_message", e) + def _handle_settings_update(self, settings: dict): + """Handle settings update message. + + Args: + settings: Dict containing settings to update (framerate, layout_margin, layout_spacing, screen) + """ + # Update framerate + if "framerate" in settings: + self._global_framerate = max(1, min(240, settings["framerate"])) + + # Update layout settings + layout_changed = False + if "layout_margin" in settings: + self._layout_margin = settings["layout_margin"] + layout_changed = True + if "layout_spacing" in settings: + self._layout_spacing = settings["layout_spacing"] + layout_changed = True + + # If layout settings changed, reposition all windows + if layout_changed: + # Re-register all windows with new margin/spacing values + self._reregister_all_windows() + + # Handle screen change - recreate LayoutManager and reposition + if "screen" in settings: + new_screen = settings["screen"] + if new_screen != self._screen: + self._screen = new_screen + # Get new monitor dimensions + try: + screen_width, screen_height, screen_offset_x, screen_offset_y = get_monitor_dimensions(self._screen) + except Exception as e: + # Fall back to current dimensions + screen_width, screen_height = self._layout_manager.screen_size + screen_offset_x, screen_offset_y = self._layout_manager.screen_offset + # Create new layout manager with new screen info + self._layout_manager = LayoutManager( + screen_width=screen_width, + screen_height=screen_height, + screen_offset_x=screen_offset_x, + screen_offset_y=screen_offset_y, + default_margin=self._layout_margin, + default_spacing=self._layout_spacing, + ) + # Re-register all windows with new layout manager + self._reregister_all_windows() + + def _reregister_all_windows(self): + """Re-register all windows with the layout manager after screen change. + + This ensures windows are repositioned to the new screen's coordinates. + """ + # Use current margin/spacing values when re-registering + current_margin = self._layout_margin + current_spacing = self._layout_spacing + + for name, win in self._windows.items(): + props = win.get('props', {}) + + # Get anchor and layout mode from props or defaults + anchor_str = props.get('anchor', 'top_left') + layout_mode_str = props.get('layout_mode', 'auto') + + try: + anchor = Anchor(anchor_str) + except ValueError: + anchor = Anchor.TOP_LEFT + + try: + layout_mode = LayoutMode(layout_mode_str) if layout_mode_str == "manual" else LayoutMode.AUTO + except ValueError: + layout_mode = LayoutMode.AUTO + + # Get dimensions + width = props.get('width', 400) + height = win.get('canvas', {}).size[1] if win.get('canvas') else 200 + priority = props.get('priority', 10) + + # Re-register with layout manager, using CURRENT margin/spacing values + self._layout_manager.register_window( + name=name, + anchor=anchor, + mode=layout_mode, + priority=priority, + width=width, + height=height, + margin_x=current_margin, + margin_y=current_margin, + spacing=current_spacing, + ) + + # Force position recalculation and window repositioning + self._update_all_window_positions() + def _create_overlay_window(self, name, x, y, w, h): ex = WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE hwnd = user32.CreateWindowExW(ex, _class_name, name, WS_POPUP, x, y, w, h, diff --git a/hud_server/server.py b/hud_server/server.py index c42399557..4c3c551f5 100644 --- a/hud_server/server.py +++ b/hud_server/server.py @@ -211,6 +211,24 @@ async def root(): """Root endpoint - same as health check.""" return await health_check() + # ─────────────────────────────── Settings ─────────────────────────────── # + + @app.post("/settings/update", tags=["settings"]) + async def update_settings( + framerate: Optional[int] = None, + layout_margin: Optional[int] = None, + layout_spacing: Optional[int] = None, + screen: Optional[int] = None + ): + """Update HUD server settings dynamically without restart.""" + self.update_settings( + framerate=framerate, + layout_margin=layout_margin, + layout_spacing=layout_spacing, + screen=screen + ) + return {"status": "ok", "message": "Settings updated"} + # ─────────────────────────────── Groups ─────────────────────────────── # @app.post("/groups", response_model=OperationResponse, tags=["groups"]) @@ -736,6 +754,36 @@ def start(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, framerate: i ) return False + def update_settings(self, framerate: int = None, layout_margin: int = None, + layout_spacing: int = None, screen: int = None): + """Update HUD server settings without restarting. + + Args: + framerate: New framerate (1-240) + layout_margin: New layout margin in pixels + layout_spacing: New layout spacing in pixels + screen: New screen index (1=primary, 2=secondary, etc.) + """ + # Update local state and build message with only changed settings + settings_msg = {"type": "update_settings"} + + if framerate is not None: + self._framerate = max(1, min(240, framerate)) + settings_msg["framerate"] = self._framerate + if layout_margin is not None: + self._layout_margin = layout_margin + settings_msg["layout_margin"] = self._layout_margin + if layout_spacing is not None: + self._layout_spacing = layout_spacing + settings_msg["layout_spacing"] = self._layout_spacing + if screen is not None: + self._screen = max(1, screen) + settings_msg["screen"] = self._screen + + # Send to overlay if running - only include changed settings + if self._command_queue and self._overlay_thread and self._overlay_thread.is_alive(): + self._command_queue.put(settings_msg) + def _run_server(self): """Run the server in its own thread with its own event loop.""" try: diff --git a/hud_server/tests/run_tests.py b/hud_server/tests/run_tests.py index 8f8f50db2..0c97f218e 100644 --- a/hud_server/tests/run_tests.py +++ b/hud_server/tests/run_tests.py @@ -9,6 +9,7 @@ python -m hud_server.tests.run_tests --persistent # Run persistent info tests python -m hud_server.tests.run_tests --chat # Run chat tests python -m hud_server.tests.run_tests --unicode # Run Unicode/emoji stress tests + python -m hud_server.tests.run_tests --settings # Run settings update tests python -m hud_server.tests.run_tests --layout # Run layout manager unit tests (no server needed) python -m hud_server.tests.run_tests --layout-visual # Run visual layout tests with actual HUD windows python -m hud_server.tests.run_tests --snake # Run the Snake game (interactive, 2 min) @@ -98,6 +99,9 @@ async def run_test_suite(test_name: str): elif test_name == "chat": from hud_server.tests.test_chat import run_all_chat_tests await run_all_chat_tests(session) + elif test_name == "settings": + from hud_server.tests.test_settings import run_all_settings_tests + await run_all_settings_tests(session) elif test_name == "unicode": from hud_server.tests.test_unicode_stress import run_all_unicode_stress_tests await run_all_unicode_stress_tests(session) @@ -107,6 +111,7 @@ async def run_test_suite(test_name: str): from hud_server.tests.test_persistent import run_all_persistent_tests from hud_server.tests.test_chat import run_all_chat_tests from hud_server.tests.test_unicode_stress import run_all_unicode_stress_tests + from hud_server.tests.test_settings import run_all_settings_tests await run_all_message_tests(session) await asyncio.sleep(2) @@ -117,6 +122,8 @@ async def run_test_suite(test_name: str): await run_all_chat_tests(session) await asyncio.sleep(2) await run_all_unicode_stress_tests(session) + await asyncio.sleep(2) + await run_all_settings_tests(session) print(f"\n{'=' * 60}") print(f"{test_name.capitalize()} tests complete!") @@ -139,7 +146,7 @@ def main(): # Snake game - interactive fun test from hud_server.tests.test_snake import run_snake_test asyncio.run(run_snake_test()) - elif arg in ["messages", "progress", "persistent", "chat", "unicode", "all"]: + elif arg in ["messages", "progress", "persistent", "chat", "unicode", "settings", "all"]: asyncio.run(run_test_suite(arg)) elif arg == "help": print(__doc__) diff --git a/hud_server/tests/test_session.py b/hud_server/tests/test_session.py index eaa2ad752..ca5c4aa15 100644 --- a/hud_server/tests/test_session.py +++ b/hud_server/tests/test_session.py @@ -5,6 +5,7 @@ to the HUD server and overlay. """ +import httpx from typing import Optional, Any from hud_server.http_client import HudHttpClient @@ -429,3 +430,45 @@ async def health_check(self) -> bool: return False return await self._client.health_check() + async def update_settings(self, framerate: Optional[int] = None, + layout_margin: Optional[int] = None, + layout_spacing: Optional[int] = None, + screen: Optional[int] = None): + """Update HUD server settings dynamically. + + Args: + framerate: New framerate (1-240) + layout_margin: New layout margin in pixels + layout_spacing: New layout spacing in pixels + screen: New screen index (1=primary, etc.) + """ + if not self._client: + print(f"[{self.name}] Cannot update settings: not connected") + return + + # Build query parameters + params = {} + if framerate is not None: + params["framerate"] = framerate + if layout_margin is not None: + params["layout_margin"] = layout_margin + if layout_spacing is not None: + params["layout_spacing"] = layout_spacing + if screen is not None: + params["screen"] = screen + + print(f"[{self.name}] Updating settings: {params}") + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + f"{self.base_url}/settings/update", + params=params + ) + if response.status_code == 200: + print(f"[{self.name}] Settings updated successfully") + else: + print(f"[{self.name}] Settings update failed: {response.status_code}") + except Exception as e: + print(f"[{self.name}] Settings update error: {e}") + diff --git a/templates/configs/settings.yaml b/templates/configs/settings.yaml index aa07dec81..ea03cf911 100644 --- a/templates/configs/settings.yaml +++ b/templates/configs/settings.yaml @@ -49,4 +49,6 @@ hud_server: host: "127.0.0.1" port: 7862 framerate: 60 + layout_margin: 20 + layout_spacing: 15 screen: 1 diff --git a/wingman_core.py b/wingman_core.py index 14a26ead5..6b0cf1698 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -484,6 +484,21 @@ async def _on_hud_server_settings_changed(self, hud_settings): await self._start_hud_server_if_enabled() elif not should_run and is_running: await self._stop_hud_server() + elif should_run and is_running: + # Server already running - update settings without restart + try: + self._hud_server.update_settings( + framerate=getattr(hud_settings, 'framerate', 60), + layout_margin=getattr(hud_settings, 'layout_margin', 20), + layout_spacing=getattr(hud_settings, 'layout_spacing', 15), + screen=getattr(hud_settings, 'screen', 1), + ) + except Exception as e: + self.printr.print( + f"Error updating HUD server settings: {e}", + color=LogType.ERROR, + server_only=True + ) async def _stop_hud_server(self): """Stop the HUD server if running.""" From b95b4e19458b981064daf4713e143fb84c2ba6c2 Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 17 Feb 2026 18:15:30 +0100 Subject: [PATCH 24/27] Refactor HUD server settings and validation logic for improved configuration management --- api/interface.py | 16 +- hud_server/constants.py | 2 +- hud_server/validation.py | 89 ++++++++++ services/migrations/migration_200_to_210.py | 5 + skills/hud/default_config.yaml | 74 ++++++-- skills/hud/main.py | 178 ++++++++++---------- templates/configs/settings.yaml | 2 +- wingman_core.py | 45 +++-- 8 files changed, 277 insertions(+), 134 deletions(-) create mode 100644 hud_server/validation.py diff --git a/api/interface.py b/api/interface.py index 376ff9453..b8dd1fced 100644 --- a/api/interface.py +++ b/api/interface.py @@ -1059,25 +1059,25 @@ class DuplicateWingmanResult(BaseModel): class HudServerSettings(BaseModel): """HUD Server settings for global configuration.""" - enabled: bool = True + enabled: bool """Whether the HUD server should auto-start with Wingman AI Core.""" - host: str = "127.0.0.1" + host: str """The interface to listen on. Use '127.0.0.1' for local only, '0.0.0.0' for LAN access.""" - port: int = 7862 + port: int """The port to listen on.""" - framerate: int = Field(default=60, ge=1) + framerate: int """HUD overlay rendering framerate. Higher = smoother but more CPU. Minimum 1.""" - layout_margin: int = Field(default=20, ge=0, le=200) + layout_margin: int """Margin from screen edges in pixels for HUD elements. Between 0 and 200.""" - layout_spacing: int = Field(default=15, ge=0, le=100) + layout_spacing: int """Spacing between stacked HUD windows in pixels. Between 0 and 100.""" - screen: int = Field(default=1, ge=1, le=10) + screen: int """Which screen/monitor to render the HUD on (1 = primary, 2 = secondary, etc.).""" @@ -1087,7 +1087,7 @@ class SettingsConfig(BaseModel): wingman_pro: WingmanProSettings xvasynth: XVASynthSettings pocket_tts: PocketTTSSettings - hud_server: Optional[HudServerSettings] = None + hud_server: HudServerSettings debug_mode: bool streamer_mode: bool cancel_tts_key: Optional[str] = None diff --git a/hud_server/constants.py b/hud_server/constants.py index 1f5026af1..8e96a5515 100644 --- a/hud_server/constants.py +++ b/hud_server/constants.py @@ -138,7 +138,7 @@ HTTP_INTERNAL_ERROR = 500 # Log Messages -LOG_SERVER_STARTED = "[HUD] Server started on http://{}:{}" +LOG_SERVER_STARTED = "[HUD] Server started on http://{}:{}/docs" LOG_SERVER_STOPPED = "[HUD] Server stopped" LOG_SERVER_STARTUP_TIMEOUT = "[HUD] Failed to start within {}s timeout" LOG_SERVER_ALREADY_RUNNING = "[HUD] Server already running" diff --git a/hud_server/validation.py b/hud_server/validation.py new file mode 100644 index 000000000..0ee4e309a --- /dev/null +++ b/hud_server/validation.py @@ -0,0 +1,89 @@ +"""Validation utilities for HUD server settings.""" + +import ipaddress + + +def validate_hud_settings(hud_settings) -> dict: + """Validate HUD server settings and return dict with defaults for invalid values. + + Args: + hud_settings: Object with hud_server settings attributes + + Returns: + Dict with validated/fixed values: host, port, framerate, layout_margin, layout_spacing, screen + """ + defaults = { + 'host': '127.0.0.1', + 'port': 7862, + 'framerate': 60, + 'layout_margin': 20, + 'layout_spacing': 15, + 'screen': 1, + } + + host = getattr(hud_settings, 'host', defaults['host']) + port = getattr(hud_settings, 'port', defaults['port']) + framerate = getattr(hud_settings, 'framerate', defaults['framerate']) + layout_margin = getattr(hud_settings, 'layout_margin', defaults['layout_margin']) + layout_spacing = getattr(hud_settings, 'layout_spacing', defaults['layout_spacing']) + screen = getattr(hud_settings, 'screen', defaults['screen']) + + invalid = {} + + # Validate host + try: + ipaddress.IPv4Address(host) + except (ipaddress.AddressValueError, ValueError): + invalid['host'] = (host, defaults['host']) + host = defaults['host'] + + # Validate port + if not isinstance(port, int) or port < 1 or port > 65535: + invalid['port'] = (port, defaults['port']) + port = defaults['port'] + + # Validate framerate + if not isinstance(framerate, int) or framerate < 1: + invalid['framerate'] = (framerate, defaults['framerate']) + framerate = defaults['framerate'] + + # Validate layout_margin + if not isinstance(layout_margin, int) or layout_margin < 0: + invalid['layout_margin'] = (layout_margin, defaults['layout_margin']) + layout_margin = defaults['layout_margin'] + + # Validate layout_spacing + if not isinstance(layout_spacing, int) or layout_spacing < 0: + invalid['layout_spacing'] = (layout_spacing, defaults['layout_spacing']) + layout_spacing = defaults['layout_spacing'] + + # Validate screen + if not isinstance(screen, int) or screen < 1: + invalid['screen'] = (screen, defaults['screen']) + screen = defaults['screen'] + + return { + 'host': host, + 'port': port, + 'framerate': framerate, + 'layout_margin': layout_margin, + 'layout_spacing': layout_spacing, + 'screen': screen, + '_invalid': invalid, # Track invalid values for logging + } + + +def get_invalid_summary(invalid: dict) -> str: + """Generate a formatted summary of invalid settings changes. + + Args: + invalid: Dict of {field: (old_value, new_value)} from validate_hud_settings + + Returns: + Formatted string for logging + """ + if not invalid: + return "" + lines = ["Invalid settings detected, using defaults:"] + lines.extend(f" - {k}: {v[0]!r} → {v[1]!r}" for k, v in invalid.items()) + return "\n".join(lines) diff --git a/services/migrations/migration_200_to_210.py b/services/migrations/migration_200_to_210.py index 14164a0ea..2d3049f84 100644 --- a/services/migrations/migration_200_to_210.py +++ b/services/migrations/migration_200_to_210.py @@ -22,6 +22,11 @@ def migrate_settings(self, old: dict, new: dict) -> dict: old["pocket_tts"] = new["pocket_tts"] self.log("- added new setting: pocket_tts") + # Add HUD Server Settings + if "hud_server" not in old and "hud_server" in new: + old["hud_server"] = new["hud_server"] + self.log("- added new setting: hud_server") + return old def migrate_defaults(self, old: dict, new: dict) -> dict: diff --git a/skills/hud/default_config.yaml b/skills/hud/default_config.yaml index cd953135d..48ed6cda8 100644 --- a/skills/hud/default_config.yaml +++ b/skills/hud/default_config.yaml @@ -13,13 +13,6 @@ hint: en: Please make sure the HUD server in the global settings is enabled. de: Bitte stelle sicher, dass der HUD-Server in den globalen Einstellungen aktiviert ist. custom_properties: - - id: user_color - name: User Color - hint: Color for user name in messages (hex format). - property_type: string - value: "#4cd964" - required: false - - id: accent_color name: Accent Color hint: Accent color for assistant messages and highlights (hex format). @@ -43,10 +36,29 @@ custom_properties: - id: chat_anchor name: Chat Window Position - hint: Screen position for the chat window. Options are top_left, top_center, top_right, left_center, center, right_center, bottom_left, bottom_center, bottom_right. - property_type: string + hint: Screen position for the chat window. + property_type: single_select value: "top_left" required: false + options: + - label: "Top Left" + value: "top_left" + - label: "Top Center" + value: "top_center" + - label: "Top Right" + value: "top_right" + - label: "Left Center" + value: "left_center" + - label: "Center" + value: "center" + - label: "Right Center" + value: "right_center" + - label: "Bottom Left" + value: "bottom_left" + - label: "Bottom Center" + value: "bottom_center" + - label: "Bottom Right" + value: "bottom_right" - id: chat_priority name: Chat Window Priority @@ -71,10 +83,29 @@ custom_properties: - id: persistent_anchor name: Info Panel Position - hint: Screen position for persistent info panels. Options are top_left, top_center, top_right, left_center, center, right_center, bottom_left, bottom_center, bottom_right. - property_type: string + hint: Screen position for persistent info panels. + property_type: single_select value: "top_left" required: false + options: + - label: "Top Left" + value: "top_left" + - label: "Top Center" + value: "top_center" + - label: "Top Right" + value: "top_right" + - label: "Left Center" + value: "left_center" + - label: "Center" + value: "center" + - label: "Right Center" + value: "right_center" + - label: "Bottom Left" + value: "bottom_left" + - label: "Bottom Center" + value: "bottom_center" + - label: "Bottom Right" + value: "bottom_right" - id: persistent_priority name: Info Panel Priority @@ -97,13 +128,6 @@ custom_properties: value: 600 required: false - - id: opacity - name: Opacity - hint: Transparency of HUD elements (0.0 to 1.0). - property_type: number - value: 0.85 - required: false - - id: border_radius name: Border Radius hint: Corner roundness of HUD elements in pixels. @@ -139,6 +163,20 @@ custom_properties: value: 5 required: false + - id: opacity + name: Opacity + hint: Transparency of HUD elements (0 = fully transparent, 100 = no transparency). + property_type: slider + options: + - label: "min" + value: 0 + - label: "max" + value: 100 + - label: "step" + value: 1 + value: 85 + required: false + - id: typewriter_effect name: Typewriter Effect hint: Animate text appearing character by character. diff --git a/skills/hud/main.py b/skills/hud/main.py index c19be7d73..6faa3d0f8 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -28,6 +28,7 @@ from skills.skill_base import Skill, tool from hud_server.http_client import HudHttpClient from hud_server.types import Anchor, HudColor, FontFamily, LayoutMode, MessageProps, PersistentProps, WindowType +from hud_server.validation import validate_hud_settings if TYPE_CHECKING: from wingmen.open_ai_wingman import OpenAiWingman @@ -56,7 +57,6 @@ def __init__( # State self.active = False self.stop_event = threading.Event() - self.current_display_text = "" self.expecting_audio = False self.audio_expect_start_time = 0.0 @@ -76,7 +76,25 @@ def __init__( self._main_loop: Optional[asyncio.AbstractEventLoop] = None # Group name for HUD (just wingman identifier, element passed separately) - self._group_name = None + self._group_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) + + # ─────────────────────────────── Helpers ─────────────────────────────── # + + @staticmethod + def _is_valid_hex_color(color: str) -> bool: + """Validate a hex color string (#RGB, #RRGGBB, or #RRGGBBAA).""" + if not isinstance(color, str): + return False + if not color.startswith('#'): + return False + hex_part = color[1:] + if len(hex_part) not in (3, 6, 8): + return False + try: + int(hex_part, 16) + return True + except ValueError: + return False # ─────────────────────────────── Configuration ─────────────────────────────── # @@ -96,35 +114,9 @@ async def validate(self) -> list[WingmanInitializationError]: ) ) - # Color validation helper - supports #RGB, #RRGGBB, or #RRGGBBAA formats - def is_valid_hex_color(color: str) -> bool: - if not isinstance(color, str): - return False - if not color.startswith('#'): - return False - hex_part = color[1:] - if len(hex_part) not in (3, 6, 8): # 3, 6, or 8 hex chars (with alpha) - return False - try: - int(hex_part, 16) - return True - except ValueError: - return False - - # Validate user_color - user_color = self.retrieve_custom_property_value("user_color", errors) - if not is_valid_hex_color(user_color): - errors.append( - WingmanInitializationError( - wingman_name=self.wingman.name, - message=f"Invalid user_color: '{user_color}'. Must be a valid hex color (e.g., #ffffff).", - error_type=WingmanInitializationErrorType.INVALID_CONFIG - ) - ) - # Validate accent_color accent_color = self.retrieve_custom_property_value("accent_color", errors) - if not is_valid_hex_color(accent_color): + if not self._is_valid_hex_color(accent_color): errors.append( WingmanInitializationError( wingman_name=self.wingman.name, @@ -135,7 +127,7 @@ def is_valid_hex_color(color: str) -> bool: # Validate bg_color bg_color = self.retrieve_custom_property_value("bg_color", errors) - if not is_valid_hex_color(bg_color): + if not self._is_valid_hex_color(bg_color): errors.append( WingmanInitializationError( wingman_name=self.wingman.name, @@ -146,7 +138,7 @@ def is_valid_hex_color(color: str) -> bool: # Validate text_color text_color = self.retrieve_custom_property_value("text_color", errors) - if not is_valid_hex_color(text_color): + if not self._is_valid_hex_color(text_color): errors.append( WingmanInitializationError( wingman_name=self.wingman.name, @@ -243,13 +235,13 @@ def is_valid_hex_color(color: str) -> bool: ) ) - # Validate opacity + # Validate opacity (0-100 range from slider) opacity = self.retrieve_custom_property_value("opacity", errors) - if not isinstance(opacity, (int, float)) or not (0.0 <= opacity <= 1.0): + if not isinstance(opacity, (int, float)) or not (0 <= opacity <= 100): errors.append( WingmanInitializationError( wingman_name=self.wingman.name, - message=f"Invalid opacity: '{opacity}'. Must be a number between 0.0 and 1.0.", + message=f"Invalid opacity: '{opacity}'. Must be a number between 0 and 100.", error_type=WingmanInitializationErrorType.INVALID_CONFIG ) ) @@ -371,7 +363,7 @@ def _get_hud_props(self) -> MessageProps: bg_color=str(self._get_prop("bg_color", HudColor.BG_DARK)), text_color=str(self._get_prop("text_color", HudColor.TEXT_PRIMARY)), accent_color=str(self._get_prop("accent_color", HudColor.ACCENT_BLUE)), - opacity=float(self._get_prop("opacity", 0.85)), + opacity=float(self._get_prop("opacity", 85)) / 100.0, border_radius=int(self._get_prop("border_radius", 12)), font_size=int(self._get_prop("font_size", 16)), content_padding=int(self._get_prop("content_padding", 16)), @@ -390,7 +382,7 @@ def _get_persistent_props(self) -> PersistentProps: bg_color=str(self._get_prop("bg_color", HudColor.BG_DARK)), text_color=str(self._get_prop("text_color", HudColor.TEXT_PRIMARY)), accent_color=str(self._get_prop("accent_color", HudColor.ACCENT_BLUE)), - opacity=float(self._get_prop("opacity", 0.85)), + opacity=float(self._get_prop("opacity", 85)) / 100.0, border_radius=int(self._get_prop("border_radius", 12)), font_size=int(self._get_prop("font_size", 16)), content_padding=int(self._get_prop("content_padding", 16)), @@ -450,7 +442,8 @@ async def _ensure_connected(self) -> bool: # Create client if it doesn't exist (e.g., after skill reactivation or loop change) if not self._client: - base_url = f"http://{hud_settings.host}:{hud_settings.port}" + validated = validate_hud_settings(hud_settings) + base_url = f"http://{validated['host']}:{validated['port']}" self._client = HudHttpClient(base_url=base_url) # Store current loop reference @@ -459,12 +452,6 @@ async def _ensure_connected(self) -> bool: except RuntimeError: pass - # Setup group names if not done - if self._group_name == "messages": - sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) - self._group_name = f"messages_{sanitized_name}" - self._group_name = f"persistent_{sanitized_name}" - if not self._client.connected: # Try to connect/reconnect try: @@ -518,7 +505,8 @@ async def prepare(self) -> None: return # Connect to HUD server - base_url = f"http://{hud_settings.host}:{hud_settings.port}" + validated = validate_hud_settings(hud_settings) + base_url = f"http://{validated['host']}:{validated['port']}" self._client = HudHttpClient(base_url=base_url) # store the loop where the client was created @@ -527,11 +515,6 @@ async def prepare(self) -> None: except RuntimeError: pass - # Setup groups with unique names per wingman - sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '_', self.wingman.name) - self._group_name = f"messages_{sanitized_name}" - self._group_name = f"persistent_{sanitized_name}" - try: if await self._client.connect(timeout=3.0): await printr.print_async( @@ -625,8 +608,11 @@ async def unload(self) -> None: # Save state self._save_persistent_items() self.hud_clear_all(False) - await self._client.delete_group(self._group_name, WindowType.MESSAGE) - await self._client.delete_group(self._group_name, WindowType.PERSISTENT) + + # Delete groups if client exists + if self._client: + await self._client.delete_group(self._group_name, WindowType.MESSAGE) + await self._client.delete_group(self._group_name, WindowType.PERSISTENT) # Disconnect client if self._client: @@ -703,7 +689,7 @@ async def _show_message( title: str, message: str, color: str, - tools: list = None, + tools: Optional[list] = None, duration: float = 180.0 ): """Show a message on the HUD.""" @@ -868,11 +854,10 @@ async def on_add_user_message(self, message: str) -> None: if not self._get_prop("show_chat_messages", True): return - self.current_display_text = message - user_color = str(self._get_prop("user_color", "#4cd964")) - await self._show_message("USER", message, user_color) - + # Use accent color for user messages (user_color was unused) accent_color = str(self._get_prop("accent_color", "#00aaff")) + await self._show_message("USER", message, accent_color) + await self._show_loader(True, accent_color) async def on_add_assistant_message(self, message: str, tool_calls: list) -> None: @@ -933,7 +918,6 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None }) if message: - self.current_display_text = message self.expecting_audio = True self.audio_expect_start_time = time.time() await self._show_message( @@ -950,7 +934,7 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None # ─────────────────────────────── Tool Methods ─────────────────────────────── # @tool() - def hud_add_info( + async def hud_add_info( self, title: str, description_markdown: str, @@ -964,6 +948,9 @@ def hud_add_info( :param description_markdown: Content to display (Markdown supported). :param duration: Auto-remove after this many seconds. If not set, stays until removed. """ + if not await self._ensure_connected(): + return "HUD server is not available." + valid_duration = duration if duration and duration > 0 else None self._persistent_items[title] = { @@ -973,8 +960,7 @@ def hud_add_info( 'expiry': time.time() + valid_duration if valid_duration else None } - if self._client: - self._send_command_sync( + self._send_command_sync( self._client.add_item( group_name=self._group_name, element=WindowType.PERSISTENT, @@ -988,24 +974,26 @@ def hud_add_info( return f"Added/Updated info panel: {title}" @tool() - def hud_remove_info(self, title: str) -> str: + async def hud_remove_info(self, title: str) -> str: """ Remove a persistent information panel from the HUD. :param title: The title of the info panel to remove. """ + if not await self._ensure_connected(): + return "HUD server is not available." + self._persistent_items.pop(title, None) - if self._client: - self._send_command_sync( - self._client.remove_item(group_name=self._group_name, element=WindowType.PERSISTENT, title=title) - ) + self._send_command_sync( + self._client.remove_item(group_name=self._group_name, element=WindowType.PERSISTENT, title=title) + ) self._save_persistent_items() return f"Removed info panel: {title}" @tool() - def hud_list_info(self) -> str: + async def hud_list_info(self) -> str: """ List all currently visible information panels on the HUD. Returns JSON with all active panels. @@ -1038,10 +1026,13 @@ def hud_list_info(self) -> str: return json.dumps(active_items, indent=2) @tool() - def hud_clear_all(self, save: bool = True) -> str: + async def hud_clear_all(self, save: bool = True) -> str: """ Remove all information panels and progress bars from the HUD. """ + if not await self._ensure_connected(): + return "HUD server is not available." + # Create a copy of keys to iterate because we will modify the dict items_to_remove = list(self._persistent_items.keys()) cleared_count = len(items_to_remove) @@ -1059,7 +1050,7 @@ def hud_clear_all(self, save: bool = True) -> str: return f"Cleared {cleared_count} item(s) from HUD." @tool() - def hud_show_progress( + async def hud_show_progress( self, title: str, current: float, @@ -1078,6 +1069,9 @@ def hud_show_progress( :param auto_close: If True, removes the bar when reaching 100%. :param color: Optional color for the progress bar (hex color like #00ff00). """ + if not await self._ensure_connected(): + return "HUD server is not available." + if maximum <= 0: maximum = 100 @@ -1111,7 +1105,7 @@ def hud_show_progress( return f"Progress '{title}': {percentage:.1f}%" @tool() - def hud_show_timer( + async def hud_show_timer( self, title: str, duration_seconds: float, @@ -1128,6 +1122,9 @@ def hud_show_timer( :param auto_close: If True (default), removes the timer after completion. :param color: Optional color for the timer bar (hex color like #00ff00). """ + if not await self._ensure_connected(): + return "HUD server is not available." + if duration_seconds <= 0: duration_seconds = 1 @@ -1161,7 +1158,7 @@ def hud_show_timer( return f"Timer '{title}' started: {duration_seconds:.1f}s" @tool() - def hud_update_progress( + async def hud_update_progress( self, title: str, current: float, @@ -1176,6 +1173,9 @@ def hud_update_progress( :param maximum: Optional new maximum value. :param description_markdown: Optional new description. """ + if not await self._ensure_connected(): + return "HUD server is not available." + if title not in self._persistent_items: return f"Progress '{title}' not found. Use hud_show_progress first." @@ -1214,7 +1214,7 @@ def hud_update_progress( return f"Updated progress '{title}': {percentage:.1f}%" @tool() - def hud_update_info( + async def hud_update_info( self, title: str, description_markdown: str, @@ -1227,6 +1227,9 @@ def hud_update_info( :param description_markdown: The new content (Markdown supported). :param duration: Optional new auto-remove timer in seconds. """ + if not await self._ensure_connected(): + return "HUD server is not available." + if title not in self._persistent_items: return f"Info '{title}' not found. Use hud_add_info first." @@ -1258,7 +1261,7 @@ def hud_update_info( return f"Updated info panel: {title}" @tool() - def hud_hide(self) -> str: + async def hud_hide(self) -> str: """ Hide the HUD elements (message window and persistent info panel). @@ -1266,29 +1269,26 @@ def hud_hide(self) -> str: and perform all logic (timers, auto-hide, item updates) in the background. Use hud_show to display them again. """ - print(f"[HUD SKILL] hud_hide called, _group_name={self._group_name}") - if self._client: - print(f"[HUD SKILL] Calling hide_element for PERSISTENT") - self._send_command_sync( - self._client.hide_element( - group_name=self._group_name, - element=WindowType.PERSISTENT - ) + if not await self._ensure_connected(): + return "HUD server is not available." + + self._send_command_sync( + self._client.hide_element( + group_name=self._group_name, + element=WindowType.PERSISTENT ) - print(f"[HUD SKILL] Calling hide_element for MESSAGE") - self._send_command_sync( - self._client.hide_element( - group_name=self._group_name, - element=WindowType.MESSAGE - ) + ) + self._send_command_sync( + self._client.hide_element( + group_name=self._group_name, + element=WindowType.MESSAGE ) - else: - print(f"[HUD SKILL] No client!") + ) return "HUD is now hidden." @tool() - def hud_show(self) -> str: + async def hud_show(self) -> str: """ Show the HUD elements (message window and persistent info panel). diff --git a/templates/configs/settings.yaml b/templates/configs/settings.yaml index ea03cf911..72c018f6a 100644 --- a/templates/configs/settings.yaml +++ b/templates/configs/settings.yaml @@ -45,7 +45,7 @@ pocket_tts: enable: true custom_model_path: "" hud_server: - enabled: true + enabled: false host: "127.0.0.1" port: 7862 framerate: 60 diff --git a/wingman_core.py b/wingman_core.py index 6b0cf1698..87cbe805d 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -60,6 +60,7 @@ from services.tower import Tower from services.websocket_user import WebSocketUser from hud_server.server import HudServer +from hud_server.validation import validate_hud_settings, get_invalid_summary class WingmanCore(WebSocketUser): @@ -433,6 +434,19 @@ async def startup(self): # Start HUD Server if enabled await self._start_hud_server_if_enabled() + def _get_validated_hud_settings(self, hud_settings, log_invalid: bool = True) -> dict: + """Validate HUD settings and return dict with defaults for invalid values.""" + result = validate_hud_settings(hud_settings) + invalid = result.pop('_invalid', {}) + + if log_invalid and invalid: + self.printr.print( + "[HUD] " + get_invalid_summary(invalid), + color=LogType.INFO + ) + + return result + async def _start_hud_server_if_enabled(self): """Start the HUD server if enabled in settings.""" hud_settings = getattr(self.settings_service.settings, 'hud_server', None) @@ -441,38 +455,35 @@ async def _start_hud_server_if_enabled(self): if platform.system() != "Windows": self.printr.print( - "HUD Server is only supported on Windows.", + "[HUD] Server is only supported on Windows.", color=LogType.WARNING, server_only=True, ) return try: + validated = self._get_validated_hud_settings(hud_settings) self._hud_server = HudServer() - if not self._hud_server.start( - host=hud_settings.host, - port=hud_settings.port, - framerate=getattr(hud_settings, 'framerate', 60), - layout_margin=getattr(hud_settings, 'layout_margin', 20), - layout_spacing=getattr(hud_settings, 'layout_spacing', 15), - screen=getattr(hud_settings, 'screen', 1), - ): + if not self._hud_server.start(**validated): self.printr.print( - f"HUD Server failed to start on port {hud_settings.port}", + f"[HUD] Server failed to start on port {validated['port']}", color=LogType.ERROR, - server_only=True, + server_only=False, ) self._hud_server = None except Exception as e: self.printr.print( - f"HUD Server error: {e}", + f"[HUD] Server error: {e}", color=LogType.ERROR, - server_only=True, + server_only=False, ) self._hud_server = None async def _on_hud_server_settings_changed(self, hud_settings): """Handle HUD server settings changes — start or stop as needed.""" + # Validate settings and apply defaults for invalid values + validated = self._get_validated_hud_settings(hud_settings) + should_run = ( hud_settings is not None and hud_settings.enabled @@ -488,10 +499,10 @@ async def _on_hud_server_settings_changed(self, hud_settings): # Server already running - update settings without restart try: self._hud_server.update_settings( - framerate=getattr(hud_settings, 'framerate', 60), - layout_margin=getattr(hud_settings, 'layout_margin', 20), - layout_spacing=getattr(hud_settings, 'layout_spacing', 15), - screen=getattr(hud_settings, 'screen', 1), + framerate=validated['framerate'], + layout_margin=validated['layout_margin'], + layout_spacing=validated['layout_spacing'], + screen=validated['screen'], ) except Exception as e: self.printr.print( From 1dae969aff9d3dce9cbd1c075f98d9814d5309fe Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 17 Feb 2026 19:05:07 +0100 Subject: [PATCH 25/27] Update README to reflect changes in HUD group creation and dynamic settings update functionality for hud server --- hud_server/README.md | 75 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/hud_server/README.md b/hud_server/README.md index cc2f0add9..67625617c 100644 --- a/hud_server/README.md +++ b/hud_server/README.md @@ -69,25 +69,38 @@ server.start(host="127.0.0.1", port=7862, framerate=60) ```python from hud_server.http_client import HudHttpClient +from hud_server.types import WindowType async with HudHttpClient() as client: - # Create a HUD group - await client.create_group("my_wingman", { - "x": 20, "y": 20, "width": 400, - "bg_color": "#1e212b", "accent_color": "#00aaff" + # Create a message HUD group (for temporary messages) + await client.create_group("my_wingman", WindowType.MESSAGE, { + "anchor": "top_left", + "priority": 20, + "width": 400, + "bg_color": "#1e212b", + "accent_color": "#00aaff" }) - + + # Create a persistent HUD group (for info panels) + await client.create_group("my_wingman", WindowType.PERSISTENT, { + "anchor": "bottom_left", + "priority": 10, + "width": 400 + }) + # Show a message await client.show_message( group_name="my_wingman", + element=WindowType.MESSAGE, title="Hello!", content="This is a **Markdown** message with `code`.", duration=10.0 ) - - # Add a progress bar + + # Add a progress bar (to persistent group) await client.show_progress( group_name="my_wingman", + element=WindowType.PERSISTENT, title="Loading", current=50, maximum=100 @@ -98,11 +111,13 @@ async with HudHttpClient() as client: ```python from hud_server.http_client import HudHttpClientSync +from hud_server.types import WindowType with HudHttpClientSync() as client: - client.create_group("my_wingman") - client.show_message("my_wingman", "Title", "Content") - client.show_progress("my_wingman", "Loading", 50, 100) + client.create_group("my_wingman", WindowType.MESSAGE) + client.create_group("my_wingman", WindowType.PERSISTENT) + client.show_message("my_wingman", WindowType.MESSAGE, "Title", "Content") + client.show_progress("my_wingman", WindowType.PERSISTENT, "Loading", 50, 100) ``` ## API Endpoints @@ -115,10 +130,24 @@ with HudHttpClientSync() as client: ### Groups - `POST /groups` - Create or update a HUD group -- `PATCH /groups/{group_name}` - Update group properties -- `DELETE /groups/{group_name}` - Delete a group +- `PUT /groups/{group_name}/{element}` - Create or update a specific element type +- `PATCH /groups/{group_name}/{element}` - Update group properties +- `DELETE /groups/{group_name}/{element}` - Delete a group element - `GET /groups` - List all groups +> **Note:** Groups require an `element` parameter to specify the window type (`message`, `persistent`, or `chat`). + +### Element Visibility + +- `POST /element/show` - Show a hidden element (message, persistent, or chat) +- `POST /element/hide` - Hide an element without removing it + +This allows you to temporarily hide HUD elements while preserving their state and continuing to receive updates in the background. + +### Settings + +- `POST /settings/update` - Update server settings at runtime (framerate, layout_margin, layout_spacing, screen) + ### Messages - `POST /message` - Show a message in a group @@ -192,10 +221,24 @@ settings = HudServerSettings( port=7862, # Default port framerate=60, # Overlay FPS (1-240) layout_margin=20, # Screen edge margin - layout_spacing=15 # Window spacing + layout_spacing=15, # Window spacing + screen=1 # Monitor index (1=primary, 2+=additional monitors) ) ``` +### Dynamic Settings Update + +The server supports runtime configuration changes without restart via the `/settings/update` endpoint: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `framerate` | int | Overlay FPS (1-240) | +| `layout_margin` | int | Screen edge margin in pixels | +| `layout_spacing` | int | Window spacing in pixels | +| `screen` | int | Monitor index (1=primary, 2+=additional monitors) | + +**Note:** Screen changes take effect on next overlay render cycle. The server must be restarted for host/port changes to take effect. + ### Group Properties ```python @@ -547,3 +590,9 @@ server.start(port=7863) 1. Verify server is running: `http://127.0.0.1:7862/health` 2. Check firewall settings 3. Use correct host/port in client + +### Multi-monitor Issues + +1. Verify the correct screen index: Screen 1 is primary, Screen 2 is secondary, etc. +2. Check Windows display settings to confirm monitor order +3. Try restarting the HUD server after changing screen settings From 9eac5aadd74a40e10d2eb82bfefab6e143e69e6c Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 17 Feb 2026 19:35:28 +0100 Subject: [PATCH 26/27] Refactor tool response update method docstring for clarity --- wingmen/open_ai_wingman.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wingmen/open_ai_wingman.py b/wingmen/open_ai_wingman.py index 929b5e291..96c2f801f 100644 --- a/wingmen/open_ai_wingman.py +++ b/wingmen/open_ai_wingman.py @@ -1330,7 +1330,7 @@ def _add_tool_response(self, tool_call, response: str, completed: bool = True): self.pending_tool_calls.append(tool_call.id) async def _update_tool_response(self, tool_call_id, response) -> bool: - """Updates a tool response in the conversation history. This also moves the message to the end of the history if all tool responses are given. + """Updates a tool response in the conversation history. Args: tool_call_id (str): The identifier of the tool call to update the response for. From 78129af66c627ed279607bf13a09468443b9690c Mon Sep 17 00:00:00 2001 From: Jan Winkler Date: Tue, 17 Feb 2026 21:30:46 +0100 Subject: [PATCH 27/27] Enhance error handling during HUD item clearance and SSE connection reconnection attempts --- services/mcp_client.py | 50 ++++++++++++++++++++++++++++-------------- skills/hud/main.py | 24 ++++++++++++++++---- wingman_core.py | 14 +++++++++++- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/services/mcp_client.py b/services/mcp_client.py index 43587bfa3..735cf132c 100644 --- a/services/mcp_client.py +++ b/services/mcp_client.py @@ -692,25 +692,43 @@ async def _call_tool_sse( # Check if SSE connection is still alive, attempt reconnect if not if not connection.sse_connection_alive or not connection.sse_loop or not connection.session: - printr.print( - f"SSE connection to {connection.config.display_name} was closed, attempting reconnect...", - color=LogType.WARNING, - server_only=True, - ) - # Clean up the old connection - await self._cleanup_connection(connection) - # Reconnect - await self._connect_sse(connection, connection.merged_headers) + # Retry with exponential backoff + max_retries = 3 + base_delay = 0.5 + last_error = None + + for attempt in range(max_retries): + if attempt > 0: + delay = base_delay * (2 ** (attempt - 1)) + printr.print( + f"SSE reconnect to {connection.config.display_name} failed, retrying in {delay}s (attempt {attempt + 1}/{max_retries})...", + color=LogType.WARNING, + server_only=True, + ) + await asyncio.sleep(delay) - if not connection.sse_connection_alive or not connection.sse_loop or not connection.session: + # Clean up the old connection + await self._cleanup_connection(connection) + # Attempt reconnect + try: + await self._connect_sse(connection, connection.merged_headers) + except Exception as e: + last_error = e + continue + + # Check if reconnect succeeded + if connection.sse_connection_alive and connection.sse_loop and connection.session: + printr.print( + f"SSE connection to {connection.config.display_name} reconnected successfully", + color=LogType.INFO, + server_only=True, + ) + break + else: + # All retries exhausted raise RuntimeError( - f"Failed to reconnect SSE connection to {connection.config.display_name}" + f"Failed to reconnect SSE connection to {connection.config.display_name} after {max_retries} attempts: {last_error}" ) - printr.print( - f"SSE connection to {connection.config.display_name} reconnected successfully", - color=LogType.INFO, - server_only=True, - ) # Create a future to get the result from the SSE thread loop = asyncio.get_event_loop() diff --git a/skills/hud/main.py b/skills/hud/main.py index 6faa3d0f8..483c8f147 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -607,12 +607,28 @@ async def unload(self) -> None: # Save state self._save_persistent_items() - self.hud_clear_all(False) - # Delete groups if client exists + # Clear HUD items - wrap in try-except to handle server unavailability + try: + await self.hud_clear_all(False) + except Exception as e: + printr.print( + f"[HUD] Error clearing items during unload: {e}", + color=LogType.WARNING, + server_only=True + ) + + # Delete groups if client exists - wrap in try-except if self._client: - await self._client.delete_group(self._group_name, WindowType.MESSAGE) - await self._client.delete_group(self._group_name, WindowType.PERSISTENT) + try: + await self._client.delete_group(self._group_name, WindowType.MESSAGE) + await self._client.delete_group(self._group_name, WindowType.PERSISTENT) + except Exception as e: + printr.print( + f"[HUD] Error deleting groups during unload: {e}", + color=LogType.WARNING, + server_only=True + ) # Disconnect client if self._client: diff --git a/wingman_core.py b/wingman_core.py index 87cbe805d..061f6508f 100644 --- a/wingman_core.py +++ b/wingman_core.py @@ -464,7 +464,19 @@ async def _start_hud_server_if_enabled(self): try: validated = self._get_validated_hud_settings(hud_settings) self._hud_server = HudServer() - if not self._hud_server.start(**validated): + # Run blocking start() in executor to avoid blocking the event loop + loop = asyncio.get_event_loop() + success = await loop.run_in_executor( + None, + self._hud_server.start, + validated['host'], + validated['port'], + validated['framerate'], + validated['layout_margin'], + validated['layout_spacing'], + validated['screen'], + ) + if not success: self.printr.print( f"[HUD] Server failed to start on port {validated['port']}", color=LogType.ERROR,