""" Lemontropia Suite - HUD Overlay v2.0 Cleaner, customizable HUD with collapsible sections. """ import sys import json from pathlib import Path from decimal import Decimal from datetime import datetime, timedelta from dataclasses import dataclass, asdict, field from typing import Optional, Dict, Any, List, Set # Windows-specific imports for click-through support if sys.platform == 'win32': import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 GetForegroundWindow = user32.GetForegroundWindow GetForegroundWindow.restype = wintypes.HWND from PyQt6.QtWidgets import ( QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QSizePolicy, QPushButton, QMenu, QDialog, QCheckBox, QFormLayout, QScrollArea ) from PyQt6.QtCore import ( Qt, QTimer, pyqtSignal, QPoint, QSettings, QObject ) from PyQt6.QtGui import QFont, QColor, QPalette, QMouseEvent @dataclass class HUDConfig: """Configuration for which HUD elements to show.""" show_session_time: bool = True show_profit_loss: bool = True show_return_pct: bool = True show_cost_breakdown: bool = False # Weapon/Armor/Heal costs show_combat_stats: bool = False # Kills, Globals, DPP show_damage_stats: bool = False # Damage dealt/taken show_cost_metrics: bool = True # Cost per shot/hit/heal show_shrapnel: bool = False show_gear_info: bool = True compact_mode: bool = False def to_dict(self) -> dict: return { 'show_session_time': self.show_session_time, 'show_profit_loss': self.show_profit_loss, 'show_return_pct': self.show_return_pct, 'show_cost_breakdown': self.show_cost_breakdown, 'show_combat_stats': self.show_combat_stats, 'show_damage_stats': self.show_damage_stats, 'show_cost_metrics': self.show_cost_metrics, 'show_shrapnel': self.show_shrapnel, 'show_gear_info': self.show_gear_info, 'compact_mode': self.compact_mode, } @classmethod def from_dict(cls, data: dict) -> "HUDConfig": return cls( show_session_time=data.get('show_session_time', True), show_profit_loss=data.get('show_profit_loss', True), show_return_pct=data.get('show_return_pct', True), show_cost_breakdown=data.get('show_cost_breakdown', False), show_combat_stats=data.get('show_combat_stats', False), show_damage_stats=data.get('show_damage_stats', False), show_cost_metrics=data.get('show_cost_metrics', True), show_shrapnel=data.get('show_shrapnel', False), show_gear_info=data.get('show_gear_info', True), compact_mode=data.get('compact_mode', False), ) @dataclass class HUDStats: """Simplified stats for HUD display.""" session_time: timedelta = field(default_factory=lambda: timedelta(0)) # Financial (core) loot_total: Decimal = Decimal('0.0') cost_total: Decimal = Decimal('0.0') profit_loss: Decimal = Decimal('0.0') return_percentage: Decimal = Decimal('0.0') # Cost breakdown (optional) weapon_cost_total: Decimal = Decimal('0.0') armor_cost_total: Decimal = Decimal('0.0') healing_cost_total: Decimal = Decimal('0.0') # Combat (optional) kills: int = 0 globals_count: int = 0 hofs_count: int = 0 # Cost metrics (core) cost_per_shot: Decimal = Decimal('0.0') cost_per_hit: Decimal = Decimal('0.0') cost_per_heal: Decimal = Decimal('0.0') # Gear current_weapon: str = "None" current_armor: str = "None" current_fap: str = "None" current_loadout: str = "None" def recalculate(self): """Recalculate derived values.""" self.profit_loss = self.loot_total - self.cost_total if self.cost_total > 0: self.return_percentage = (self.loot_total / self.cost_total) * Decimal('100') else: self.return_percentage = Decimal('0') def to_dict(self) -> dict: return { 'session_time': str(self.session_time), 'loot_total': str(self.loot_total), 'cost_total': str(self.cost_total), 'profit_loss': str(self.profit_loss), 'return_percentage': str(self.return_percentage), 'weapon_cost_total': str(self.weapon_cost_total), 'armor_cost_total': str(self.armor_cost_total), 'healing_cost_total': str(self.healing_cost_total), 'kills': self.kills, 'globals_count': self.globals_count, 'hofs_count': self.hofs_count, 'cost_per_shot': str(self.cost_per_shot), 'cost_per_hit': str(self.cost_per_hit), 'cost_per_heal': str(self.cost_per_heal), 'current_weapon': self.current_weapon, 'current_armor': self.current_armor, 'current_fap': self.current_fap, 'current_loadout': self.current_loadout, } class HUDSettingsDialog(QDialog): """Dialog to configure which HUD elements to show.""" def __init__(self, config: HUDConfig, parent=None): super().__init__(parent) self.setWindowTitle("HUD Settings") self.setMinimumWidth(300) self.config = config self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(True) content = QWidget() form = QFormLayout(content) # Core stats (always recommended) form.addRow(QLabel("Core Stats (Recommended)")) self.cb_time = QCheckBox("Session Time") self.cb_time.setChecked(self.config.show_session_time) form.addRow(self.cb_time) self.cb_profit = QCheckBox("Profit/Loss") self.cb_profit.setChecked(self.config.show_profit_loss) form.addRow(self.cb_profit) self.cb_return = QCheckBox("Return %") self.cb_return.setChecked(self.config.show_return_pct) form.addRow(self.cb_return) self.cb_cost_metrics = QCheckBox("Cost per Shot/Hit/Heal") self.cb_cost_metrics.setChecked(self.config.show_cost_metrics) form.addRow(self.cb_cost_metrics) self.cb_gear = QCheckBox("Current Gear") self.cb_gear.setChecked(self.config.show_gear_info) form.addRow(self.cb_gear) # Optional stats form.addRow(QLabel("Optional Stats")) self.cb_cost_breakdown = QCheckBox("Cost Breakdown (Weapon/Armor/Heal)") self.cb_cost_breakdown.setChecked(self.config.show_cost_breakdown) form.addRow(self.cb_cost_breakdown) self.cb_combat = QCheckBox("Combat Stats (Kills, Globals)") self.cb_combat.setChecked(self.config.show_combat_stats) form.addRow(self.cb_combat) self.cb_damage = QCheckBox("Damage Stats (Dealt/Taken)") self.cb_damage.setChecked(self.config.show_damage_stats) form.addRow(self.cb_damage) self.cb_shrapnel = QCheckBox("Shrapnel Amount") self.cb_shrapnel.setChecked(self.config.show_shrapnel) form.addRow(self.cb_shrapnel) # Display mode form.addRow(QLabel("Display Mode")) self.cb_compact = QCheckBox("Compact Mode (smaller font)") self.cb_compact.setChecked(self.config.compact_mode) form.addRow(self.cb_compact) scroll.setWidget(content) layout.addWidget(scroll) # Buttons btn_layout = QHBoxLayout() btn_layout.addStretch() save_btn = QPushButton("Save") save_btn.clicked.connect(self._on_save) btn_layout.addWidget(save_btn) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) def _on_save(self): self.config.show_session_time = self.cb_time.isChecked() self.config.show_profit_loss = self.cb_profit.isChecked() self.config.show_return_pct = self.cb_return.isChecked() self.config.show_cost_metrics = self.cb_cost_metrics.isChecked() self.config.show_gear_info = self.cb_gear.isChecked() self.config.show_cost_breakdown = self.cb_cost_breakdown.isChecked() self.config.show_combat_stats = self.cb_combat.isChecked() self.config.show_damage_stats = self.cb_damage.isChecked() self.config.show_shrapnel = self.cb_shrapnel.isChecked() self.config.compact_mode = self.cb_compact.isChecked() self.accept() def get_config(self) -> HUDConfig: return self.config class HUDOverlay(QWidget): """ Clean, customizable HUD Overlay for Lemontropia Suite. Features: - Collapsible sections - Customizable display elements - Compact mode - Draggable with Ctrl+click """ position_changed = pyqtSignal(QPoint) stats_updated = pyqtSignal(dict) def __init__(self, parent=None, config_path: Optional[str] = None): super().__init__(parent) self.config_path = Path(config_path) if config_path else Path.home() / ".lemontropia" / "hud_config.json" self.config_path.parent.mkdir(parents=True, exist_ok=True) # Load HUD config self.hud_config = self._load_hud_config() # Session state self._session_start: Optional[datetime] = None self._stats = HUDStats() self.session_active = False # Drag state self._dragging = False self._drag_offset = QPoint() # Setup self._setup_window() self._setup_ui() self._load_position() # Timer for session time self._timer = QTimer(self) self._timer.timeout.connect(self._update_session_time) def _load_hud_config(self) -> HUDConfig: """Load HUD configuration.""" try: if self.config_path.exists(): with open(self.config_path, 'r') as f: data = json.load(f) return HUDConfig.from_dict(data) except Exception as e: pass return HUDConfig() # Default config def _save_hud_config(self): """Save HUD configuration.""" try: with open(self.config_path, 'w') as f: json.dump(self.hud_config.to_dict(), f, indent=2) except Exception as e: pass def _setup_window(self): """Configure window properties.""" self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) def _setup_ui(self): """Build the HUD UI.""" # Create container first self.container = QFrame(self) self.container.setObjectName("hudContainer") self.container.setStyleSheet(self._get_stylesheet()) # Now calculate and set size self._update_window_size() layout = QVBoxLayout(self.container) layout.setContentsMargins(12, 12, 12, 8) layout.setSpacing(4) # === HEADER: Status + Gear === header = QHBoxLayout() self.status_label = QLabel("● Ready") self.status_label.setStyleSheet("color: #7FFF7F; font-weight: bold;") header.addWidget(self.status_label) header.addStretch() # Settings button self.settings_btn = QPushButton("⚙️") self.settings_btn.setFixedSize(24, 24) self.settings_btn.setStyleSheet(""" QPushButton { background: transparent; border: none; font-size: 14px; } QPushButton:hover { background: rgba(255, 255, 255, 30); border-radius: 4px; } """) self.settings_btn.clicked.connect(self._show_settings) header.addWidget(self.settings_btn) layout.addLayout(header) # === GEAR INFO (if enabled) === if self.hud_config.show_gear_info: self.gear_frame = QFrame() gear_layout = QVBoxLayout(self.gear_frame) gear_layout.setContentsMargins(0, 0, 0, 0) gear_layout.setSpacing(2) self.weapon_label = QLabel("🔫 None") self.armor_label = QLabel("🛡️ None") self.loadout_label = QLabel("📋 No Loadout") gear_layout.addWidget(self.weapon_label) gear_layout.addWidget(self.armor_label) gear_layout.addWidget(self.loadout_label) layout.addWidget(self.gear_frame) # Separator sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) sep.setStyleSheet("background-color: rgba(255, 215, 0, 50);") sep.setFixedHeight(1) layout.addWidget(sep) # === CORE: P/L + Return % === if self.hud_config.show_profit_loss or self.hud_config.show_return_pct: core_frame = QFrame() core_layout = QHBoxLayout(core_frame) core_layout.setContentsMargins(0, 4, 0, 4) if self.hud_config.show_profit_loss: self.profit_label = QLabel("P/L: 0.00 PED") self.profit_label.setStyleSheet("font-size: 18px; font-weight: bold;") core_layout.addWidget(self.profit_label) core_layout.addStretch() if self.hud_config.show_return_pct: self.return_label = QLabel("0.0%") self.return_label.setStyleSheet("font-size: 16px; font-weight: bold;") core_layout.addWidget(self.return_label) layout.addWidget(core_frame) # === COST METRICS (if enabled) === if self.hud_config.show_cost_metrics: metrics_frame = QFrame() metrics_frame.setStyleSheet("background-color: rgba(0, 0, 0, 100); border-radius: 4px;") metrics_layout = QHBoxLayout(metrics_frame) metrics_layout.setContentsMargins(8, 4, 8, 4) self.cps_label = QLabel("Shot: 0.0000") self.cph_label = QLabel("Hit: 0.0000") self.cphl_label = QLabel("Heal: 0.0000") for lbl in [self.cps_label, self.cph_label, self.cphl_label]: lbl.setStyleSheet("font-size: 10px; color: #AAAAAA;") metrics_layout.addWidget(lbl) metrics_layout.addStretch() layout.addWidget(metrics_frame) # === OPTIONAL: Cost Breakdown === if self.hud_config.show_cost_breakdown: breakdown_frame = QFrame() breakdown_layout = QFormLayout(breakdown_frame) breakdown_layout.setContentsMargins(0, 0, 0, 0) breakdown_layout.setSpacing(2) self.wep_cost_label = QLabel("0.00") self.arm_cost_label = QLabel("0.00") self.heal_cost_label = QLabel("0.00") breakdown_layout.addRow("Weapon:", self.wep_cost_label) breakdown_layout.addRow("Armor:", self.arm_cost_label) breakdown_layout.addRow("Healing:", self.heal_cost_label) layout.addWidget(breakdown_frame) # === OPTIONAL: Combat Stats === if self.hud_config.show_combat_stats: combat_frame = QFrame() combat_layout = QHBoxLayout(combat_frame) combat_layout.setContentsMargins(0, 0, 0, 0) self.kills_label = QLabel("Kills: 0") self.globals_label = QLabel("Globals: 0") combat_layout.addWidget(self.kills_label) combat_layout.addStretch() combat_layout.addWidget(self.globals_label) layout.addWidget(combat_frame) # === FOOTER: Session Time + Drag Hint === footer = QHBoxLayout() if self.hud_config.show_session_time: self.time_label = QLabel("00:00:00") self.time_label.setStyleSheet("color: #888; font-size: 11px;") footer.addWidget(self.time_label) else: footer.addStretch() footer.addStretch() self.drag_hint = QLabel("Ctrl+drag to move") self.drag_hint.setStyleSheet("font-size: 9px; color: #666;") footer.addWidget(self.drag_hint) layout.addLayout(footer) def _get_stylesheet(self) -> str: """Get stylesheet based on compact mode.""" font_size = "11px" if self.hud_config.compact_mode else "12px" return f""" #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; font-size: {font_size}; }} """ def _update_window_size(self): """Calculate window size based on enabled features.""" height = 80 # Base height if self.hud_config.show_gear_info: height += 70 if self.hud_config.show_profit_loss or self.hud_config.show_return_pct: height += 40 if self.hud_config.show_cost_metrics: height += 30 if self.hud_config.show_cost_breakdown: height += 70 if self.hud_config.show_combat_stats: height += 25 if self.hud_config.show_session_time: height += 20 width = 280 if self.hud_config.compact_mode else 320 self.setFixedSize(width, height) self.container.setFixedSize(width, height) def _show_settings(self): """Show settings dialog.""" dialog = HUDSettingsDialog(self.hud_config, self) if dialog.exec() == QDialog.DialogCode.Accepted: self.hud_config = dialog.get_config() self._save_hud_config() # Rebuild UI with new settings self._rebuild_ui() def _rebuild_ui(self): """Rebuild UI with current config.""" # Delete old container and recreate if hasattr(self, 'container') and self.container: self.container.deleteLater() self.container = None # Rebuild everything self._setup_ui() self._refresh_display() # === Session Management === def start_session(self, weapon: str = "Unknown", armor: str = "None", fap: str = "None", loadout: str = "Default", cost_per_shot: Decimal = Decimal('0.0'), cost_per_hit: Decimal = Decimal('0.0'), cost_per_heal: Decimal = Decimal('0.0')) -> None: """Start a new hunting session.""" self._session_start = datetime.now() self._stats = HUDStats() self._stats.current_weapon = weapon self._stats.current_armor = armor self._stats.current_fap = fap self._stats.current_loadout = loadout self._stats.cost_per_shot = cost_per_shot self._stats.cost_per_hit = cost_per_hit self._stats.cost_per_heal = cost_per_heal self.session_active = True self._timer.start(1000) self._refresh_display() self.status_label.setText("● Live") self.status_label.setStyleSheet("color: #7FFF7F; font-weight: bold;") def stop_session(self) -> None: """Stop the current session.""" self.session_active = False self._timer.stop() self.status_label.setText("● Stopped") self.status_label.setStyleSheet("color: #FF7F7F; font-weight: bold;") # === Event Handlers === def _update_session_time(self): """Update session time display.""" if self._session_start and self.session_active: elapsed = datetime.now() - self._session_start hours, remainder = divmod(elapsed.seconds, 3600) minutes, seconds = divmod(remainder, 60) if hasattr(self, 'time_label'): self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}") def _refresh_display(self): """Refresh all display labels.""" # Profit/Loss if hasattr(self, 'profit_label'): profit = self._stats.profit_loss color = "#7FFF7F" if profit >= 0 else "#FF7F7F" self.profit_label.setText(f"{profit:+.2f} PED") self.profit_label.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {color};") # Return % if hasattr(self, 'return_label'): ret = self._stats.return_percentage if ret >= 100: color = "#7FFF7F" elif ret >= 90: color = "#FFFF7F" else: color = "#FF7F7F" self.return_label.setText(f"{ret:.1f}%") self.return_label.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {color};") # Cost metrics if hasattr(self, 'cps_label'): self.cps_label.setText(f"Shot: {self._stats.cost_per_shot:.4f}") if hasattr(self, 'cph_label'): self.cph_label.setText(f"Hit: {self._stats.cost_per_hit:.4f}") if hasattr(self, 'cphl_label'): self.cphl_label.setText(f"Heal: {self._stats.cost_per_heal:.4f}") # Gear if hasattr(self, 'weapon_label'): self.weapon_label.setText(f"🔫 {self._stats.current_weapon[:20]}") if hasattr(self, 'armor_label'): self.armor_label.setText(f"🛡️ {self._stats.current_armor[:20]}") if hasattr(self, 'loadout_label'): self.loadout_label.setText(f"📋 {self._stats.current_loadout[:20]}") # Cost breakdown if hasattr(self, 'wep_cost_label'): self.wep_cost_label.setText(f"{self._stats.weapon_cost_total:.2f}") if hasattr(self, 'arm_cost_label'): self.arm_cost_label.setText(f"{self._stats.armor_cost_total:.2f}") if hasattr(self, 'heal_cost_label'): self.heal_cost_label.setText(f"{self._stats.healing_cost_total:.2f}") # Combat if hasattr(self, 'kills_label'): self.kills_label.setText(f"Kills: {self._stats.kills}") if hasattr(self, 'globals_label'): self.globals_label.setText(f"Globals: {self._stats.globals_count}") # === Public Update Methods === def update_loot(self, value_ped: Decimal): """Update loot value.""" if self.session_active: self._stats.loot_total += value_ped self._stats.recalculate() self._refresh_display() def update_weapon_cost(self, cost_ped: Decimal): """Update weapon cost.""" if self.session_active: self._stats.weapon_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._refresh_display() def update_armor_cost(self, cost_ped: Decimal): """Update armor cost.""" if self.session_active: self._stats.armor_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._refresh_display() def update_healing_cost(self, cost_ped: Decimal): """Update healing cost.""" if self.session_active: self._stats.healing_cost_total += cost_ped self._stats.cost_total += cost_ped self._stats.recalculate() self._refresh_display() def update_kills(self, count: int = 1): """Update kill count.""" if self.session_active: self._stats.kills += count self._refresh_display() def update_globals(self): """Update global count.""" if self.session_active: self._stats.globals_count += 1 self._refresh_display() # === Mouse Handling === def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: if event.modifiers() == Qt.KeyboardModifier.ControlModifier: self._dragging = True self._drag_offset = event.pos() self.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() def mouseMoveEvent(self, event: QMouseEvent): if self._dragging: new_pos = self.mapToGlobal(event.pos()) - self._drag_offset self.move(new_pos) self.position_changed.emit(new_pos) event.accept() def mouseReleaseEvent(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton and self._dragging: self._dragging = False self.setCursor(Qt.CursorShape.ArrowCursor) self._save_position() event.accept() def enterEvent(self, event): """Mouse entered - enable interaction.""" super().enterEvent(event) def leaveEvent(self, event): """Mouse left - disable interaction.""" super().leaveEvent(event) def _save_position(self): """Save window position.""" try: pos_file = self.config_path.parent / "hud_position.json" with open(pos_file, 'w') as f: json.dump({'x': self.x(), 'y': self.y()}, f) except: pass def _load_position(self): """Load window position.""" try: pos_file = self.config_path.parent / "hud_position.json" if pos_file.exists(): with open(pos_file, 'r') as f: pos = json.load(f) self.move(pos.get('x', 100), pos.get('y', 100)) else: screen = QApplication.primaryScreen().geometry() self.move(screen.width() - 360, 50) except: self.move(100, 100)