diff --git a/api/enums.py b/api/enums.py index 00f7953e..d5887d14 100644 --- a/api/enums.py +++ b/api/enums.py @@ -84,6 +84,7 @@ class CustomPropertyType(Enum): SLIDER = "slider" AUDIO_FILES = "audio_files" AUDIO_DEVICE = "audio_device" + COLOR = "color" class AzureApiVersion(Enum): diff --git a/api/interface.py b/api/interface.py index 92b7a09c..b8dd1fce 100644 --- a/api/interface.py +++ b/api/interface.py @@ -1056,12 +1056,38 @@ class DuplicateWingmanResult(BaseModel): wingman_file: WingmanConfigFileInfo +class HudServerSettings(BaseModel): + """HUD Server settings for global configuration.""" + + enabled: bool + """Whether the HUD server should auto-start with Wingman AI Core.""" + + host: str + """The interface to listen on. Use '127.0.0.1' for local only, '0.0.0.0' for LAN access.""" + + port: int + """The port to listen on.""" + + framerate: int + """HUD overlay rendering framerate. Higher = smoother but more CPU. Minimum 1.""" + + layout_margin: int + """Margin from screen edges in pixels for HUD elements. Between 0 and 200.""" + + layout_spacing: int + """Spacing between stacked HUD windows in pixels. Between 0 and 100.""" + + screen: int + """Which screen/monitor to render the HUD on (1 = primary, 2 = secondary, etc.).""" + + class SettingsConfig(BaseModel): audio: Optional[AudioSettings] = None voice_activation: VoiceActivationSettings wingman_pro: WingmanProSettings xvasynth: XVASynthSettings pocket_tts: PocketTTSSettings + hud_server: HudServerSettings debug_mode: bool streamer_mode: bool cancel_tts_key: Optional[str] = None diff --git a/hud_server/API.md b/hud_server/API.md new file mode 100644 index 00000000..38ee04a9 --- /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 00000000..67625617 --- /dev/null +++ b/hud_server/README.md @@ -0,0 +1,598 @@ +# 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 +from hud_server.types import WindowType + +async with HudHttpClient() as client: + # 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 (to persistent group) + await client.show_progress( + group_name="my_wingman", + element=WindowType.PERSISTENT, + title="Loading", + current=50, + maximum=100 + ) +``` + +### Using the Client (Sync) + +```python +from hud_server.http_client import HudHttpClientSync +from hud_server.types import WindowType + +with HudHttpClientSync() as client: + 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 + +### Health & Status + +- `GET /health` - Health check and list of active groups +- `GET /` - Same as `/health` + +### Groups + +- `POST /groups` - Create or update a HUD 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 +- `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 (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 +- `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 + 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 +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 +python -m hud_server.tests.run_tests --snake # You know this one ... +``` + +## 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 + +### 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 diff --git a/hud_server/__init__.py b/hud_server/__init__.py new file mode 100644 index 00000000..6b4b9e84 --- /dev/null +++ b/hud_server/__init__.py @@ -0,0 +1,118 @@ +""" +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 +- 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 + +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 +from hud_server.http_client import HudHttpClient, HudHttpClientSync +from hud_server.models import ( + HudServerSettings, + GroupState, + MessageRequest, + ChatMessageRequest, + ProgressRequest, + TimerRequest, + ItemRequest, + StateRestoreRequest, + 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", + "ChatMessageRequest", + "ProgressRequest", + "TimerRequest", + "ItemRequest", + "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 new file mode 100644 index 00000000..8e96a551 --- /dev/null +++ b/hud_server/constants.py @@ -0,0 +1,155 @@ +""" +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 +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" +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_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" + +# 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://{}:{}/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" +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/http_client.py b/hud_server/http_client.py new file mode 100644 index 00000000..090cdabc --- /dev/null +++ b/hud_server/http_client.py @@ -0,0 +1,1247 @@ +# -*- 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: + 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: + # 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.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 httpx +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, PATH_ELEMENT_SHOW, PATH_ELEMENT_HIDE +from services.printr import Printr +from hud_server import constants as hud_const +from hud_server.types import ( + Anchor, + LayoutMode, + HudColor, + FontFamily, + BaseProps, + WindowType +) + +printr = Printr() + + +def _resolve_enum(value: Any) -> Any: + """Convert enum values to their string representation.""" + if isinstance(value, (Anchor, LayoutMode, HudColor, FontFamily, WindowType)): + 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.""" + + # Timeout constants + 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("/") + self._client: Optional[httpx.AsyncClient] = None + self._connected = False + + @property + def connected(self) -> bool: + return self._connected + + 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 # Expected during cleanup + 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(PATH_HEALTH) + if response.status_code == 200: + self._connected = True + return True + return False + 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 + + 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. + + 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: + if not await self.connect(): + return None + + async def _execute_request(): + """Execute the HTTP request with the given method.""" + if method == "GET": + return await self._client.get(path) + elif method == "POST": + return await self._client.post(path, json=json) + elif method == "PUT": + return await self._client.put(path, json=json) + 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 + + 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} timed out after {self.MAX_TIMEOUT_RETRIES} attempts", + color=LogType.WARNING, + server_only=True + ) + 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 + + # ─────────────────────────────── Health ─────────────────────────────── # + + async def health_check(self) -> bool: + """Check if server is responsive.""" + 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", PATH_HEALTH) + + # ─────────────────────────────── Groups ─────────────────────────────── # + + 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 (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): + - 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, WindowType, message_props + + props = message_props( + anchor=Anchor.TOP_RIGHT, + accent_color=HudColor.ACCENT_ORANGE + ) + 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. + + The server will broadcast the updated props to the overlay for real-time updates. + Props can contain enum values (Anchor, HudColor, etc.) which will be auto-resolved. + + 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: + True if successful, False otherwise + """ + 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, element: WindowType) -> Optional[dict]: + """Delete a HUD group.""" + encoded_group = quote(group_name, safe='') + 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.""" + 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"{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", PATH_STATE_RESTORE, { + "group_name": group_name, + "state": state + }) + + # ─────────────────────────────── Messages ─────────────────────────────── # + + async def show_message( + self, + group_name: str, + element: WindowType, + title: str, + content: str, + color: Optional[Union[str, HudColor]] = None, + tools: Optional[list] = None, + props: Optional[BaseProps] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Show a message in a HUD group. + + Args: + 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) + 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: + from hud_server.types import WindowType + await client.show_message( + "Computer", + WindowType.MESSAGE, + "Alert", + "Something **important** happened!", + color=HudColor.WARNING, + duration=10.0 + ) + """ + data: dict[str, Any] = { + "group_name": group_name, + "element": _resolve_enum(element), + "title": title, + "content": content + } + if color: + data["color"] = _resolve_enum(color) + if tools: + data["tools"] = tools + if props: + data["props"] = _resolve_props(props) + if duration is not None: + data["duration"] = duration + + return await self._request("POST", PATH_MESSAGE, data) + + 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, 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}/{_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]: + """Show or hide the loader animation. + + 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, + "element": _resolve_enum(element), + "show": show + } + if color: + data["color"] = _resolve_enum(color) + return await self._request("POST", PATH_LOADER, data) + + # ─────────────────────────────── Items ─────────────────────────────── # + + async def add_item( + self, + group_name: str, + element: WindowType, + title: str, + description: str = "", + color: Optional[Union[str, HudColor]] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Add a persistent item to a group. + + Args: + 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) + duration: Optional auto-remove duration in seconds + + Returns: + Server response dict or None if failed + + Example: + from hud_server.types import WindowType + await client.add_item( + "Computer", + WindowType.PERSISTENT, + "Shield Status", + "Shields at 100%", + color=HudColor.SHIELD + ) + """ + data: dict[str, Any] = { + "group_name": group_name, + "element": _resolve_enum(element), + "title": title, + "description": description + } + if color: + data["color"] = _resolve_enum(color) + if duration is not None: + data["duration"] = duration + + return await self._request("POST", PATH_ITEMS, data) + + async def update_item( + self, + group_name: str, + element: WindowType, + title: str, + description: Optional[str] = None, + color: Optional[Union[str, HudColor]] = None, + duration: Optional[float] = None + ) -> Optional[dict]: + """Update an existing 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) + 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, + "element": _resolve_enum(element), + "title": title + } + if description is not None: + data["description"] = description + if color is not None: + data["color"] = _resolve_enum(color) + if duration is not None: + data["duration"] = duration + + return await self._request("PUT", PATH_ITEMS, data) + + 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}/{_resolve_enum(element)}/{encoded_title}") + + 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}/{_resolve_enum(element)}") + + # ─────────────────────────────── Progress ─────────────────────────────── # + + async def show_progress( + self, + group_name: str, + element: WindowType, + title: str, + current: float, + maximum: float = 100, + description: str = "", + color: Optional[Union[str, HudColor]] = None, + auto_close: bool = False, + props: Optional[BaseProps] = None + ) -> Optional[dict]: + """Show or update a progress bar. + + 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) + 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: + from hud_server.types import WindowType + await client.show_progress( + "Computer", + WindowType.PERSISTENT, + "Downloading...", + current=45, + maximum=100, + color=HudColor.INFO, + auto_close=True + ) + """ + data: dict[str, Any] = { + "group_name": group_name, + "element": _resolve_enum(element), + "title": title, + "current": current, + "maximum": maximum, + "description": description, + "auto_close": auto_close + } + if color: + data["color"] = _resolve_enum(color) + if props: + data["props"] = _resolve_props(props) + + return await self._request("POST", PATH_PROGRESS, data) + + async def show_timer( + self, + group_name: str, + element: WindowType, + title: str, + duration: float, + description: str = "", + color: Optional[Union[str, HudColor]] = None, + auto_close: bool = True, + initial_progress: float = 0, + props: Optional[BaseProps] = None + ) -> Optional[dict]: + """Show a timer-based progress bar. + + 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 + 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: + from hud_server.types import WindowType + await client.show_timer( + "Computer", + WindowType.PERSISTENT, + "Quantum Cooldown", + duration=30.0, + color=HudColor.QUANTUM, + auto_close=True + ) + """ + data: dict[str, Any] = { + "group_name": group_name, + "element": _resolve_enum(element), + "title": title, + "duration": duration, + "description": description, + "auto_close": auto_close, + "initial_progress": initial_progress + } + if color: + data["color"] = _resolve_enum(color) + if props: + data["props"] = _resolve_props(props) + + return await self._request("POST", PATH_TIMER, data) + + # ─────────────────────────────── Chat Window ─────────────────────────────── # + + async def create_chat_window( + self, + group_name: str, + element: WindowType, + # Layout (anchor-based) - preferred + anchor: Union[str, Anchor] = Anchor.TOP_LEFT, + priority: int = 5, + 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, + # 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. + + 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 = { + "group_name": group_name, + "element": _resolve_enum(element), + # Layout + "anchor": _resolve_enum(anchor), + "priority": priority, + "layout_mode": _resolve_enum(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", PATH_CHAT_WINDOW, data) + + async def delete_chat_window(self, group_name: str, element: WindowType) -> Optional[dict]: + """Delete a chat window.""" + 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, + group_name: str, + element: WindowType, + sender: str, + text: str, + color: Optional[Union[str, HudColor]] = None + ) -> Optional[dict]: + """Send a message to a chat window. + + Args: + 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) + + 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 = { + "group_name": group_name, + "element": _resolve_enum(element), + "sender": sender, + "text": text + } + if color: + data["color"] = _resolve_enum(color) + + return await self._request("POST", PATH_CHAT_MESSAGE, data) + + async def update_chat_message( + self, + group_name: str, + element: WindowType, + message_id: str, + text: str + ) -> Optional[dict]: + """Update an existing chat message's text content by its ID.""" + data = { + "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, group_name: str, element: WindowType) -> Optional[dict]: + """Clear all messages from a chat window.""" + 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, group_name: str, element: WindowType) -> Optional[dict]: + """Show a hidden chat window.""" + 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, group_name: str, element: WindowType) -> Optional[dict]: + """Hide a chat window.""" + 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) + }) + + + +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. + """ + + # 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) -> None: + """Ensure event loop is running in background thread.""" + 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_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() + 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 = 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) + result = self._run_coro(self._client.connect(timeout)) + return result if result is not None else False + + 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 + + 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, 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, element, props)) if self._client else None + + 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, element, props)) if self._client else False + + 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 + + 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, + element: WindowType, + title: str, + content: str, + color: Optional[Union[str, HudColor]] = None, + tools: Optional[list] = None, + props: Optional[BaseProps] = 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, element, title, content, color, tools, props, duration + )) 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, 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, 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, + duration: Optional[float] = None + ): + """Add persistent item. Color accepts HudColor enum or hex string.""" + return self._run_coro(self._client.add_item( + 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, + duration: Optional[float] = None + ): + """Update item. Color accepts HudColor enum or hex string.""" + return self._run_coro(self._client.update_item( + group_name, element, title, description, color, duration + )) 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, 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, + description: str = "", + color: Optional[Union[str, HudColor]] = None, + auto_close: bool = False, + props: Optional[BaseProps] = None + ): + """Show progress bar. Color accepts HudColor enum or hex string.""" + return self._run_coro(self._client.show_progress( + 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 = "", + color: Optional[Union[str, HudColor]] = None, + auto_close: bool = True, + initial_progress: float = 0, + props: Optional[BaseProps] = None + ): + """Show timer. Color accepts HudColor enum or hex string.""" + return self._run_coro(self._client.show_timer( + group_name, element, title, duration, description, color, auto_close, initial_progress, props + )) if self._client else None + + def create_chat_window( + self, + group_name: str, + element: WindowType, + # Layout (anchor-based) - preferred + anchor: Union[str, Anchor] = Anchor.TOP_LEFT, + priority: int = 5, + 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, + # 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( + group_name=group_name, + element=element, + anchor=anchor, + priority=priority, + 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, + 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, 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, + 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( + group_name, element, sender, text, color + )) if self._client else None + + def update_chat_message(self, group_name: str, element: WindowType, message_id: str, text: str): + return self._run_coro(self._client.update_chat_message( + group_name, element, message_id, text + )) 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_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_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 new file mode 100644 index 00000000..2824cce5 --- /dev/null +++ b/hud_server/hud_manager.py @@ -0,0 +1,844 @@ +""" +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 +import uuid +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 + id: str = field(default_factory=lambda: str(uuid.uuid4())) + 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 + # 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.""" + 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": [ + { + "id": msg.id, + "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( + id=msg_data.get("id", str(uuid.uuid4())), + 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. + Supports callbacks for real-time overlay integration. + """ + + 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) -> 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) -> 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]) -> 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] Callback {i} failed for command '{command.get('type', 'unknown')}': " + f"{type(e).__name__}: {e}", + color=LogType.ERROR, + server_only=True + ) + + # ─────────────────────────────── Group Management ─────────────────────────────── # + + 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 key not in self._groups: + self._groups[key] = GroupState() + + if props: + 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, element: str, props: dict[str, Any]) -> bool: + """Update properties of an existing group.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + 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, element: str) -> bool: + """Delete a HUD group.""" + key = self._make_key(group_name, element) + with self._lock: + if key in self._groups: + del self._groups[key] + + 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, + element: 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.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + self.create_group(group_name, element) + + self._groups[key].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[key].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, element: str, content: str) -> bool: + """Append content to the current message (for streaming).""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + state = self._groups[key] + 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, element: str) -> bool: + """Hide/fade out the current message.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + self._groups[key].current_message = None + + self._notify_callbacks({ + "type": "hide_message", + "group": group_name + }) + + return True + + # ─────────────────────────────── Loader ─────────────────────────────── # + + 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 key not in self._groups: + self.create_group(group_name, element) + + self._groups[key].loader_visible = show + if color: + self._groups[key].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, + 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 key not in self._groups: + self.create_group(group_name, element) + + self._groups[key].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, + 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 key not in self._groups: + return False + + if title not in self._groups[key].items: + return False + + item = self._groups[key].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, element: str, title: str) -> bool: + """Remove an item from a group.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + if title in self._groups[key].items: + del self._groups[key].items[title] + + self._notify_callbacks({ + "type": "remove_item", + "group": group_name, + "title": title + }) + + return True + return False + + 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 key not in self._groups: + return False + + self._groups[key].items.clear() + + self._notify_callbacks({ + "type": "clear_items", + "group": group_name + }) + + return True + + # ─────────────────────────────── Progress ─────────────────────────────── # + + def show_progress( + self, + group_name: str, + element: 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.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + self.create_group(group_name, element) + + items = self._groups[key].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, + element: 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.""" + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + self.create_group(group_name, element) + + now = time.time() + self._groups[key].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 send_chat_message( + self, + group_name: str, + element: str, + sender: str, + text: str, + color: Optional[str] = None + ) -> 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. + """ + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return None + + state = self._groups[key] + + # Append to last message if same sender + if ( + state.chat_messages + and state.chat_messages[-1].sender == sender + ): + state.chat_messages[-1].text += " " + text + message_id = state.chat_messages[-1].id + else: + 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) + if len(state.chat_messages) > max_messages: + state.chat_messages = state.chat_messages[-max_messages:] + + self._notify_callbacks({ + "type": "chat_message", + "group": group_name, + "element": element, + "id": message_id, + "sender": sender, + "text": text, + "color": color + }) + + return message_id + + def update_chat_message( + self, + group_name: str, + element: 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. + """ + key = self._make_key(group_name, element) + with self._lock: + if key not in self._groups: + return False + + 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", + "group": group_name, + "id": message_id, + "text": text + }) + return True + + return False + + 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 key not in self._groups: + return False + + self._groups[key].chat_messages.clear() + + self._notify_callbacks({ + "type": "clear_chat_window", + "group": group_name + }) + + return True + + 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 key not in self._groups: + return False + + self._groups[key].visible = True + + self._notify_callbacks({ + "type": "show_chat_window", + "group": group_name + }) + + return True + + 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 key not in self._groups: + return False + + self._groups[key].visible = False + + self._notify_callbacks({ + "type": "hide_chat_window", + "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 + + 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() + + self._notify_callbacks({ + "type": "clear_all" + }) diff --git a/hud_server/layout/__init__.py b/hud_server/layout/__init__.py new file mode 100644 index 00000000..18d93f26 --- /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 00000000..271c0322 --- /dev/null +++ b/hud_server/layout/manager.py @@ -0,0 +1,522 @@ +""" +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 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 + + # 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" # Reserved for future use (currently behaves like AUTO) + + +@dataclass +class WindowInfo: + """ + 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 + 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 # Reserved for future use + + # 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, + 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 + + # 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() + + 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, + 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. + """ + 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 + + # 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_with_offset + + 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 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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: + 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 + + def get_position(self, name: str) -> Optional[Tuple[int, int]]: + """Get the computed position for a window (offset already included via compute_positions).""" + 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 00000000..952b8e7a --- /dev/null +++ b/hud_server/models.py @@ -0,0 +1,396 @@ +""" +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, Field, field_validator +from hud_server.types import WindowType + + +# ─────────────────────────────── 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 = 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 = 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.""" + + 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.).""" + + @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 ─────────────────────────────── # + + +class HudGroupProps(BaseModel): + """Properties for a HUD group. All properties are optional when updating.""" + + # 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=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 - 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) + 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 = Field(default=16, ge=0, le=100) + + # Behavior + typewriter_effect: bool = True + typewriter_speed: int = Field(default=200, ge=1, le=1000) + show_loader: bool = True + auto_fade: bool = True + 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 = Field(default=0, ge=-1000, le=1000) + + # Layout Management + 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 = 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 = Field(default=10, ge=0, le=100) + """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 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.""" + + +class UpdateGroupRequest(BaseModel): + """Request to update group properties.""" + + group_name: str + """Name of the group to update.""" + + element: WindowType + """The element type.""" + + props: dict[str, Any] + """Properties to update.""" + + +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 (e.g., wingman name).""" + + element: WindowType + """The element type (message, persistent, or chat).""" + + title: str = Field(..., min_length=1, max_length=200) + """Message title.""" + + content: str = Field(..., max_length=50000) + """Message content (supports Markdown).""" + + 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 + """Optional tool information for display.""" + + props: Optional[dict[str, Any]] = None + """Optional property overrides for this message.""" + + 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): + """Request to append content to current message (streaming).""" + + group_name: str + element: WindowType + content: str + + +class LoaderRequest(BaseModel): + """Request to show/hide loader animation.""" + + group_name: str + element: WindowType + 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 (e.g., wingman name).""" + + element: WindowType + """The element type (must be persistent).""" + + 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 + element: WindowType + 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 + element: WindowType + title: str + + +class ProgressRequest(BaseModel): + """Request to show/update a progress bar.""" + + group_name: str + element: WindowType + 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 + element: WindowType + 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.""" + + group_name: str + """Name of the HUD group.""" + + element: WindowType + """The element type (must be chat).""" + + sender: str + """Sender name.""" + + text: str + """Message text.""" + + color: Optional[str] = None + """Optional sender color override.""" + + +class ChatMessageUpdateRequest(BaseModel): + """Request to update an existing chat 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).""" + + text: str + """New message text to replace the existing content.""" + + +class CreateChatWindowRequest(BaseModel): + """Request to create a chat window.""" + + 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 + 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 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.""" + + 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 00000000..8b137891 --- /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 00000000..848be34d --- /dev/null +++ b/hud_server/overlay/overlay.py @@ -0,0 +1,3744 @@ +""" +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 typing import Tuple, Dict, Optional +import traceback + +# 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 + +if sys.platform != "win32": + raise ImportError("hud_server.overlay is only available on Windows") + +from hud_server.platform.win32 import ( + user32, + gdi32, + kernel32, + 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, + SWP_NOMOVE, + SWP_NOSIZE, + SRCCOPY, + DIB_RGB_COLORS, + BI_RGB, + SW_SHOWNOACTIVATE, + HWND_TOPMOST, + PM_REMOVE, + _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 ( + 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. + + 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, + screen: int = 1, + ): + 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 + self._screen = screen + + # 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 + # ===================================================================== + # 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, + "font_family": "Segoe UI", + "color_emojis": True, + "typewriter_effect": True, + # Persistent window defaults + "persistent_x": 20, + "persistent_y": 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 + + # ===================================================================== + # SHARED RESOURCES (used by unified window system) + # ===================================================================== + self.fonts = {} + 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 + # ===================================================================== + # 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 + # ===================================================================== + # 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=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, + ) + + # ========================================================================= + # 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 + # ========================================================================= + + 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("x", 20) + props["y"] = props.get("y", 300) + props["width"] = props.get("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, + "hidden": False, # manually hidden flag + } + + # 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.""" + # 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 + ] + 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") + # 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) + # 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) + # 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) + # 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) + + # Second pass: check collisions and update persistent windows + for group, pers_win in persistent_windows.items(): + try: + 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) + + # 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 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) + if fade_state == 0 and opacity == 0: + continue + + canvas = win.get("canvas") + if not canvas: + continue + + # 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.).""" + # 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 + # 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.).""" + 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 + + # 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: + 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 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 + 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: + # 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_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", 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 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) + + # 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: + # 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 + 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 + + # 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 + ): + 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 with user-specified alpha + final_draw.rounded_rectangle( + [0, 0, width - 1, final_h - 1], + radius=radius, + fill=bg + (bg_alpha,), + outline=(55, 62, 74), + ) + + # 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, 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) + + # 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: + # 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_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", 300)) + max_height = int(props.get("max_height", 600)) + 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, + max_height, + radius, + padding, + bg, + bg_alpha, + 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 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) + + # 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, + emoji_y_offset=3, + ) + # 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"): + 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] + 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 + ) + # 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", "") + 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 + # 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) + + # 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 + ): + 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 with user-specified alpha + final_draw.rounded_rectangle( + [0, 0, width - 1, final_h - 1], + radius=radius, + fill=bg + (bg_alpha,), + 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)) + + # 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) + + # 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 _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 + + # 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")) + + # 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: + 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]: + """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.""" + # 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 _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: + size: Font size in pixels + family: Font family name + + Returns: + Dictionary of font objects for the given size and family + """ + 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 = { + "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", + }, + } + + family_lower = family.lower() + font_files = font_map.get(family_lower, font_map["segoe ui"]) + fonts_dir = "C:/Windows/Fonts/" + + pil_size = size + pil_code_size = max(1, size - 1) # Code font slightly smaller, but at least 1 + + # Load emoji fonts + emoji_font = None + emoji_font_paths = [ + fonts_dir + "seguiemj.ttf", + fonts_dir + "seguisym.ttf", + ] + for emoji_path in emoji_font_paths: + try: + emoji_font = ImageFont.truetype(emoji_path, pil_size) + break + except: + pass + + 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 to load fonts from Windows fonts directory + try: + 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), + "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 + ), + "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_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: + # Fallback: try loading by family name directly + try: + fonts_dict = { + "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(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) + ), + "_font_size": size, + "_font_family": family, + } + except: + # Final fallback to default + default = ImageFont.load_default() + 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")), + "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._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]: + 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. + + Automatically adds a space after emojis if not already present. + + 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) + space_w, _ = self._get_text_size(" ", 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 + + # 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 + 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 _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. + + OPTIMIZED: Uses caching for bar element surfaces. + """ + # 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)) + + # 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) + + def _cleanup_chat_window(self, chat_name: str): + """Clean up resources for a specific chat window.""" + window_name = f"chat_{chat_name}" + + # Unregister from layout manager + self._layout_manager.unregister_window(window_name) + + # Clean up unified window if it exists + if window_name in self._windows: + self._destroy_window(window_name) + + 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 _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("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 + + # 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 + + self._init_fonts() + + 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() + + 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 (includes chat windows now) + # ========================================================= + self._update_all_windows() + + # 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() + + 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: + 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") + + # 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 + ): + 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") + ) + 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 + # 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 + 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: + 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") + ), + "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, + "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", + "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", + "scroll_fade_height", + "anchor", + "priority", + "layout_mode", + ]: + if key in msg and msg[key] is not None: + default_props[key] = msg[key] + + # 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, + "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, + } + + # 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)) + 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: + # Store hwnd in the unified window state + self._windows[window_name]["hwnd"] = hwnd + window_dc, mem_dc = self._init_gdi(hwnd) + 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") + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + win = self._windows[window_name] + props = msg.get("props", {}) + 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 = win.get("hwnd") + if hwnd: + 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)) + 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: + 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") + window_name = f"chat_{chat_name}" + if chat_name and window_name in self._windows: + 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 win["messages"] and win["messages"][-1]["sender"] == sender: + win["messages"][-1]["text"] += " " + msg.get("text", "") + win["messages"][-1]["timestamp"] = now + else: + message = { + "id": message_id, + "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 + 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 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(window_name, True) + + 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}" + 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") + 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(window_name, True) + win["canvas_dirty"] = True + + elif t == "hide_chat_window": + 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] + 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 + + # ===================================================================== + # 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 + + 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, + 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 _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], + 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. + OPTIMIZED: Uses caching for track backgrounds and gradient overlays. + + 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 + scaled_height = height * scale + scaled_width = width * scale + radius = scaled_height // 2 + + # 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 + 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,), + ) + + # Get cached gradient overlay + highlight_height = fill_h // 3 + shadow_height = fill_h // 4 + 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) + 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 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 00000000..8b137891 --- /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 00000000..dce6c15f --- /dev/null +++ b/hud_server/platform/win32.py @@ -0,0 +1,377 @@ +import sys + +if sys.platform != "win32": + raise ImportError("hud_server.platform.win32 is only available on Windows") + +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 +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, +] + +# 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)] + + +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 + +# 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: + 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 + ) + + +# ─────────────────────────────── 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/rendering/__init__.py b/hud_server/rendering/__init__.py new file mode 100644 index 00000000..8b137891 --- /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 00000000..47a9d102 --- /dev/null +++ b/hud_server/rendering/markdown.py @@ -0,0 +1,2545 @@ +""" +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 copy +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 + +# 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. + + 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 + self.colors = colors + self.color_emojis = color_emojis # Enable colored emoji rendering + # 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 = {} + 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} + + 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) >= self._image_cache_max: + # 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. + + 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])) + except: + size = (len(text) * 8, 16) + + # Limit cache size to prevent memory leaks (simple eviction) + if len(self._text_size_cache) > self._text_size_cache_max: + 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]: + """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: + 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)) + 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 + # ========================================================================= + + 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) + - '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. + """ + # 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 + + 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] + + # 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 ** + '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)) + + # 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': 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) + 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', '') + + # 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'): + 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 + + # 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: + 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 + 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 00000000..4c3c551f --- /dev/null +++ b/hud_server/server.py @@ -0,0 +1,875 @@ +""" +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 +import time +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 import constants as hud_const +from hud_server.types import WindowType +from hud_server.models import ( + CreateGroupRequest, + UpdateGroupRequest, + MessageRequest, + AppendMessageRequest, + LoaderRequest, + ItemRequest, + UpdateItemRequest, + ProgressRequest, + TimerRequest, + ChatMessageRequest, + ChatMessageUpdateRequest, + CreateChatWindowRequest, + StateRestoreRequest, + HealthResponse, + GroupStateResponse, + OperationResponse, + ChatMessageResponse, +) + +# 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: + _HeadsUpOverlay = None + _PIL_AVAILABLE = False + +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" + + # 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 + DEFAULT_SCREEN = 1 + + # 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 = 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 + self._screen = self.DEFAULT_SCREEN + + # 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.).""" + # 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} + ) + + @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() + + # ─────────────────────────────── 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"]) + async def create_group(request: CreateGroupRequest): + """Create or update a HUD group.""" + 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}/{element}", response_model=OperationResponse, tags=["groups"]) + async def update_group(group_name: str, element: str, request: UpdateGroupRequest): + """Update properties of an existing group.""" + 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}/{element}", response_model=OperationResponse, tags=["groups"]) + async def patch_group(group_name: str, element: str, request: UpdateGroupRequest): + """Update properties of an existing group (PATCH).""" + 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}/{element}", response_model=OperationResponse, tags=["groups"]) + async def delete_group(group_name: str, element: str): + """Delete a HUD group.""" + 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") + + @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.""" + self.manager.show_message( + group_name=request.group_name, + element=request.element.value, + 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.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}/{element}", response_model=OperationResponse, tags=["messages"]) + async def hide_message(group_name: str, element: str): + """Hide the current message in a group.""" + 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") + + # ─────────────────────────────── 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.element.value, 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, + element=request.element.value, + 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, + element=request.element.value, + 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}/{element}/{title}", response_model=OperationResponse, tags=["items"]) + async def remove_item(group_name: str, element: str, title: str): + """Remove an item from a group.""" + 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}/{element}", response_model=OperationResponse, tags=["items"]) + async def clear_items(group_name: str, element: str): + """Clear all items from a group.""" + 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") + + # ─────────────────────────────── 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, + element=request.element.value, + 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, + element=request.element.value, + 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 = { + "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, + "sender_colors": request.sender_colors or {}, + "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_group(request.group_name, request.element.value, props) + return OperationResponse(status="ok", message=f"Chat window '{request.group_name}' created") + + @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.""" + 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( + 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.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( + 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.group_name}'") + return OperationResponse(status="ok") + + @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.""" + 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/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def show_chat_window(group_name: str, element: str): + """Show a hidden chat window.""" + 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/{group_name}/{element}", response_model=OperationResponse, tags=["chat"]) + async def hide_chat_window(group_name: str, element: str): + """Hide a chat window.""" + 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") + + + # ─────────────────────────────── Overlay Support ─────────────────────────────── # + + 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: + 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, + screen=self._screen, + ) + + # 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=hud_const.THREAD_NAME_OVERLAY + ) + self._overlay_thread.start() + + 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 not self._command_queue and not self._overlay_thread: + return + + 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) + + 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.""" + 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 + ) + + # ─────────────────────────────── 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, + screen: int = DEFAULT_SCREEN) -> 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 + screen: Which monitor to render the HUD on (1 = primary, 2 = secondary, etc.) + + Returns: + 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 + self._port = port + 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, + daemon=True, + name=hud_const.THREAD_NAME_SERVER + ) + self._thread.start() + + # 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( + 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 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: + 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._loop.run_until_complete(self._server.serve()) + 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.""" + 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=hud_const.SERVER_SHUTDOWN_TIMEOUT) + self._thread = None + + self._server = None + self._loop = None + + printr.print( + hud_const.LOG_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/__init__.py b/hud_server/tests/__init__.py new file mode 100644 index 00000000..5dbf0a33 --- /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 00000000..6fb75f7d --- /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 +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType + + +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, 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: + 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, 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, WindowType.MESSAGE, 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", 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", 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") + print(" Waiting 5 seconds - verify visually...") + await asyncio.sleep(5) + + print("\n5. Cleanup...") + for name, _, _, _ in groups_config: + await client.hide_message(name, WindowType.MESSAGE) + + 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 00000000..0c97f218 --- /dev/null +++ b/hud_server/tests/run_tests.py @@ -0,0 +1,163 @@ +""" +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 --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) +""" +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 == "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) + 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 + from hud_server.tests.test_settings import run_all_settings_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) + await asyncio.sleep(2) + await run_all_settings_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 == "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", "settings", "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 00000000..c62138c0 --- /dev/null +++ b/hud_server/tests/test_chat.py @@ -0,0 +1,570 @@ +""" +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 +- Consecutive same-sender message merging +- Auto-hide and manual show/hide +- Message overflow with fade effect +""" + +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" # 🚀 +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}" + + # 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=LayoutMode.AUTO.value, + width=session.config["hud_width"], + max_height=300, + auto_hide=False, + 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!") + 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}" + + 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=LayoutMode.AUTO.value, + width=450, + max_height=400, + auto_hide=False, + sender_colors={ + "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) + 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, "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 + - Nested item +- 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 | +| 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 = [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)} + + 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=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._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: + 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}" + + 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=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...") + await asyncio.sleep(1) + 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 + 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 with long messages and typewriter effect.""" + print(f"[{session.name}] Testing chat overflow...") + + chat_name = f"overflow_{session.session_id}" + + 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, + layout_mode=LayoutMode.AUTO.value, + width=400, + 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) + + # 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 session.delete_chat_window(chat_name) + 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}" + + 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=LayoutMode.AUTO.value, + width=400, + max_height=300, + auto_hide=False, + sender_colors={ + "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 + 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) + + +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) + + +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}" + + 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=LayoutMode.AUTO.value, + width=400, + max_height=300, + auto_hide=False, + sender_colors={ + "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 + 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 +# ============================================================================= + +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_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) + 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 00000000..f3404d22 --- /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 00000000..94e24cef --- /dev/null +++ b/hud_server/tests/test_layout_visual.py @@ -0,0 +1,727 @@ +""" +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 +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType + + +# ============================================================================= +# ANCHOR CONFIGURATION - All 9 anchor points +# ============================================================================= + +ANCHOR_CONFIG = { + Anchor.TOP_LEFT: { + "label": "TOP LEFT", + "color": HudColor.ERROR, + "emoji_fallback": "[TL]", + }, + Anchor.TOP_CENTER: { + "label": "TOP CENTER", + "color": HudColor.ACCENT_ORANGE, + "emoji_fallback": "[TC]", + }, + Anchor.TOP_RIGHT: { + "label": "TOP RIGHT", + "color": HudColor.ACCENT_GREEN, + "emoji_fallback": "[TR]", + }, + Anchor.RIGHT_CENTER: { + "label": "RIGHT CENTER", + "color": HudColor.CYAN, + "emoji_fallback": "[RC]", + }, + Anchor.BOTTOM_RIGHT: { + "label": "BOTTOM RIGHT", + "color": HudColor.BLUE, + "emoji_fallback": "[BR]", + }, + Anchor.BOTTOM_CENTER: { + "label": "BOTTOM CENTER", + "color": HudColor.MAGENTA, + "emoji_fallback": "[BC]", + }, + Anchor.BOTTOM_LEFT: { + "label": "BOTTOM LEFT", + "color": HudColor.YELLOW, + "emoji_fallback": "[BL]", + }, + Anchor.LEFT_CENTER: { + "label": "LEFT CENTER", + "color": "#ff8855", + "emoji_fallback": "[LC]", + }, + Anchor.CENTER: { + "label": "CENTER", + "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: + try: + await client.hide_message(name, WindowType.MESSAGE) + 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_{_get_value(anchor)}" + groups.append(group_name) + + 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, 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 + ) + 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, 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) + 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, 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 + ) + 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, HudColor.ACCENT_ORANGE), ("right_b", 15, HudColor.ACCENT_PURPLE)]: + groups.append(name) + 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, 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 + ) + 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 + 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", WindowType.MESSAGE, 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", 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 + ) + await asyncio.sleep(0.3) + + await client.show_message( + "dyn_bottom", + WindowType.MESSAGE, + 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", + WindowType.MESSAGE, + 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", + WindowType.MESSAGE, + 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", + 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 + ) + 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 = [HudColor.RED, HudColor.GREEN, HudColor.BLUE] + labels = ["First (Red)", "Second (Green)", "Third (Blue)"] + + for i, (name, color, label) in enumerate(zip(groups, colors, labels)): + 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, 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, 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", 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", 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", WindowType.MESSAGE) + await asyncio.sleep(3) + + # Hide all except blue + print("Phase 5: Only Blue remains") + await client.hide_message("vis_2", WindowType.MESSAGE) + 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", 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) + 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, 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 + ) + 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") + 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", 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 + ) + + print("Center window displayed") + await asyncio.sleep(2) + + # Add edge centers + edge_centers = [ + ("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) + 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, 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 + ) + 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, HudColor.ERROR), (20, HudColor.SUCCESS), (10, HudColor.INFO)]): + name = f"left_stack_{i}" + groups.append(name) + + 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, 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 + ) + 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, HudColor.ACCENT_ORANGE), (15, HudColor.ACCENT_PURPLE)]): + name = f"right_stack_{i}" + groups.append(name) + + 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, 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 + ) + 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 + 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", 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 + ) + + # Progress window below + 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", WindowType.MESSAGE, props=progress_props) + + # Add progress bar + await client.show_progress( + "progress_group", + WindowType.MESSAGE, + 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", + WindowType.MESSAGE, + 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", WindowType.MESSAGE, "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"] + colors = [HudColor.RED, HudColor.GREEN, HudColor.BLUE] + + for i, name in enumerate(groups): + 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, WindowType.MESSAGE, props=props) + + 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, 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", WindowType.MESSAGE) + await asyncio.sleep(0.3) + + # Show middle + 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", WindowType.MESSAGE) + await asyncio.sleep(0.3) + + # Show first + 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...") + 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 00000000..3d4f6855 --- /dev/null +++ b/hud_server/tests/test_messages.py @@ -0,0 +1,264 @@ +""" +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") + + +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 +# ============================================================================= + +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) + await asyncio.sleep(1) + await test_message_bottom_fade(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 00000000..cc8d0ceb --- /dev/null +++ b/hud_server/tests/test_multiuser.py @@ -0,0 +1,968 @@ +""" +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 +from hud_server.types import ( + HudColor, LayoutMode, + MessageProps, PersistentProps, ChatWindowProps, WindowType +) + + +# ============================================================================= +# USER CONFIGURATIONS - Different visual styles across the screen +# ============================================================================= + +USER_CONFIGS = { + "alice": { + "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": HudColor.ACCENT_BLUE.value, + "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": { + "layout_mode": LayoutMode.MANUAL.value, + "x": 20, + "y": 390, + "width": 320, + "max_height": 300, + "bg_color": "#1a2332", + "text_color": "#e8f4ff", + "accent_color": HudColor.ACCENT_GREEN.value, + "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": { + "layout_mode": LayoutMode.MANUAL.value, + "x": 1500, + "y": 20, + "width": 400, + "max_height": 380, + "bg_color": "#2a1f1a", + "text_color": "#fff5e8", + "accent_color": HudColor.ACCENT_ORANGE.value, + "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": { + "layout_mode": LayoutMode.MANUAL.value, + "x": 1520, + "y": 420, + "width": 340, + "max_height": 280, + "bg_color": "#2a1f1a", + "text_color": "#fff5e8", + "accent_color": HudColor.WARNING.value, + "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": { + "layout_mode": LayoutMode.MANUAL.value, + "x": 20, + "y": 720, + "width": 420, + "max_height": 320, + "bg_color": "#1f1a2a", + "text_color": "#f0e8ff", + "accent_color": HudColor.ACCENT_PURPLE.value, + "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": { + "layout_mode": LayoutMode.MANUAL.value, + "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", + "layout_mode": LayoutMode.MANUAL.value, + "x": 800, + "y": 20, + "width": 450, + "max_height": 400, + "bg_color": "#1a1a2e", + "text_color": HudColor.WHITE.value, + "accent_color": HudColor.ERROR.value, + "opacity": 0.95, + "border_radius": 12, + "font_size": 16, + "typewriter_effect": True, + "z_order": 20, # Highest priority + }, + "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": HudColor.INFO.value, + "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": 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": HudColor.SUCCESS.value, + "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") + + 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: + return + + # 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") + + # 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") + + 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, + WindowType.MESSAGE, + 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, WindowType.MESSAGE, 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, + WindowType.PERSISTENT, + 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, + WindowType.PERSISTENT, + 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, WindowType.PERSISTENT, 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, + WindowType.PERSISTENT, + 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, + WindowType.PERSISTENT, + 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: + # 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, WindowType.MESSAGE, props=props) + 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", + WindowType.MESSAGE, + 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", + WindowType.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", + WindowType.PERSISTENT, + 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 00000000..e39ba478 --- /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 00000000..26ab7b86 --- /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 00000000..f76cfa0e --- /dev/null +++ b/hud_server/tests/test_runner.py @@ -0,0 +1,144 @@ +""" +Test Runner - Utilities for running tests with the HUD server. +""" + +import asyncio +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: + 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: 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): + # 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}" + 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 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, + host: str = "127.0.0.1", port: int = 7862): + """Run a test interactively with automatic server management.""" + async def _run(): + 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_session.py b/hud_server/tests/test_session.py new file mode 100644 index 00000000..ca5c4aa1 --- /dev/null +++ b/hud_server/tests/test_session.py @@ -0,0 +1,474 @@ +""" +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. +""" + +import httpx +from typing import Optional, Any + +from hud_server.http_client import HudHttpClient +from hud_server.types import ( + Anchor, LayoutMode, HudColor, FontFamily, + MessageProps, PersistentProps, ChatWindowProps, WindowType +) + + +# ============================================================================= +# SESSION CONFIGURATIONS +# ============================================================================= + +SESSION_CONFIGS = { + 1: { + "name": "Atlas", + # Layout (anchor-based) + "anchor": Anchor.TOP_LEFT, + "priority": 20, + "persistent_anchor": Anchor.TOP_LEFT, + "persistent_priority": 10, + "layout_mode": LayoutMode.AUTO, + # Sizes + "hud_width": 450, + "persistent_width": 350, + "hud_max_height": 500, + # Visual + "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, + "content_padding": 16, + "typewriter_effect": True, + }, + 2: { + "name": "Nova", + # Layout (anchor-based) + "anchor": Anchor.TOP_RIGHT, + "priority": 20, + "persistent_anchor": Anchor.TOP_RIGHT, + "persistent_priority": 10, + "layout_mode": LayoutMode.AUTO, + # Sizes + "hud_width": 400, + "persistent_width": 320, + "hud_max_height": 450, + # Visual + "bg_color": "#1a1f2e", + "text_color": "#e8e8e8", + "accent_color": HudColor.ACCENT_ORANGE, + "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": Anchor.BOTTOM_LEFT, + "priority": 20, + "persistent_anchor": Anchor.BOTTOM_LEFT, + "persistent_priority": 10, + "layout_mode": LayoutMode.AUTO, + # Sizes + "hud_width": 380, + "persistent_width": 300, + "hud_max_height": 480, + # Visual + "bg_color": "#12161f", + "text_color": "#d0d0d0", + "accent_color": HudColor.ACCENT_PURPLE, + "user_color": HudColor.ACCENT_GREEN, + "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 (just the identifier, element passed separately) + self.group_name = f"session_{session_id}_{self.name.lower()}" + + 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) -> MessageProps: + """Get display properties from config as MessageProps.""" + return MessageProps( + # Layout (anchor-based) + 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"], + # Visual + 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._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"], + # Visual + 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 + # ========================================================================= + + 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 + 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, + element=WindowType.MESSAGE, + title=title, + content=message, + color=color_value, + tools=tools, + 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"] + 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.""" + 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.""" + if not self._client: + return + await self._client.hide_message(group_name=self.group_name, element=WindowType.MESSAGE) + + 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, + element=WindowType.MESSAGE, + show=state, + color=color_value, + ) + + # ========================================================================= + # 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.group_name, + element=WindowType.PERSISTENT, + 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.group_name, + element=WindowType.PERSISTENT, + 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.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.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 + # ========================================================================= + + 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.group_name, + element=WindowType.PERSISTENT, + 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.group_name, + element=WindowType.PERSISTENT, + 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(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]: + """Send a message to a chat window. Returns the message ID.""" + if not self._client: + return None + result = await self._client.send_chat_message( + group_name=window_name, + element=WindowType.CHAT, + 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( + group_name=window_name, + element=WindowType.CHAT, + message_id=message_id, + text=text, + ) + + async def clear_chat_window(self, name: str): + """Clear a chat window.""" + if not self._client: + return + 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(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(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(group_name=name, element=WindowType.CHAT) + + # ========================================================================= + # 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() + + 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/hud_server/tests/test_snake.py b/hud_server/tests/test_snake.py new file mode 100644 index 00000000..a35b8a02 --- /dev/null +++ b/hud_server/tests/test_snake.py @@ -0,0 +1,1063 @@ +# -*- coding: utf-8 -*- +""" +Test Snake - Interactive Snake game using the HUD Server. + +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 + +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 +""" + +import asyncio +import time +import random +from enum import Enum +from hud_server.tests.test_session import TestSession +from hud_server.types import Anchor, LayoutMode, HudColor, MessageProps, WindowType + +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 = 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 +COLORS = { + CELL_EMPTY: "#1a1a2e", + 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 + + +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 +# ============================================================================= + +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 + + # 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, 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) + # 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 + 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 + + # 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 + 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) + + 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 +# ============================================================================= + +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() + +# 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 + + # 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 + + 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 = 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, + element=WindowType.MESSAGE, + title=" ", + content=" ", # Need non-empty content to keep HUD visible + color=cell_color, + props=props, + duration=3600 # Max allowed 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, WindowType.MESSAGE) + _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, WindowType.MESSAGE) + + _active_cell_huds.clear() + + # Also clean up stats + await session._client.delete_group("snake_stats", WindowType.MESSAGE) + + +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 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 all regular foods + for food_pos in game.foods: + await show_cell(session, food_pos[0], food_pos[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): + """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, game: SnakeGame, 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 + 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 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}" + + 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", + element=WindowType.MESSAGE, + title=" ", + content=combo_text, + color=color, + props=props, + duration=1.5 # Show for 1.5 seconds + ) + + +# ============================================================================= +# Game Screens +# ============================================================================= + +async def show_start_screen(session: TestSession): + """Display the game start screen as individual HUD elements.""" + if not session._client: + return + + # 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, + props=_menu_props(250, font_size=16), + duration=3600, + ) + + # 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 +- 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=_menu_props(240), + duration=3600, + ) + + # Features HUD + await session._client.show_message( + group_name="snake_menu_features", + element=WindowType.MESSAGE, + 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=_menu_props(230), + duration=3600, + ) + + # Controls HUD + await session._client.show_message( + group_name="snake_menu_controls", + element=WindowType.MESSAGE, + title=" ", + content=f"""## Controls +- **↑ ↓ ← →** : Move snake +- **Grid Size:** {GRID_WIDTH} x {GRID_HEIGHT}""", + color=COLOR_GAME, + props=_menu_props(220), + duration=3600, + ) + + # 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, + props=_menu_props(210, bg_color="#1a4d1a", font_size=16), + duration=3600, + ) + + +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 + + # 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_str} | **Speed:** {1/speed:.1f}/s{combo_str}""" + + await session._client.show_message( + group_name="snake_stats", + element=WindowType.MESSAGE, + title="🎮 Endless Snake", + content=stats_message, + color=COLOR_GAME, + props=_stats_props(), + duration=3600, # Max allowed duration + ) + + +async def show_game_over_screen(session: TestSession, game: SnakeGame, elapsed: float): + """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 >= 50: + result_emoji, rating = "💎", "MASTER!" + elif game.score >= 30: + result_emoji, rating = "🌟", "AMAZING!" + elif game.score >= 20: + result_emoji, rating = "🎉", "GREAT!" + elif game.score >= 10: + result_emoji, rating = "👍", "GOOD!" + elif game.score >= 5: + result_emoji, rating = "😊", "NICE!" + else: + result_emoji, rating = "😅", "KEEP TRYING!" + + # Format time + minutes = int(elapsed // 60) + seconds = int(elapsed % 60) + time_str = f"{minutes}:{seconds:02d}" + + # 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, + props=_menu_props(250, accent_color=COLOR_GAME_OVER, bg_color="#1a0a0a", width=500, font_size=18), + duration=3600, + ) + + # Rating HUD + await session._client.show_message( + group_name="snake_gameover_rating", + element=WindowType.MESSAGE, + title=" ", + content=f"## {rating}", + color=COLOR_GAME_OVER, + props=_menu_props(240, accent_color=COLOR_GAME_OVER, width=500, font_size=16), + duration=3600, + ) + + # Stats HUD + await session._client.show_message( + group_name="snake_gameover_stats", + element=WindowType.MESSAGE, + title=" ", + content=f"""### Final Stats +- **Score:** {game.score} +- **Final Length:** {len(game.snake)} +- **Survival Time:** {time_str} +- **Reason:** {game.game_over_reason}""", + color=COLOR_GAME_OVER, + props=_menu_props(230, accent_color=COLOR_GAME_OVER, width=500), + duration=3600, + ) + + # 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, + props=_menu_props(220, bg_color="#1a4d1a", width=500, font_size=15), + duration=3600, + ) + + # Exit Button HUD + await session._client.show_message( + group_name="snake_gameover_exit", + element=WindowType.MESSAGE, + title=" ", + content="👋 **Press ESC to exit**", + color="#888888", + props=_menu_props(210, accent_color="#888888", bg_color="#1a1a1a", width=500, font_size=15), + duration=3600, + ) + + +# ============================================================================= +# Main Game Loop +# ============================================================================= + +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() + + # 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) + + # 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", 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!") + + + # 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, "combo": -1} + elapsed = 0.0 + last_combo_shown = 0 + + try: + while game_running: + current_time = time.time() + elapsed = current_time - start_time + + # 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 + + ate_food = 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 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, game, old_states, new_states) + + # 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() + + await asyncio.sleep(0.01) + + # Cleanup and show game over + await cleanup_all_cells(session) + await show_game_over_screen(session, game, elapsed) + + # 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", 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() + + 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", 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", 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 + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +async def run_snake_test(): + """Run the enhanced endless Snake game test with advanced features.""" + from hud_server.tests.test_runner import TestContext + + print("=" * 60) + print("ENDLESS SNAKE GAME - ENHANCED EDITION") + print("=" * 60) + print("Features: Gradient Snake | Combos | Golden Apples | Animated Borders") + 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 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__": + asyncio.run(run_snake_test()) diff --git a/hud_server/tests/test_unicode_stress.py b/hud_server/tests/test_unicode_stress.py new file mode 100644 index 00000000..ae6131f9 --- /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/hud_server/types.py b/hud_server/types.py new file mode 100644 index 00000000..f89321cf --- /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/hud_server/validation.py b/hud_server/validation.py new file mode 100644 index 00000000..0ee4e309 --- /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/mcp_client.py b/services/mcp_client.py index 3be9bc9c..735cf132 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,54 @@ 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: + # 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) + + # 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} after {max_retries} attempts: {last_error}" + ) # Create a future to get the result from the SSE thread loop = asyncio.get_event_loop() @@ -700,6 +753,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 +816,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: diff --git a/services/migrations/migration_200_to_210.py b/services/migrations/migration_200_to_210.py index 14164a0e..2d3049f8 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/services/settings_service.py b/services/settings_service.py index 25a2502f..179b24f6 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/skills/hud/default_config.yaml b/skills/hud/default_config.yaml new file mode 100644 index 00000000..bd417ab3 --- /dev/null +++ b/skills/hud/default_config.yaml @@ -0,0 +1,223 @@ +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: chat_anchor + name: Chat Window Position + hint: Screen position/anchor 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: persistent_anchor + name: Info Panel Position + 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: 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: 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: hud_width + name: Chat Window Width + hint: Width of the chat window in pixels. + property_type: number + value: 400 + 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: 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_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: 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. Available options: Segoe UI, Arial, Verdana, Tahoma, Trebuchet MS, Calibri, Consolas, Courier New, Open Sans." + property_type: single_select + value: "Segoe UI" + required: false + options: + - label: "Segoe UI" + value: "Segoe UI" + - label: "Arial" + value: "Arial" + - label: "Verdana" + value: "Verdana" + - label: "Tahoma" + value: "Tahoma" + - label: "Trebuchet MS" + value: "Trebuchet MS" + - label: "Calibri" + value: "Calibri" + - label: "Consolas" + value: "Consolas" + - label: "Courier New" + value: "Courier New" + + - id: accent_color + name: Accent Color + hint: Accent color for assistant messages and highlights (hex format). + property_type: color + value: "#00aaff" + required: false + + - id: bg_color + name: Background Color + hint: Background color of HUD elements (hex format, e.g. #1e212b or #1e212b80 for 50% transparent). + property_type: color + value: "#1e212b" + required: false + + - id: text_color + name: Text Color + hint: Main text color (hex format). + property_type: color + value: "#f0f0f0" + 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: 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: 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. + 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 00000000..0cf380d4 Binary files /dev/null and b/skills/hud/logo.png differ diff --git a/skills/hud/main.py b/skills/hud/main.py new file mode 100644 index 00000000..483c8f14 --- /dev/null +++ b/skills/hud/main.py @@ -0,0 +1,1328 @@ +""" +HUD Skill - Display messages, info panels, progress bars, and timers on a HUD overlay. + +This skill uses the integrated HUD Server (enabled in global settings) to display +information on a transparent overlay. It supports: +- Chat message display (user and assistant messages) +- Persistent information panels +- Progress bars +- Countdown timers + +The HUD Server must be enabled in global settings for this skill to work. +""" + +import asyncio +import inspect +import json +import os +import threading +import time +import re +from os import path +from typing import TYPE_CHECKING, Optional + +from api.enums import LogType, WingmanInitializationErrorType +from api.interface import SettingsConfig, SkillConfig, WingmanInitializationError +from services.file import get_writable_dir +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, WindowType +from hud_server.validation import validate_hud_settings + +if TYPE_CHECKING: + from wingmen.open_ai_wingman import OpenAiWingman + +printr = Printr() + + +class HUD(Skill): + """ + HUD Skill - Display information on a transparent overlay. + + 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, + settings: SettingsConfig, + wingman: "OpenAiWingman" + ) -> None: + super().__init__(config=config, settings=settings, wingman=wingman) + + # State + self.active = False + self.stop_event = threading.Event() + 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 + + # Group name for HUD (just wingman identifier, element passed separately) + 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 ─────────────────────────────── # + + 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 accent_color + accent_color = self.retrieve_custom_property_value("accent_color", errors) + if not self._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 self._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 self._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 self.VALID_ANCHORS: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid chat_anchor: '{chat_anchor}'. Must be one of: {', '.join(self.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 self.VALID_ANCHORS: + errors.append( + WingmanInitializationError( + wingman_name=self.wingman.name, + message=f"Invalid persistent_anchor: '{persistent_anchor}'. Must be one of: {', '.join(self.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 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 (0-100 range from slider) + opacity = self.retrieve_custom_property_value("opacity", errors) + 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 and 100.", + 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) -> MessageProps: + """Get all HUD visual properties as a dictionary.""" + 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", 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)), + 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) -> PersistentProps: + """Get properties for persistent info panels.""" + 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)), + 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)), + 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)), + 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.""" + # 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: + return + + if not await self._ensure_connected(): + return + + # Get new props + msg_props = self._get_hud_props() + pers_props = self._get_persistent_props() + + # Delete and recreate message group + 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._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: + await self._restore_persistent_items() + + 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: + 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 + try: + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + pass + + 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._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(): + 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 + 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 + try: + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + pass + + 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._group_name, WindowType.MESSAGE, props=msg_props) + await self._client.create_group(self._group_name, WindowType.PERSISTENT, 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() + + # 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: + 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: + try: + await self._client.disconnect() + except Exception: + pass + self._client = None + + self.active = False + + # ─────────────────────────────── 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: + self.expecting_audio = False + + # Hide message when audio stops + if was_playing and not is_playing: + 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 self._hide_message() + 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: + 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: Optional[list] = None, + duration: float = 180.0 + ): + """Show a message on the HUD.""" + if not await self._ensure_connected(): + return + + props = self._get_hud_props() + props.fade_delay = duration + + result = await self._client.show_message( + group_name=self._group_name, + element=WindowType.MESSAGE, + 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(): + return + + 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._group_name, element=WindowType.MESSAGE, 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._group_name, + element=WindowType.PERSISTENT, + 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._group_name, + element=WindowType.PERSISTENT, + 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._group_name, + element=WindowType.PERSISTENT, + 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 + + # 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: + """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: + 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 + + tools_data.append({ + 'name': tool_name, + 'source': source, + 'type': source_type, + 'icon': icon_path + }) + + if 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() + async 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. + """ + 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] = { + 'description': description_markdown, + 'duration': valid_duration, + 'added_at': time.time(), + 'expiry': time.time() + valid_duration if valid_duration else None + } + + self._send_command_sync( + self._client.add_item( + group_name=self._group_name, + element=WindowType.PERSISTENT, + title=title, + description=description_markdown, + duration=valid_duration + ) + ) + + self._save_persistent_items() + return f"Added/Updated info panel: {title}" + + @tool() + 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) + + 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() + async 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() + 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) + + 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._group_name, element=WindowType.PERSISTENT, title=title) + ) + + if save: + self._save_persistent_items() + + return f"Cleared {cleared_count} item(s) from HUD." + + @tool() + async 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 not await self._ensure_connected(): + return "HUD server is not available." + + 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._group_name, + element=WindowType.PERSISTENT, + 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() + async 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 not await self._ensure_connected(): + return "HUD server is not available." + + 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._group_name, + element=WindowType.PERSISTENT, + 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() + async 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 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." + + 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._group_name, + element=WindowType.PERSISTENT, + 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() + async 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 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." + + 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._group_name, + element=WindowType.PERSISTENT, + title=title, + description=description_markdown, + duration=send_duration + ) + ) + + self._save_persistent_items() + return f"Updated info panel: {title}" + + @tool() + async 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. + """ + 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 + ) + ) + self._send_command_sync( + self._client.hide_element( + group_name=self._group_name, + element=WindowType.MESSAGE + ) + ) + + return "HUD is now hidden." + + @tool() + async 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." diff --git a/templates/configs/settings.yaml b/templates/configs/settings.yaml index da864f83..72c018f6 100644 --- a/templates/configs/settings.yaml +++ b/templates/configs/settings.yaml @@ -43,4 +43,12 @@ xvasynth: process_device: cpu pocket_tts: enable: true - custom_model_path: "" \ No newline at end of file + custom_model_path: "" +hud_server: + enabled: false + 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 f3b545c1..061f6508 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 @@ -58,6 +59,8 @@ from services.system_manager import SystemManager 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): @@ -349,6 +352,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 @@ -381,6 +387,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, @@ -422,6 +431,104 @@ 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() + + 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) + 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: + validated = self._get_validated_hud_settings(hud_settings) + self._hud_server = HudServer() + # 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, + server_only=False, + ) + self._hud_server = None + except Exception as e: + self.printr.print( + f"[HUD] Server error: {e}", + color=LogType.ERROR, + 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 + 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() + elif should_run and is_running: + # Server already running - update settings without restart + try: + self._hud_server.update_settings( + framerate=validated['framerate'], + layout_margin=validated['layout_margin'], + layout_spacing=validated['layout_spacing'], + screen=validated['screen'], + ) + 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.""" + 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,10 +1699,13 @@ 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() + 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( diff --git a/wingmen/open_ai_wingman.py b/wingmen/open_ai_wingman.py index 742447c7..96c2f801 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. @@ -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.