"""
Lemontropia Suite - HUD Overlay v2.0
Cleaner, customizable HUD with collapsible sections.
"""
import sys
import json
import logging
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
logger = logging.getLogger(__name__)
# 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_total_cost: bool = True # NEW: Total cost display
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 # Deprecated - now integrated in loot summary
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_total_cost': self.show_total_cost,
'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_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_total_cost=data.get('show_total_cost', 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_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
# Damage stats (optional)
damage_dealt: Decimal = Decimal('0.0')
damage_taken: Decimal = Decimal('0.0')
# Shrapnel (optional)
shrapnel_total: Decimal = Decimal('0.0')
loot_other: Decimal = Decimal('0.0') # Non-shrapnel loot
# Session records
highest_loot: Decimal = Decimal('0.0') # Highest total loot from single kill
# Kill tracking buffer
_current_kill_loot: Decimal = Decimal('0.0')
_last_loot_time: Optional[datetime] = None
# 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."""
# Total loot is shrapnel + other loot
self.loot_total = self.shrapnel_total + self.loot_other
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,
'damage_dealt': str(self.damage_dealt),
'damage_taken': str(self.damage_taken),
'shrapnel_total': str(self.shrapnel_total),
'loot_other': str(self.loot_other),
'highest_loot': str(self.highest_loot),
'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_total_cost = QCheckBox("Cost & Loot Summary")
self.cb_total_cost.setChecked(self.config.show_total_cost)
form.addRow(self.cb_total_cost)
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)
# 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_total_cost = self.cb_total_cost.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.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: 1px solid rgba(255, 255, 255, 100);
border-radius: 4px;
color: #888;
font-size: 10px;
font-weight: bold;
}
QPushButton:hover {
background: rgba(255, 255, 255, 30);
border-color: #FFF;
color: #FFF;
}
""")
self.settings_btn.clicked.connect(self._show_settings)
header.addWidget(self.settings_btn)
layout.addLayout(header)
# Separator after header
sep1 = QFrame()
sep1.setFrameShape(QFrame.Shape.HLine)
sep1.setStyleSheet("background-color: rgba(255, 215, 0, 50);")
sep1.setFixedHeight(1)
layout.addWidget(sep1)
# === GEAR INFO (if enabled) ===
if self.hud_config.show_gear_info:
gear_box = QFrame()
gear_box.setStyleSheet("background-color: rgba(255, 255, 255, 5); border-radius: 4px;")
gear_layout = QVBoxLayout(gear_box)
gear_layout.setContentsMargins(6, 4, 6, 4)
gear_layout.setSpacing(2)
self.weapon_label = QLabel("W: None")
self.armor_label = QLabel("A: None")
self.loadout_label = QLabel("L: No Loadout")
gear_layout.addWidget(self.weapon_label)
gear_layout.addWidget(self.armor_label)
gear_layout.addWidget(self.loadout_label)
layout.addWidget(gear_box)
# === CORE: P/L + Return % ===
if self.hud_config.show_profit_loss or self.hud_config.show_return_pct:
core_box = QFrame()
core_box.setStyleSheet("background-color: rgba(0, 0, 0, 50); border: 1px solid rgba(255, 215, 0, 30); border-radius: 4px;")
core_layout = QHBoxLayout(core_box)
core_layout.setContentsMargins(8, 6, 8, 6)
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_box)
# === TOTAL COST + LOOT SUMMARY ===
if self.hud_config.show_total_cost:
summary_frame = QFrame()
summary_frame.setStyleSheet("background-color: rgba(0, 0, 0, 100); border-radius: 4px;")
summary_layout = QVBoxLayout(summary_frame)
summary_layout.setContentsMargins(8, 4, 8, 4)
summary_layout.setSpacing(2)
# Cost row
cost_layout = QHBoxLayout()
self.total_cost_label = QLabel("Cost: 0.00")
self.total_cost_label.setStyleSheet("font-size: 12px; color: #FFAAAA;")
cost_layout.addWidget(self.total_cost_label)
cost_layout.addStretch()
summary_layout.addLayout(cost_layout)
# Loot breakdown row
loot_layout = QHBoxLayout()
self.total_loot_label = QLabel("Total: 0.00")
self.total_loot_label.setStyleSheet("font-size: 11px; color: #AAFFAA;")
self.shrapnel_value_label = QLabel("S: 0.00")
self.shrapnel_value_label.setStyleSheet("font-size: 11px; color: #87CEEB;")
self.regular_loot_label = QLabel("R: 0.00")
self.regular_loot_label.setStyleSheet("font-size: 11px; color: #FFD700;")
loot_layout.addWidget(self.total_loot_label)
loot_layout.addStretch()
loot_layout.addWidget(self.shrapnel_value_label)
loot_layout.addWidget(self.regular_loot_label)
summary_layout.addLayout(loot_layout)
# Highest loot row
highest_layout = QHBoxLayout()
self.highest_loot_label = QLabel("Highest: 0.00")
self.highest_loot_label.setStyleSheet("font-size: 11px; color: #FFD700; font-weight: bold;")
highest_layout.addWidget(self.highest_loot_label)
highest_layout.addStretch()
summary_layout.addLayout(highest_layout)
layout.addWidget(summary_frame)
# Separator before cost metrics
if self.hud_config.show_total_cost and self.hud_config.show_cost_metrics:
sep2 = QFrame()
sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet("background-color: rgba(255, 255, 255, 30);")
sep2.setFixedHeight(1)
layout.addWidget(sep2)
# === COST METRICS (if enabled) ===
if self.hud_config.show_cost_metrics:
metrics_box = QFrame()
metrics_box.setStyleSheet("background-color: rgba(50, 50, 50, 100); border: 1px solid rgba(255, 255, 255, 30); border-radius: 4px;")
metrics_layout = QHBoxLayout(metrics_box)
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: #CCCCCC;")
metrics_layout.addWidget(lbl)
metrics_layout.addStretch()
layout.addWidget(metrics_box)
# Separator before cost breakdown
if self.hud_config.show_cost_breakdown and (self.hud_config.show_total_cost or self.hud_config.show_cost_metrics):
sep3 = QFrame()
sep3.setFrameShape(QFrame.Shape.HLine)
sep3.setStyleSheet("background-color: rgba(255, 255, 255, 30);")
sep3.setFixedHeight(1)
layout.addWidget(sep3)
# === OPTIONAL: Cost Breakdown ===
if self.hud_config.show_cost_breakdown:
breakdown_box = QFrame()
breakdown_box.setStyleSheet("background-color: rgba(255, 0, 0, 10); border: 1px solid rgba(255, 100, 100, 30); border-radius: 4px;")
breakdown_layout = QFormLayout(breakdown_box)
breakdown_layout.setContentsMargins(6, 4, 6, 4)
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_box)
# Separator before combat stats
if self.hud_config.show_combat_stats and (self.hud_config.show_cost_breakdown or self.hud_config.show_total_cost):
sep4 = QFrame()
sep4.setFrameShape(QFrame.Shape.HLine)
sep4.setStyleSheet("background-color: rgba(255, 255, 255, 30);")
sep4.setFixedHeight(1)
layout.addWidget(sep4)
# === OPTIONAL: Combat Stats ===
if self.hud_config.show_combat_stats:
combat_box = QFrame()
combat_box.setStyleSheet("background-color: rgba(0, 100, 0, 30); border: 1px solid rgba(100, 255, 100, 30); border-radius: 4px;")
combat_layout = QHBoxLayout(combat_box)
combat_layout.setContentsMargins(6, 4, 6, 4)
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_box)
# Separator before damage stats
if self.hud_config.show_damage_stats and self.hud_config.show_combat_stats:
sep5 = QFrame()
sep5.setFrameShape(QFrame.Shape.HLine)
sep5.setStyleSheet("background-color: rgba(255, 255, 255, 30);")
sep5.setFixedHeight(1)
layout.addWidget(sep5)
# === OPTIONAL: Damage Stats ===
if self.hud_config.show_damage_stats:
damage_box = QFrame()
damage_box.setStyleSheet("background-color: rgba(100, 0, 0, 30); border: 1px solid rgba(255, 100, 100, 30); border-radius: 4px;")
damage_layout = QHBoxLayout(damage_box)
damage_layout.setContentsMargins(6, 4, 6, 4)
self.damage_dealt_label = QLabel("Dealt: 0")
self.damage_taken_label = QLabel("Taken: 0")
damage_layout.addWidget(self.damage_dealt_label)
damage_layout.addStretch()
damage_layout.addWidget(self.damage_taken_label)
layout.addWidget(damage_box)
# Separator before footer
sep_footer = QFrame()
sep_footer.setFrameShape(QFrame.Shape.HLine)
sep_footer.setStyleSheet("background-color: rgba(255, 215, 0, 50);")
sep_footer.setFixedHeight(1)
layout.addWidget(sep_footer)
# === 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)
# Ensure container is visible
self.container.show()
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_total_cost:
height += 70 # Three rows now: cost + loot breakdown + highest
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_damage_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."""
# Hide first to avoid visual glitch
self.hide()
# Delete old container
if hasattr(self, 'container') and self.container:
self.container.deleteLater()
self.container = None
# Small delay to ensure cleanup
from PyQt6.QtCore import QTimer
QTimer.singleShot(10, self._do_rebuild)
def _do_rebuild(self):
"""Actually rebuild the UI."""
try:
self._setup_ui()
self._refresh_display()
self.show()
except Exception as e:
logger.error(f"Error rebuilding HUD: {e}")
# Try to recover
self.show()
# === Session Management ===
def start_session(self, weapon: str = "Unknown", armor: str = "None",
fap: str = "None", loadout: str = "Default",
weapon_dpp: Decimal = Decimal('0.0'),
weapon_cost_per_hour: Decimal = Decimal('0.0'),
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
# Initialize kill tracking
self._stats._current_kill_loot = Decimal('0.0')
self._stats._last_loot_time = None
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 end_session(self) -> None:
"""Alias for stop_session for backward compatibility."""
self.stop_session()
def stop_session(self) -> None:
"""Stop the current session."""
# Check if current kill is highest before stopping
if self._stats._current_kill_loot > self._stats.highest_loot:
self._stats.highest_loot = self._stats._current_kill_loot
self._refresh_display()
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 _safe_set_text(self, widget_name: str, text: str):
"""Safely set text on a widget if it exists and is valid."""
try:
widget = getattr(self, widget_name, None)
if widget is not None:
widget.setText(text)
except RuntimeError:
# Widget was deleted
pass
def _refresh_display(self):
"""Refresh all display labels."""
# Profit/Loss
profit = self._stats.profit_loss
color = "#7FFF7F" if profit >= 0 else "#FF7F7F"
self._safe_set_text('profit_label', f"{profit:+.2f} PED")
if hasattr(self, 'profit_label') and self.profit_label is not None:
try:
self.profit_label.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {color};")
except RuntimeError:
pass
# Return %
ret = self._stats.return_percentage
if ret >= 100:
color = "#7FFF7F"
elif ret >= 90:
color = "#FFFF7F"
else:
color = "#FF7F7F"
self._safe_set_text('return_label', f"{ret:.1f}%")
if hasattr(self, 'return_label') and self.return_label is not None:
try:
self.return_label.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {color};")
except RuntimeError:
pass
# Total Cost + Loot breakdown + Highest
self._safe_set_text('total_cost_label', f"Cost: {self._stats.cost_total:.2f}")
self._safe_set_text('total_loot_label', f"Total: {self._stats.loot_total:.2f}")
self._safe_set_text('shrapnel_value_label', f"S: {self._stats.shrapnel_total:.2f}")
self._safe_set_text('regular_loot_label', f"R: {self._stats.loot_other:.2f}")
self._safe_set_text('highest_loot_label', f"Highest: {self._stats.highest_loot:.2f}")
# Cost metrics
self._safe_set_text('cps_label', f"Shot: {self._stats.cost_per_shot:.4f}")
self._safe_set_text('cph_label', f"Hit: {self._stats.cost_per_hit:.4f}")
self._safe_set_text('cphl_label', f"Heal: {self._stats.cost_per_heal:.4f}")
# Gear
self._safe_set_text('weapon_label', f"W: {self._stats.current_weapon[:20]}")
self._safe_set_text('armor_label', f"A: {self._stats.current_armor[:20]}")
self._safe_set_text('loadout_label', f"L: {self._stats.current_loadout[:20]}")
# Cost breakdown
self._safe_set_text('wep_cost_label', f"{self._stats.weapon_cost_total:.2f}")
self._safe_set_text('arm_cost_label', f"{self._stats.armor_cost_total:.2f}")
self._safe_set_text('heal_cost_label', f"{self._stats.healing_cost_total:.2f}")
# Combat
self._safe_set_text('kills_label', f"Kills: {self._stats.kills}")
self._safe_set_text('globals_label', f"Globals: {self._stats.globals_count}")
# Damage stats
self._safe_set_text('damage_dealt_label', f"Dealt: {int(self._stats.damage_dealt)}")
self._safe_set_text('damage_taken_label', f"Taken: {int(self._stats.damage_taken)}")
# Shrapnel (in summary section now - already updated above)
# === Public Update Methods ===
def update_loot(self, value_ped: Decimal, is_shrapnel: bool = False):
"""Update loot value - tracks per-kill totals for highest loot."""
if self.session_active:
now = datetime.now()
# Check if this is a new kill (gap of >2 seconds since last loot)
if self._stats._last_loot_time is not None:
time_since_last = (now - self._stats._last_loot_time).total_seconds()
if time_since_last > 2.0:
# Previous kill is complete, check if it was highest
if self._stats._current_kill_loot > self._stats.highest_loot:
self._stats.highest_loot = self._stats._current_kill_loot
# Start new kill
self._stats._current_kill_loot = value_ped
else:
# Same kill, add to current
self._stats._current_kill_loot += value_ped
else:
# First loot of session
self._stats._current_kill_loot = value_ped
self._stats._last_loot_time = now
# Add to totals
if is_shrapnel:
self._stats.shrapnel_total += value_ped
else:
self._stats.loot_other += value_ped
self._stats.recalculate()
self._refresh_display()
def update_shrapnel(self, amount: Decimal):
"""Update shrapnel amount (convenience method)."""
self.update_loot(amount, is_shrapnel=True)
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()
def update_damage(self, dealt: Decimal = Decimal('0'), taken: Decimal = Decimal('0')):
"""Update damage stats."""
if self.session_active:
if dealt > 0:
self._stats.damage_dealt += dealt
if taken > 0:
self._stats.damage_taken += taken
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)