# Description: Transparent HUD Overlay for Lemontropia Suite # Implements frameless, always-on-top, click-through overlay with draggable support # Follows Never-Break Rules: Decimal precision, 60+ FPS, Observer Pattern integration import sys import json from pathlib import Path from decimal import Decimal from datetime import datetime, timedelta from dataclasses import dataclass, asdict from typing import Optional, Dict, Any # Windows-specific imports for click-through support if sys.platform == 'win32': import ctypes from ctypes import wintypes from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QSizePolicy ) from PyQt6.QtCore import ( Qt, QTimer, pyqtSignal, QPoint, QSettings, QObject ) from PyQt6.QtGui import QFont, QColor, QPalette, QMouseEvent @dataclass class HUDStats: """Data structure for HUD statistics.""" session_time: timedelta = timedelta(0) loot_total: Decimal = Decimal('0.0') damage_dealt: int = 0 damage_taken: int = 0 kills: int = 0 globals_count: int = 0 hofs_count: int = 0 current_weapon: str = "None" current_loadout: str = "None" def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization.""" return { 'session_time_seconds': self.session_time.total_seconds(), 'loot_total': str(self.loot_total), 'damage_dealt': self.damage_dealt, 'damage_taken': self.damage_taken, 'kills': self.kills, 'globals_count': self.globals_count, 'hofs_count': self.hofs_count, 'current_weapon': self.current_weapon, 'current_loadout': self.current_loadout, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'HUDStats': """Create from dictionary.""" return cls( session_time=timedelta(seconds=data.get('session_time_seconds', 0)), loot_total=Decimal(data.get('loot_total', '0.0')), damage_dealt=data.get('damage_dealt', 0), damage_taken=data.get('damage_taken', 0), kills=data.get('kills', 0), globals_count=data.get('globals_count', 0), hofs_count=data.get('hofs_count', 0), current_weapon=data.get('current_weapon', 'None'), current_loadout=data.get('current_loadout', 'None'), ) class HUDOverlay(QWidget): """ Transparent, always-on-top HUD overlay for Lemontropia Suite. Features: - Frameless window (no borders, title bar) - Transparent background with semi-transparent content - Always on top of other windows - Click-through when not holding modifier key - Draggable when holding Ctrl key - Position persistence across sessions - Real-time stat updates via signals/slots Window Flags: - FramelessWindowHint: Removes window decorations - WindowStaysOnTopHint: Keeps above other windows - Tool: Makes it a tool window (no taskbar entry) """ # Signal emitted when stats are updated (for external integration) stats_updated = pyqtSignal(dict) # Signal emitted when HUD is moved position_changed = pyqtSignal(QPoint) def __init__(self, parent: Optional[QObject] = None, config_path: Optional[str] = None): """ Initialize HUD Overlay. Args: parent: Parent widget (optional) config_path: Path to config file for position persistence """ super().__init__(parent) # Configuration path for saving position if config_path is None: ui_dir = Path(__file__).parent self.config_path = ui_dir.parent / "data" / "hud_config.json" else: self.config_path = Path(config_path) self.config_path.parent.mkdir(parents=True, exist_ok=True) # Session tracking self._session_start: Optional[datetime] = None self._stats = HUDStats() self.session_active = False # Public flag for session state # Drag state self._dragging = False self._drag_offset = QPoint() self._modifier_pressed = False # Timer for session time updates self._timer = QTimer(self) self._timer.timeout.connect(self._update_session_time) self._setup_window() self._setup_ui() self._load_position() def _setup_window(self) -> None: """Configure window properties for HUD behavior.""" # Window flags for frameless, always-on-top, tool window self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) # Enable transparency self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) # Enable mouse tracking for hover detection self.setMouseTracking(True) # Size self.setFixedSize(320, 220) # Accept focus for keyboard events (needed for modifier detection) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def _setup_ui(self) -> None: """Build the HUD UI components.""" # Main container with semi-transparent background self.container = QFrame(self) self.container.setFixedSize(320, 220) self.container.setObjectName("hudContainer") # Style the container - semi-transparent dark background self.container.setStyleSheet(""" #hudContainer { background-color: rgba(0, 0, 0, 180); border: 1px solid rgba(255, 215, 0, 80); border-radius: 8px; } QLabel { color: #FFFFFF; font-family: 'Segoe UI', 'Arial', sans-serif; } .stat-label { font-size: 11px; color: #CCCCCC; } .stat-value { font-size: 13px; font-weight: bold; color: #FFD700; } .header { font-size: 14px; font-weight: bold; color: #FFD700; } .subheader { font-size: 10px; color: #888888; } .positive { color: #7FFF7F; } .negative { color: #FF7F7F; } """) # Main layout layout = QVBoxLayout(self.container) layout.setContentsMargins(12, 10, 12, 10) layout.setSpacing(6) # === HEADER === header_layout = QHBoxLayout() self.title_label = QLabel("🍋 LEMONTROPIA") self.title_label.setProperty("class", "header") self.title_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #FFD700;") header_layout.addWidget(self.title_label) header_layout.addStretch() self.time_label = QLabel("00:00:00") self.time_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #00FFFF;") header_layout.addWidget(self.time_label) layout.addLayout(header_layout) # Drag hint (only visible on hover) self.drag_hint = QLabel("Hold Ctrl to drag") self.drag_hint.setStyleSheet("font-size: 9px; color: #666666;") self.drag_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.drag_hint) # === SEPARATOR === separator = QFrame() separator.setFrameShape(QFrame.Shape.HLine) separator.setStyleSheet("background-color: rgba(255, 215, 0, 50);") separator.setFixedHeight(1) layout.addWidget(separator) # === STATS GRID === # Row 1: Loot & Kills row1 = QHBoxLayout() # Loot loot_layout = QVBoxLayout() loot_label = QLabel("💰 LOOT") loot_label.setStyleSheet("font-size: 10px; color: #888888;") loot_layout.addWidget(loot_label) self.loot_value_label = QLabel("0.00 PED") self.loot_value_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #7FFF7F;") loot_layout.addWidget(self.loot_value_label) row1.addLayout(loot_layout) row1.addStretch() # Kills kills_layout = QVBoxLayout() kills_label = QLabel("💀 KILLS") kills_label.setStyleSheet("font-size: 10px; color: #888888;") kills_layout.addWidget(kills_label) self.kills_value_label = QLabel("0") self.kills_value_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #FFFFFF;") kills_layout.addWidget(self.kills_value_label) row1.addLayout(kills_layout) row1.addStretch() # Globals/HoFs globals_layout = QVBoxLayout() globals_label = QLabel("🌍 GLOBALS") globals_label.setStyleSheet("font-size: 10px; color: #888888;") globals_layout.addWidget(globals_label) self.globals_value_label = QLabel("0 / 0") self.globals_value_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #FFD700;") globals_layout.addWidget(self.globals_value_label) row1.addLayout(globals_layout) layout.addLayout(row1) # Row 2: Damage row2 = QHBoxLayout() # Damage Dealt dealt_layout = QVBoxLayout() dealt_label = QLabel("⚔️ DEALT") dealt_label.setStyleSheet("font-size: 10px; color: #888888;") dealt_layout.addWidget(dealt_label) self.dealt_value_label = QLabel("0") self.dealt_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #7FFF7F;") dealt_layout.addWidget(self.dealt_value_label) row2.addLayout(dealt_layout) row2.addStretch() # Damage Taken taken_layout = QVBoxLayout() taken_label = QLabel("🛡️ TAKEN") taken_label.setStyleSheet("font-size: 10px; color: #888888;") taken_layout.addWidget(taken_label) self.taken_value_label = QLabel("0") self.taken_value_label.setStyleSheet("font-size: 13px; font-weight: bold; color: #FF7F7F;") taken_layout.addWidget(self.taken_value_label) row2.addLayout(taken_layout) row2.addStretch() layout.addLayout(row2) # === WEAPON INFO === weapon_separator = QFrame() weapon_separator.setFrameShape(QFrame.Shape.HLine) weapon_separator.setStyleSheet("background-color: rgba(255, 215, 0, 30);") weapon_separator.setFixedHeight(1) layout.addWidget(weapon_separator) weapon_layout = QHBoxLayout() weapon_icon = QLabel("🔫") weapon_icon.setStyleSheet("font-size: 12px;") weapon_layout.addWidget(weapon_icon) self.weapon_label = QLabel("No weapon") self.weapon_label.setStyleSheet("font-size: 11px; color: #CCCCCC;") weapon_layout.addWidget(self.weapon_label) weapon_layout.addStretch() loadout_label = QLabel("Loadout:") loadout_label.setStyleSheet("font-size: 10px; color: #888888;") weapon_layout.addWidget(loadout_label) self.loadout_label = QLabel("None") self.loadout_label.setStyleSheet("font-size: 11px; color: #00FFFF;") weapon_layout.addWidget(self.loadout_label) layout.addLayout(weapon_layout) # === STATUS BAR === self.status_label = QLabel("● Live") self.status_label.setStyleSheet("font-size: 9px; color: #7FFF7F;") self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.status_label) # ======================================================================== # POSITION PERSISTENCE # ======================================================================== def _load_position(self) -> None: """Load saved position from config file.""" try: if self.config_path.exists(): with open(self.config_path, 'r') as f: config = json.load(f) x = config.get('x', 100) y = config.get('y', 100) self.move(x, y) # Load saved stats if available if 'stats' in config: self._stats = HUDStats.from_dict(config['stats']) self._refresh_display() else: # Default position: top-right of screen screen = QApplication.primaryScreen().geometry() self.move(screen.width() - 350, 50) except Exception as e: # Default position on error self.move(100, 100) def _save_position(self) -> None: """Save current position to config file.""" try: config = { 'x': self.x(), 'y': self.y(), 'stats': self._stats.to_dict(), } with open(self.config_path, 'w') as f: json.dump(config, f, indent=2) except Exception as e: pass # Silent fail on save error # ======================================================================== # MOUSE HANDLING (Drag Support) # ======================================================================== def mousePressEvent(self, event: QMouseEvent) -> None: """Handle mouse press - start drag if Ctrl is held.""" if event.button() == Qt.MouseButton.LeftButton: # Check if Ctrl is held if event.modifiers() == Qt.KeyboardModifier.ControlModifier: self._dragging = True self._drag_offset = event.pos() self.setCursor(Qt.CursorShape.ClosedHandCursor) self._enable_click_through(False) # Disable click-through for dragging event.accept() else: # Enable click-through and pass to underlying window self._enable_click_through(True) event.ignore() def mouseMoveEvent(self, event: QMouseEvent) -> None: """Handle mouse move - drag window if in drag mode.""" if self._dragging: # Move window new_pos = self.mapToGlobal(event.pos()) - self._drag_offset self.move(new_pos) self.position_changed.emit(new_pos) event.accept() else: # Show drag hint on hover self.drag_hint.setStyleSheet("font-size: 9px; color: #FFD700;") event.ignore() def mouseReleaseEvent(self, event: QMouseEvent) -> None: """Handle mouse release - end drag and save position.""" if event.button() == Qt.MouseButton.LeftButton and self._dragging: self._dragging = False self.setCursor(Qt.CursorShape.ArrowCursor) self._save_position() # Re-enable click-through after drag self._enable_click_through(True) event.accept() else: event.ignore() def leaveEvent(self, event) -> None: """Handle mouse leave - reset drag hint.""" self.drag_hint.setStyleSheet("font-size: 9px; color: #666666;") super().leaveEvent(event) def _enable_click_through(self, enable: bool) -> None: """ Enable or disable click-through behavior. When enabled, mouse events pass through to the window below. When disabled (Ctrl held), window captures mouse events for dragging. On Windows: Uses WinAPI for proper click-through support. On other platforms: Uses Qt's WA_TransparentForMouseEvents. """ if sys.platform == 'win32': self._set_click_through_win32(enable) else: # Use Qt's built-in for non-Windows platforms self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enable) def _set_click_through_win32(self, enabled: bool) -> None: """ Enable/disable click-through on Windows using WinAPI. Uses SetWindowLongW to modify the window's extended style flags: - WS_EX_TRANSPARENT (0x00000020): Allows mouse events to pass through - WS_EX_LAYERED (0x00080000): Required for transparency effects Args: enabled: True to enable click-through, False to capture mouse events """ GWL_EXSTYLE = -20 WS_EX_TRANSPARENT = 0x00000020 WS_EX_LAYERED = 0x00080000 try: hwnd = self.winId().__int__() # Get current extended style style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) if enabled: style |= WS_EX_TRANSPARENT | WS_EX_LAYERED else: style &= ~(WS_EX_TRANSPARENT | WS_EX_LAYERED) ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style) except Exception: # Fallback to Qt method if WinAPI fails self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, enabled) def keyPressEvent(self, event) -> None: """Handle key press - detect Ctrl for drag mode.""" if event.key() == Qt.Key.Key_Control: self._modifier_pressed = True self._enable_click_through(False) self.drag_hint.setText("✋ Drag mode ON") self.drag_hint.setStyleSheet("font-size: 9px; color: #00FF00;") super().keyPressEvent(event) def keyReleaseEvent(self, event) -> None: """Handle key release - detect Ctrl release.""" if event.key() == Qt.Key.Key_Control: self._modifier_pressed = False self._enable_click_through(True) self.drag_hint.setText("Hold Ctrl to drag") self.drag_hint.setStyleSheet("font-size: 9px; color: #666666;") super().keyReleaseEvent(event) # ======================================================================== # SESSION MANAGEMENT # ======================================================================== def start_session(self, weapon: str = "Unknown", loadout: str = "Default") -> None: """Start a new hunting/mining/crafting session. Args: weapon: Name of the current weapon loadout: Name of the current loadout """ self._session_start = datetime.now() self._stats = HUDStats() # Reset stats self._stats.current_weapon = weapon self._stats.current_loadout = loadout self.session_active = True self._timer.start(1000) # Update every second self._refresh_display() self.status_label.setText("● Live - Recording") self.status_label.setStyleSheet("font-size: 9px; color: #7FFF7F;") def end_session(self) -> None: """End the current session.""" self._timer.stop() self._session_start = None self.session_active = False self._save_position() # Save final stats self.status_label.setText("○ Paused") self.status_label.setStyleSheet("font-size: 9px; color: #888888;") # ======================================================================== # EVENT HANDLERS (Called from LogWatcher) # ======================================================================== def on_loot_event(self, item_name: str, value_ped: Decimal) -> None: """Called when loot is received from LogWatcher. Args: item_name: Name of the looted item value_ped: Value in PED (Decimal for precision) """ if not self.session_active: return self._stats.loot_total += value_ped # Count actual loot as kills (exclude Shrapnel and Universal Ammo) if item_name not in ('Shrapnel', 'Universal Ammo'): self._stats.kills += 1 self._refresh_display() self.stats_updated.emit(self._stats.to_dict()) def on_damage_dealt(self, damage: float) -> None: """Called when damage is dealt. Args: damage: Amount of damage dealt """ if not self.session_active: return self._stats.damage_dealt += int(damage) self._refresh_display() self.stats_updated.emit(self._stats.to_dict()) def on_damage_taken(self, damage: float) -> None: """Called when damage is taken. Args: damage: Amount of damage taken """ if not self.session_active: return self._stats.damage_taken += int(damage) self._refresh_display() self.stats_updated.emit(self._stats.to_dict()) def on_global(self, value_ped: Decimal = Decimal('0.0')) -> None: """Called on global event. Args: value_ped: Value of the global in PED """ if not self.session_active: return self._stats.globals_count += 1 self._refresh_display() self.stats_updated.emit(self._stats.to_dict()) def on_hof(self, value_ped: Decimal = Decimal('0.0')) -> None: """Called on Hall of Fame event. Args: value_ped: Value of the HoF in PED """ if not self.session_active: return self._stats.hofs_count += 1 self._refresh_display() self.stats_updated.emit(self._stats.to_dict()) def update_display(self) -> None: """Public method to refresh display (alias for _refresh_display).""" self._refresh_display() def _update_session_time(self) -> None: """Update the session time display.""" if self._session_start: self._stats.session_time = datetime.now() - self._session_start self._update_time_display() def _update_time_display(self) -> None: """Update the time label.""" total_seconds = int(self._stats.session_time.total_seconds()) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 seconds = total_seconds % 60 self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}") # ======================================================================== # STATS UPDATE INTERFACE # ======================================================================== def update_stats(self, stats: Dict[str, Any]) -> None: """ Update HUD with new statistics. This is the main interface for LogWatcher integration. Called via signals/slots when new data arrives. Args: stats: Dictionary containing stat updates. Supported keys: - 'loot': Decimal - Total loot value in PED - 'loot_delta': Decimal - Add to existing loot - 'damage_dealt': int - Total damage dealt (or add) - 'damage_dealt_add': int - Add to damage dealt - 'damage_taken': int - Total damage taken (or add) - 'damage_taken_add': int - Add to damage taken - 'kills': int - Total kills (or add) - 'kills_add': int - Add to kills - 'globals': int - Total globals (or add) - 'globals_add': int - Add to globals - 'hofs': int - Total HoFs (or add) - 'hofs_add': int - Add to HoFs - 'weapon': str - Current weapon name - 'loadout': str - Current loadout name """ # Loot (Decimal precision) if 'loot' in stats: self._stats.loot_total = Decimal(str(stats['loot'])) elif 'loot_delta' in stats: self._stats.loot_total += Decimal(str(stats['loot_delta'])) # Damage dealt if 'damage_dealt' in stats: self._stats.damage_dealt = int(stats['damage_dealt']) elif 'damage_dealt_add' in stats: self._stats.damage_dealt += int(stats['damage_dealt_add']) # Damage taken if 'damage_taken' in stats: self._stats.damage_taken = int(stats['damage_taken']) elif 'damage_taken_add' in stats: self._stats.damage_taken += int(stats['damage_taken_add']) # Kills if 'kills' in stats: self._stats.kills = int(stats['kills']) elif 'kills_add' in stats: self._stats.kills += int(stats['kills_add']) # Globals if 'globals' in stats: self._stats.globals_count = int(stats['globals']) elif 'globals_add' in stats: self._stats.globals_count += int(stats['globals_add']) # HoFs if 'hofs' in stats: self._stats.hofs_count = int(stats['hofs']) elif 'hofs_add' in stats: self._stats.hofs_count += int(stats['hofs_add']) # Weapon if 'weapon' in stats: self._stats.current_weapon = str(stats['weapon']) # Loadout if 'loadout' in stats: self._stats.current_loadout = str(stats['loadout']) # Refresh display self._refresh_display() # Emit signal for external listeners self.stats_updated.emit(self._stats.to_dict()) def _refresh_display(self) -> None: """Refresh all display labels with current stats.""" # Loot with 2 decimal places (PED format) self.loot_value_label.setText(f"{self._stats.loot_total:.2f} PED") # Kills self.kills_value_label.setText(str(self._stats.kills)) # Globals / HoFs self.globals_value_label.setText( f"{self._stats.globals_count} / {self._stats.hofs_count}" ) # Damage self.dealt_value_label.setText(str(self._stats.damage_dealt)) self.taken_value_label.setText(str(self._stats.damage_taken)) # Weapon/Loadout self.weapon_label.setText(self._stats.current_weapon[:20]) # Truncate long names self.loadout_label.setText(self._stats.current_loadout[:15]) # Update time if session active self._update_time_display() def get_stats(self) -> HUDStats: """Get current stats.""" return self._stats # ======================================================================== # WINDOW OVERRIDES # ======================================================================== def showEvent(self, event) -> None: """Handle show event - ensure proper window attributes.""" # Re-apply click-through on show if not self._modifier_pressed: self._enable_click_through(True) super().showEvent(event) def moveEvent(self, event) -> None: """Handle move event - save position periodically.""" # Save position every 5 seconds of movement (throttled) if not hasattr(self, '_last_save'): self._last_save = 0 import time current_time = time.time() if current_time - self._last_save > 5: self._save_position() self._last_save = current_time super().moveEvent(event) def closeEvent(self, event) -> None: """Handle close event - save position before closing.""" self._save_position() super().closeEvent(event) # ============================================================================ # MOCK TESTING # ============================================================================ def run_mock_test(): """Run the HUD with mock data for testing.""" app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) # Keep running when closed # Create HUD hud = HUDOverlay() hud.show() # Start session hud.start_session() # Simulate incoming stats from PyQt6.QtCore import QTimer mock_stats = { 'loot': Decimal('0.0'), 'damage_dealt': 0, 'damage_taken': 0, 'kills': 0, 'globals': 0, 'hofs': 0, 'weapon': 'Omegaton M2100', 'loadout': 'Hunting Set A', } def simulate_event(): """Simulate random game events.""" import random event_type = random.choice(['loot', 'damage', 'kill', 'global']) if event_type == 'loot': value = Decimal(str(random.uniform(0.5, 15.0))) mock_stats['loot'] += value hud.update_stats({'loot': mock_stats['loot']}) print(f"[MOCK] Loot: {value:.2f} PED") elif event_type == 'damage': damage = random.randint(5, 50) mock_stats['damage_dealt'] += damage hud.update_stats({'damage_dealt': mock_stats['damage_dealt']}) print(f"[MOCK] Damage dealt: {damage}") elif event_type == 'kill': mock_stats['kills'] += 1 hud.update_stats({'kills': mock_stats['kills']}) print(f"[MOCK] Kill! Total: {mock_stats['kills']}") elif event_type == 'global': mock_stats['globals'] += 1 hud.update_stats({'globals': mock_stats['globals']}) print(f"[MOCK] GLOBAL!!! Count: {mock_stats['globals']}") # Simulate events every 3 seconds timer = QTimer() timer.timeout.connect(simulate_event) timer.start(3000) print("\n" + "="*60) print("🍋 LEMONTROPIA HUD - MOCK TEST MODE") print("="*60) print("HUD should be visible on screen (top-right default)") print("Features to test:") print(" ✓ Frameless window with gold border") print(" ✓ Transparent background") print(" ✓ Always on top") print(" ✓ Click-through (clicks pass to window below)") print(" ✓ Hold Ctrl to drag the HUD") print(" ✓ Stats update every 3 seconds (mock data)") print("\nPress Ctrl+C in terminal to exit") print("="*60 + "\n") sys.exit(app.exec()) if __name__ == "__main__": run_mock_test()