"""
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."""
# Calculate size based on what's shown
self._update_window_size()
# Main container
self.container = QFrame(self)
self.container.setObjectName("hudContainer")
self.container.setStyleSheet(self._get_stylesheet())
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."""
# Clear container
while self.container.layout().count():
item = self.container.layout().takeAt(0)
if item.widget():
item.widget().deleteLater()
self._update_window_size()
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)