Lemontropia-Suite/ui/hud_overlay.py

708 lines
26 KiB
Python

# 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
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()
# 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)
event.accept()
else:
# Pass through to underlying window
event.ignore()
self._enable_click_through(True)
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()
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.
"""
if enable:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
else:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
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) -> None:
"""Start a new hunting/mining/crafting session."""
self._session_start = datetime.now()
self._stats = HUDStats() # Reset stats
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._save_position() # Save final stats
self.status_label.setText("○ Paused")
self.status_label.setStyleSheet("font-size: 9px; color: #888888;")
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()