From a9637781456b18a1f739329d7bf2cd4b6af3da97 Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Sun, 8 Feb 2026 20:31:35 +0000 Subject: [PATCH] fix(gui): add missing main_window.py and loadout_manager.py Files were created in workspace root instead of project directory. Adding main_window.py and loadout_manager.py to complete Sprint 2 Phase 1. --- ui/loadout_manager.py | 794 ++++++++++++++++++++++++++++ ui/main_window.py | 1174 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1968 insertions(+) create mode 100644 ui/loadout_manager.py create mode 100644 ui/main_window.py diff --git a/ui/loadout_manager.py b/ui/loadout_manager.py new file mode 100644 index 0000000..265e44f --- /dev/null +++ b/ui/loadout_manager.py @@ -0,0 +1,794 @@ +""" +Lemontropia Suite - Loadout Manager UI +A PyQt6 dialog for configuring hunting gear loadouts with cost calculations. +""" + +import json +import os +from dataclasses import dataclass, asdict +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Optional, List + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, + QLineEdit, QComboBox, QLabel, QPushButton, + QGroupBox, QSpinBox, QDoubleSpinBox, QMessageBox, + QListWidget, QListWidgetItem, QSplitter, QWidget, + QFrame, QScrollArea, QGridLayout +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFont + + +# ============================================================================ +# Data Structures +# ============================================================================ + +@dataclass +class LoadoutConfig: + """Configuration for a hunting loadout.""" + name: str + weapon_name: str + weapon_damage: Decimal + weapon_decay_pec: Decimal + weapon_ammo_pec: Decimal + armor_name: str + armor_decay_pec: Decimal + heal_cost_pec: Decimal + + # Optional fields for extended calculations + shots_per_hour: int = 3600 # Default: 1 shot per second + protection_stab: Decimal = Decimal("0") + protection_cut: Decimal = Decimal("0") + protection_impact: Decimal = Decimal("0") + protection_penetration: Decimal = Decimal("0") + protection_shrapnel: Decimal = Decimal("0") + protection_burn: Decimal = Decimal("0") + protection_cold: Decimal = Decimal("0") + protection_acid: Decimal = Decimal("0") + protection_electric: Decimal = Decimal("0") + + def calculate_dpp(self) -> Decimal: + """Calculate Damage Per Pec (DPP) = Damage / (Decay + Ammo).""" + total_cost = self.weapon_decay_pec + self.weapon_ammo_pec + if total_cost == 0: + return Decimal("0") + return self.weapon_damage / total_cost + + def calculate_cost_per_hour(self) -> Decimal: + """Calculate total PED cost per hour based on shots per hour.""" + cost_per_shot = self.weapon_decay_pec + self.weapon_ammo_pec + total_weapon_cost = cost_per_shot * Decimal(self.shots_per_hour) + + # Estimate armor decay (assume 1 hit per 5 shots on average) + armor_cost_per_hour = self.armor_decay_pec * Decimal(self.shots_per_hour // 5) + + # Estimate healing cost (assume 1 heal per 10 shots) + heal_cost_per_hour = self.heal_cost_pec * Decimal(self.shots_per_hour // 10) + + total_pec = total_weapon_cost + armor_cost_per_hour + heal_cost_per_hour + return total_pec / Decimal("100") # Convert PEC to PED + + def calculate_break_even(self, mob_health: Decimal) -> Decimal: + """Calculate break-even loot value for a mob with given health.""" + shots_to_kill = mob_health / self.weapon_damage + if shots_to_kill < 1: + shots_to_kill = Decimal("1") + + cost_per_shot = self.weapon_decay_pec + self.weapon_ammo_pec + total_cost_pec = shots_to_kill * cost_per_shot + return total_cost_pec / Decimal("100") # Convert to PED + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + k: str(v) if isinstance(v, Decimal) else v + for k, v in asdict(self).items() + } + + @classmethod + def from_dict(cls, data: dict) -> "LoadoutConfig": + """Create LoadoutConfig from dictionary.""" + decimal_fields = [ + 'weapon_damage', 'weapon_decay_pec', 'weapon_ammo_pec', + 'armor_decay_pec', 'heal_cost_pec', 'protection_stab', + 'protection_cut', 'protection_impact', 'protection_penetration', + 'protection_shrapnel', 'protection_burn', 'protection_cold', + 'protection_acid', 'protection_electric' + ] + + for field in decimal_fields: + if field in data: + data[field] = Decimal(data[field]) + + if 'shots_per_hour' in data: + data['shots_per_hour'] = int(data['shots_per_hour']) + + return cls(**data) + + +# ============================================================================ +# Mock Data +# ============================================================================ + +MOCK_WEAPONS = [ + {"name": "Sollomate Opalo", "damage": Decimal("9.0"), "decay": Decimal("0.09"), "ammo": Decimal("1.80")}, + {"name": "Omegaton M2100", "damage": Decimal("10.5"), "decay": Decimal("0.10"), "ammo": Decimal("2.10")}, + {"name": "Breer M1a", "damage": Decimal("12.0"), "decay": Decimal("0.12"), "ammo": Decimal("2.40")}, + {"name": "Justifier Mk.II", "damage": Decimal("25.0"), "decay": Decimal("0.25"), "ammo": Decimal("5.00")}, + {"name": "Marber Bravo-Type", "damage": Decimal("45.0"), "decay": Decimal("0.45"), "ammo": Decimal("9.00")}, + {"name": " Maddox IV", "damage": Decimal("80.0"), "decay": Decimal("0.80"), "ammo": Decimal("16.00")}, +] + +MOCK_ARMOR = [ + {"name": "Pixie Harness", "decay": Decimal("0.05"), "impact": Decimal("4"), "cut": Decimal("3"), "stab": Decimal("2")}, + {"name": "Shogun Harness", "decay": Decimal("0.12"), "impact": Decimal("8"), "cut": Decimal("6"), "stab": Decimal("5"), "burn": Decimal("4")}, + {"name": "Ghost Harness", "decay": Decimal("0.20"), "impact": Decimal("12"), "cut": Decimal("10"), "stab": Decimal("9"), "burn": Decimal("8"), "cold": Decimal("7")}, + {"name": "Vigilante Harness", "decay": Decimal("0.08"), "impact": Decimal("6"), "cut": Decimal("5"), "stab": Decimal("4")}, + {"name": "Hermes Harness", "decay": Decimal("0.15"), "impact": Decimal("10"), "cut": Decimal("8"), "stab": Decimal("7"), "penetration": Decimal("5")}, +] + +MOCK_HEALING = [ + {"name": "Vivo T10", "cost": Decimal("2.0")}, + {"name": "Vivo T15", "cost": Decimal("3.5")}, + {"name": "Vivo S10", "cost": Decimal("4.0")}, + {"name": "Refurbished H.E.A.R.T.", "cost": Decimal("1.5")}, + {"name": "Restoration Chip I", "cost": Decimal("5.0")}, + {"name": "Restoration Chip II", "cost": Decimal("8.0")}, +] + + +# ============================================================================ +# Custom Widgets +# ============================================================================ + +class DecimalLineEdit(QLineEdit): + """Line edit with decimal validation.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setPlaceholderText("0.00") + + def get_decimal(self) -> Decimal: + """Get value as Decimal, returns 0 on invalid input.""" + text = self.text().strip() + if not text: + return Decimal("0") + try: + return Decimal(text) + except InvalidOperation: + return Decimal("0") + + def set_decimal(self, value: Decimal): + """Set value from Decimal.""" + self.setText(str(value)) + + +class DarkGroupBox(QGroupBox): + """Group box with dark theme styling.""" + + def __init__(self, title: str, parent=None): + super().__init__(title, parent) + self.setStyleSheet(""" + QGroupBox { + color: #e0e0e0; + border: 2px solid #3d3d3d; + border-radius: 6px; + margin-top: 10px; + padding-top: 10px; + font-weight: bold; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + """) + + +# ============================================================================ +# Main Dialog +# ============================================================================ + +class LoadoutManagerDialog(QDialog): + """Main dialog for managing hunting loadouts.""" + + loadout_saved = pyqtSignal(str) # Emitted when loadout is saved + + def __init__(self, parent=None, config_dir: Optional[str] = None): + super().__init__(parent) + self.setWindowTitle("Lemontropia Suite - Loadout Manager") + self.setMinimumSize(900, 700) + + # Configuration directory + if config_dir is None: + self.config_dir = Path.home() / ".lemontropia" / "loadouts" + else: + self.config_dir = Path(config_dir) + self.config_dir.mkdir(parents=True, exist_ok=True) + + self.current_loadout: Optional[LoadoutConfig] = None + + self._apply_dark_theme() + self._create_widgets() + self._create_layout() + self._connect_signals() + self._load_saved_loadouts() + self._populate_mock_data() + + def _apply_dark_theme(self): + """Apply dark theme styling.""" + self.setStyleSheet(""" + QDialog { + background-color: #1e1e1e; + } + QLabel { + color: #e0e0e0; + } + QLineEdit { + background-color: #2d2d2d; + color: #e0e0e0; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 5px; + } + QLineEdit:focus { + border: 1px solid #4a90d9; + } + QComboBox { + background-color: #2d2d2d; + color: #e0e0e0; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 5px; + min-width: 150px; + } + QComboBox::drop-down { + border: none; + } + QComboBox QAbstractItemView { + background-color: #2d2d2d; + color: #e0e0e0; + selection-background-color: #4a90d9; + } + QPushButton { + background-color: #3d3d3d; + color: #e0e0e0; + border: 1px solid #4d4d4d; + border-radius: 4px; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #4d4d4d; + } + QPushButton:pressed { + background-color: #5d5d5d; + } + QPushButton#saveButton { + background-color: #2e7d32; + border-color: #4caf50; + } + QPushButton#saveButton:hover { + background-color: #4caf50; + } + QPushButton#deleteButton { + background-color: #7d2e2e; + border-color: #f44336; + } + QPushButton#deleteButton:hover { + background-color: #f44336; + } + QListWidget { + background-color: #2d2d2d; + color: #e0e0e0; + border: 1px solid #3d3d3d; + border-radius: 4px; + } + QListWidget::item:selected { + background-color: #4a90d9; + } + QScrollArea { + border: none; + } + """) + + def _create_widgets(self): + """Create all UI widgets.""" + # Loadout name + self.loadout_name_edit = QLineEdit() + self.loadout_name_edit.setPlaceholderText("Enter loadout name...") + + # Shots per hour + self.shots_per_hour_spin = QSpinBox() + self.shots_per_hour_spin.setRange(1, 10000) + self.shots_per_hour_spin.setValue(3600) + self.shots_per_hour_spin.setSuffix(" shots/hr") + + # Weapon section + self.weapon_group = DarkGroupBox("πŸ”« Weapon Configuration") + self.weapon_combo = QComboBox() + self.weapon_combo.setEditable(True) + self.weapon_damage_edit = DecimalLineEdit() + self.weapon_decay_edit = DecimalLineEdit() + self.weapon_ammo_edit = DecimalLineEdit() + self.dpp_label = QLabel("0.00") + self.dpp_label.setStyleSheet("color: #4caf50; font-weight: bold; font-size: 14px;") + + # Armor section + self.armor_group = DarkGroupBox("πŸ›‘οΈ Armor Configuration") + self.armor_combo = QComboBox() + self.armor_combo.setEditable(True) + self.armor_decay_edit = DecimalLineEdit() + + # Protection values + self.protection_stab_edit = DecimalLineEdit() + self.protection_cut_edit = DecimalLineEdit() + self.protection_impact_edit = DecimalLineEdit() + self.protection_pen_edit = DecimalLineEdit() + self.protection_shrap_edit = DecimalLineEdit() + self.protection_burn_edit = DecimalLineEdit() + self.protection_cold_edit = DecimalLineEdit() + self.protection_acid_edit = DecimalLineEdit() + self.protection_elec_edit = DecimalLineEdit() + + # Healing section + self.heal_group = DarkGroupBox("πŸ’Š Healing Configuration") + self.heal_combo = QComboBox() + self.heal_combo.setEditable(True) + self.heal_cost_edit = DecimalLineEdit() + + # Cost summary + self.summary_group = DarkGroupBox("πŸ“Š Cost Summary") + self.cost_per_hour_label = QLabel("0.00 PED/hr") + self.cost_per_hour_label.setStyleSheet("color: #ff9800; font-weight: bold; font-size: 16px;") + self.break_even_label = QLabel("Break-even: 0.00 PED (mob health: 100)") + self.break_even_label.setStyleSheet("color: #4caf50;") + + # Break-even calculator + self.mob_health_edit = DecimalLineEdit() + self.mob_health_edit.set_decimal(Decimal("100")) + self.calc_break_even_btn = QPushButton("Calculate Break-Even") + + # Saved loadouts list + self.saved_list = QListWidget() + + # Buttons + self.save_btn = QPushButton("πŸ’Ύ Save Loadout") + self.save_btn.setObjectName("saveButton") + self.load_btn = QPushButton("πŸ“‚ Load Selected") + self.delete_btn = QPushButton("πŸ—‘οΈ Delete") + self.delete_btn.setObjectName("deleteButton") + self.new_btn = QPushButton("πŸ†• New Loadout") + self.close_btn = QPushButton("❌ Close") + + # Refresh button for saved list + self.refresh_btn = QPushButton("πŸ”„ Refresh") + + def _create_layout(self): + """Create the main layout.""" + main_layout = QHBoxLayout(self) + main_layout.setSpacing(15) + main_layout.setContentsMargins(15, 15, 15, 15) + + # Left panel - Saved loadouts + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(0, 0, 0, 0) + + saved_label = QLabel("πŸ’Ό Saved Loadouts") + saved_label.setFont(QFont("Arial", 12, QFont.Weight.Bold)) + left_layout.addWidget(saved_label) + + left_layout.addWidget(self.saved_list) + + left_btn_layout = QHBoxLayout() + left_btn_layout.addWidget(self.load_btn) + left_btn_layout.addWidget(self.delete_btn) + left_layout.addLayout(left_btn_layout) + + left_layout.addWidget(self.refresh_btn) + left_layout.addWidget(self.new_btn) + left_layout.addStretch() + left_layout.addWidget(self.close_btn) + + # Right panel - Configuration + right_scroll = QScrollArea() + right_scroll.setWidgetResizable(True) + right_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 10, 0) + + # Loadout name header + name_layout = QHBoxLayout() + name_label = QLabel("Loadout Name:") + name_label.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + name_layout.addWidget(name_label) + name_layout.addWidget(self.loadout_name_edit, stretch=1) + name_layout.addWidget(QLabel("Shots/Hour:")) + name_layout.addWidget(self.shots_per_hour_spin) + right_layout.addLayout(name_layout) + + # Weapon configuration + weapon_layout = QFormLayout(self.weapon_group) + weapon_layout.addRow("Weapon:", self.weapon_combo) + weapon_layout.addRow("Damage:", self.weapon_damage_edit) + weapon_layout.addRow("Decay/shot (PEC):", self.weapon_decay_edit) + weapon_layout.addRow("Ammo/shot (PEC):", self.weapon_ammo_edit) + weapon_layout.addRow("DPP (Damage Per Pec):", self.dpp_label) + right_layout.addWidget(self.weapon_group) + + # Armor configuration + armor_layout = QFormLayout(self.armor_group) + armor_layout.addRow("Armor Set:", self.armor_combo) + armor_layout.addRow("Decay/hit (PEC):", self.armor_decay_edit) + + # Protection grid + protection_frame = QFrame() + protection_layout = QGridLayout(protection_frame) + protection_layout.setSpacing(5) + + protections = [ + ("Stab:", self.protection_stab_edit), + ("Cut:", self.protection_cut_edit), + ("Impact:", self.protection_impact_edit), + ("Penetration:", self.protection_pen_edit), + ("Shrapnel:", self.protection_shrap_edit), + ("Burn:", self.protection_burn_edit), + ("Cold:", self.protection_cold_edit), + ("Acid:", self.protection_acid_edit), + ("Electric:", self.protection_elec_edit), + ] + + for i, (label, edit) in enumerate(protections): + row = i // 3 + col = (i % 3) * 2 + protection_layout.addWidget(QLabel(label), row, col) + protection_layout.addWidget(edit, row, col + 1) + + armor_layout.addRow("Protection Values:", protection_frame) + right_layout.addWidget(self.armor_group) + + # Healing configuration + heal_layout = QFormLayout(self.heal_group) + heal_layout.addRow("Healing Tool:", self.heal_combo) + heal_layout.addRow("Cost/heal (PEC):", self.heal_cost_edit) + right_layout.addWidget(self.heal_group) + + # Cost summary + summary_layout = QFormLayout(self.summary_group) + summary_layout.addRow("Estimated Cost:", self.cost_per_hour_label) + + break_even_layout = QHBoxLayout() + break_even_layout.addWidget(QLabel("Mob Health:")) + break_even_layout.addWidget(self.mob_health_edit) + break_even_layout.addWidget(self.calc_break_even_btn) + summary_layout.addRow(break_even_layout) + summary_layout.addRow("", self.break_even_label) + + right_layout.addWidget(self.summary_group) + + # Save button + right_layout.addWidget(self.save_btn) + + right_layout.addStretch() + right_scroll.setWidget(right_widget) + + # Splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.addWidget(left_panel) + splitter.addWidget(right_scroll) + splitter.setSizes([250, 650]) + + main_layout.addWidget(splitter) + + def _connect_signals(self): + """Connect all signal handlers.""" + # Weapon changes + self.weapon_combo.currentTextChanged.connect(self._on_weapon_changed) + self.weapon_damage_edit.textChanged.connect(self._update_calculations) + self.weapon_decay_edit.textChanged.connect(self._update_calculations) + self.weapon_ammo_edit.textChanged.connect(self._update_calculations) + + # Armor changes + self.armor_combo.currentTextChanged.connect(self._on_armor_changed) + + # Healing changes + self.heal_combo.currentTextChanged.connect(self._on_heal_changed) + + # Buttons + self.save_btn.clicked.connect(self._save_loadout) + self.load_btn.clicked.connect(self._load_selected) + self.delete_btn.clicked.connect(self._delete_selected) + self.new_btn.clicked.connect(self._new_loadout) + self.refresh_btn.clicked.connect(self._load_saved_loadouts) + self.close_btn.clicked.connect(self.reject) + self.calc_break_even_btn.clicked.connect(self._calculate_break_even) + self.shots_per_hour_spin.valueChanged.connect(self._update_calculations) + + # Double click on list + self.saved_list.itemDoubleClicked.connect(self._load_from_item) + + def _populate_mock_data(self): + """Populate combos with mock data.""" + # Weapons + self.weapon_combo.addItem("-- Custom --") + for weapon in MOCK_WEAPONS: + self.weapon_combo.addItem(weapon["name"]) + + # Armor + self.armor_combo.addItem("-- Custom --") + for armor in MOCK_ARMOR: + self.armor_combo.addItem(armor["name"]) + + # Healing + self.heal_combo.addItem("-- Custom --") + for heal in MOCK_HEALING: + self.heal_combo.addItem(heal["name"]) + + def _on_weapon_changed(self, name: str): + """Handle weapon selection change.""" + for weapon in MOCK_WEAPONS: + if weapon["name"] == name: + self.weapon_damage_edit.set_decimal(weapon["damage"]) + self.weapon_decay_edit.set_decimal(weapon["decay"]) + self.weapon_ammo_edit.set_decimal(weapon["ammo"]) + break + self._update_calculations() + + def _on_armor_changed(self, name: str): + """Handle armor selection change.""" + for armor in MOCK_ARMOR: + if armor["name"] == name: + self.armor_decay_edit.set_decimal(armor["decay"]) + self.protection_impact_edit.set_decimal(Decimal(armor.get("impact", "0"))) + self.protection_cut_edit.set_decimal(Decimal(armor.get("cut", "0"))) + self.protection_stab_edit.set_decimal(Decimal(armor.get("stab", "0"))) + self.protection_burn_edit.set_decimal(Decimal(armor.get("burn", "0"))) + self.protection_cold_edit.set_decimal(Decimal(armor.get("cold", "0"))) + self.protection_pen_edit.set_decimal(Decimal(armor.get("penetration", "0"))) + break + + def _on_heal_changed(self, name: str): + """Handle healing selection change.""" + for heal in MOCK_HEALING: + if heal["name"] == name: + self.heal_cost_edit.set_decimal(heal["cost"]) + break + + def _update_calculations(self): + """Update DPP and cost calculations.""" + try: + config = self._get_current_config() + + # Update DPP + dpp = config.calculate_dpp() + self.dpp_label.setText(f"{dpp:.4f}") + + # Update cost per hour + cost_per_hour = config.calculate_cost_per_hour() + self.cost_per_hour_label.setText(f"{cost_per_hour:.2f} PED/hr") + + except Exception as e: + pass # Ignore calculation errors during typing + + def _calculate_break_even(self): + """Calculate and display break-even loot value.""" + try: + config = self._get_current_config() + mob_health = self.mob_health_edit.get_decimal() + + if mob_health <= 0: + QMessageBox.warning(self, "Invalid Input", "Mob health must be greater than 0") + return + + break_even = config.calculate_break_even(mob_health) + self.break_even_label.setText( + f"Break-even: {break_even:.2f} PED (mob health: {mob_health})" + ) + except Exception as e: + QMessageBox.critical(self, "Error", f"Calculation failed: {str(e)}") + + def _get_current_config(self) -> LoadoutConfig: + """Get current configuration from UI fields.""" + return LoadoutConfig( + name=self.loadout_name_edit.text().strip() or "Unnamed", + weapon_name=self.weapon_combo.currentText(), + weapon_damage=self.weapon_damage_edit.get_decimal(), + weapon_decay_pec=self.weapon_decay_edit.get_decimal(), + weapon_ammo_pec=self.weapon_ammo_edit.get_decimal(), + armor_name=self.armor_combo.currentText(), + armor_decay_pec=self.armor_decay_edit.get_decimal(), + heal_cost_pec=self.heal_cost_edit.get_decimal(), + shots_per_hour=self.shots_per_hour_spin.value(), + protection_stab=self.protection_stab_edit.get_decimal(), + protection_cut=self.protection_cut_edit.get_decimal(), + protection_impact=self.protection_impact_edit.get_decimal(), + protection_penetration=self.protection_pen_edit.get_decimal(), + protection_shrapnel=self.protection_shrap_edit.get_decimal(), + protection_burn=self.protection_burn_edit.get_decimal(), + protection_cold=self.protection_cold_edit.get_decimal(), + protection_acid=self.protection_acid_edit.get_decimal(), + protection_electric=self.protection_elec_edit.get_decimal(), + ) + + def _set_config(self, config: LoadoutConfig): + """Set UI fields from configuration.""" + self.loadout_name_edit.setText(config.name) + self.shots_per_hour_spin.setValue(config.shots_per_hour) + + self.weapon_combo.setCurrentText(config.weapon_name) + self.weapon_damage_edit.set_decimal(config.weapon_damage) + self.weapon_decay_edit.set_decimal(config.weapon_decay_pec) + self.weapon_ammo_edit.set_decimal(config.weapon_ammo_pec) + + self.armor_combo.setCurrentText(config.armor_name) + self.armor_decay_edit.set_decimal(config.armor_decay_pec) + + self.protection_stab_edit.set_decimal(config.protection_stab) + self.protection_cut_edit.set_decimal(config.protection_cut) + self.protection_impact_edit.set_decimal(config.protection_impact) + self.protection_pen_edit.set_decimal(config.protection_penetration) + self.protection_shrap_edit.set_decimal(config.protection_shrapnel) + self.protection_burn_edit.set_decimal(config.protection_burn) + self.protection_cold_edit.set_decimal(config.protection_cold) + self.protection_acid_edit.set_decimal(config.protection_acid) + self.protection_elec_edit.set_decimal(config.protection_electric) + + self.heal_combo.setCurrentText("-- Custom --") + self.heal_cost_edit.set_decimal(config.heal_cost_pec) + + self._update_calculations() + + def _save_loadout(self): + """Save current loadout to file.""" + name = self.loadout_name_edit.text().strip() + if not name: + QMessageBox.warning(self, "Missing Name", "Please enter a loadout name") + return + + # Sanitize filename + safe_name = "".join(c for c in name if c.isalnum() or c in "._- ").strip() + if not safe_name: + safe_name = "unnamed" + + config = self._get_current_config() + config.name = name + + filepath = self.config_dir / f"{safe_name}.json" + + try: + with open(filepath, 'w') as f: + json.dump(config.to_dict(), f, indent=2) + + self.current_loadout = config + self.loadout_saved.emit(name) + self._load_saved_loadouts() + + QMessageBox.information(self, "Saved", f"Loadout '{name}' saved successfully!") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save: {str(e)}") + + def _load_saved_loadouts(self): + """Load list of saved loadouts.""" + self.saved_list.clear() + + try: + for filepath in sorted(self.config_dir.glob("*.json")): + try: + with open(filepath, 'r') as f: + data = json.load(f) + config = LoadoutConfig.from_dict(data) + + item = QListWidgetItem(f"πŸ“‹ {config.name}") + item.setData(Qt.ItemDataRole.UserRole, str(filepath)) + item.setToolTip( + f"Weapon: {config.weapon_name}\n" + f"Armor: {config.armor_name}\n" + f"DPP: {config.calculate_dpp():.2f}" + ) + self.saved_list.addItem(item) + except Exception: + continue + except Exception: + pass + + def _load_selected(self): + """Load the selected loadout from the list.""" + item = self.saved_list.currentItem() + if item: + self._load_from_item(item) + else: + QMessageBox.information(self, "No Selection", "Please select a loadout to load") + + def _load_from_item(self, item: QListWidgetItem): + """Load loadout from a list item.""" + filepath = item.data(Qt.ItemDataRole.UserRole) + if not filepath: + return + + try: + with open(filepath, 'r') as f: + data = json.load(f) + config = LoadoutConfig.from_dict(data) + + self._set_config(config) + self.current_loadout = config + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load: {str(e)}") + + def _delete_selected(self): + """Delete the selected loadout.""" + item = self.saved_list.currentItem() + if not item: + QMessageBox.information(self, "No Selection", "Please select a loadout to delete") + return + + filepath = item.data(Qt.ItemDataRole.UserRole) + name = item.text().replace("πŸ“‹ ", "") + + reply = QMessageBox.question( + self, "Confirm Delete", + f"Are you sure you want to delete '{name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + try: + os.remove(filepath) + self._load_saved_loadouts() + QMessageBox.information(self, "Deleted", f"'{name}' deleted successfully") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete: {str(e)}") + + def _new_loadout(self): + """Clear all fields for a new loadout.""" + self.loadout_name_edit.clear() + self.weapon_combo.setCurrentIndex(0) + self.armor_combo.setCurrentIndex(0) + self.heal_combo.setCurrentIndex(0) + self.mob_health_edit.set_decimal(Decimal("100")) + self.current_loadout = None + self._update_calculations() + + def get_current_loadout(self) -> Optional[LoadoutConfig]: + """Get the currently loaded/created loadout.""" + return self.current_loadout + + +# ============================================================================ +# Main entry point for testing +# ============================================================================ + +def main(): + """Run the loadout manager as a standalone application.""" + import sys + from PyQt6.QtWidgets import QApplication + + app = QApplication(sys.argv) + app.setStyle('Fusion') + + # Set application-wide font + font = QFont("Segoe UI", 10) + app.setFont(font) + + dialog = LoadoutManagerDialog() + + # Connect signal for testing + dialog.loadout_saved.connect(lambda name: print(f"Loadout saved: {name}")) + + if dialog.exec() == QDialog.DialogCode.Accepted: + config = dialog.get_current_loadout() + if config: + print(f"\nFinal Loadout: {config.name}") + print(f" Weapon: {config.weapon_name} (DPP: {config.calculate_dpp():.2f})") + print(f" Cost/hour: {config.calculate_cost_per_hour():.2f} PED") + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..06933f0 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,1174 @@ +""" +Lemontropia Suite - Main Application Window +PyQt6 GUI for managing game automation projects and sessions. +""" + +import sys +from datetime import datetime +from enum import Enum, auto +from typing import Optional, List, Callable +from dataclasses import dataclass + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QSplitter, QPushButton, QListWidget, QListWidgetItem, + QTextEdit, QLabel, QStatusBar, QMenuBar, QMenu, + QDialog, QLineEdit, QFormLayout, QDialogButtonBox, + QMessageBox, QGroupBox, QFrame, QApplication, + QTreeWidget, QTreeWidgetItem, QHeaderView +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize +from PyQt6.QtGui import QAction, QFont, QColor, QPalette, QIcon + + +# ============================================================================ +# Data Models +# ============================================================================ + +class SessionState(Enum): + """Session state enumeration.""" + IDLE = "Idle" + RUNNING = "Running" + PAUSED = "Paused" + ERROR = "Error" + STOPPING = "Stopping" + + +@dataclass +class Project: + """Project data model.""" + id: int + name: str + description: str = "" + created_at: Optional[datetime] = None + session_count: int = 0 + last_session: Optional[datetime] = None + + +@dataclass +class LogEvent: + """Log event data model.""" + timestamp: datetime + level: str # DEBUG, INFO, WARNING, ERROR, CRITICAL + source: str + message: str + + def __str__(self) -> str: + time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] + return f"[{time_str}] [{self.level}] [{self.source}] {self.message}" + + +# ============================================================================ +# HUD Overlay (Placeholder for integration) +# ============================================================================ + +class HUDOverlay: + """ + HUD Overlay controller placeholder. + This will be replaced with the actual HUD implementation. + """ + def __init__(self): + self.visible = False + + def show(self): + """Show the HUD overlay.""" + self.visible = True + print("[HUD] Overlay shown") + + def hide(self): + """Hide the HUD overlay.""" + self.visible = False + print("[HUD] Overlay hidden") + + def is_visible(self) -> bool: + """Check if HUD is visible.""" + return self.visible + + +# ============================================================================ +# Project Manager (Placeholder for integration) +# ============================================================================ + +class ProjectManager: + """ + Project manager placeholder. + This will be replaced with the actual ProjectManager implementation. + """ + def __init__(self): + self._projects: List[Project] = [ + Project(1, "Default Project", "Default automation project", session_count=5), + Project(2, "Farming Bot", "Resource farming automation", session_count=12), + Project(3, "Quest Helper", "Quest automation helper", session_count=3), + ] + self._next_id = 4 + + def get_all_projects(self) -> List[Project]: + """Get all projects.""" + return self._projects.copy() + + def get_project(self, project_id: int) -> Optional[Project]: + """Get project by ID.""" + for proj in self._projects: + if proj.id == project_id: + return proj + return None + + def create_project(self, name: str, description: str = "") -> Project: + """Create a new project.""" + project = Project( + id=self._next_id, + name=name, + description=description, + created_at=datetime.now(), + session_count=0 + ) + self._projects.append(project) + self._next_id += 1 + return project + + +# ============================================================================ +# Log Watcher (Placeholder for integration) +# ============================================================================ + +class LogWatcher: + """ + Log watcher placeholder. + This will be replaced with the actual LogWatcher implementation. + """ + def __init__(self): + self._callbacks: List[Callable[[LogEvent], None]] = [] + self._running = False + + def register_callback(self, callback: Callable[[LogEvent], None]): + """Register a callback for log events.""" + self._callbacks.append(callback) + + def unregister_callback(self, callback: Callable[[LogEvent], None]): + """Unregister a callback.""" + if callback in self._callbacks: + self._callbacks.remove(callback) + + def emit(self, event: LogEvent): + """Emit a log event to all registered callbacks.""" + for callback in self._callbacks: + callback(event) + + +# ============================================================================ +# Custom Dialogs +# ============================================================================ + +class NewProjectDialog(QDialog): + """Dialog for creating a new project.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("New Project") + self.setMinimumWidth(400) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Form layout for inputs + form_layout = QFormLayout() + + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("Enter project name...") + form_layout.addRow("Name:", self.name_input) + + self.desc_input = QLineEdit() + self.desc_input.setPlaceholderText("Enter description (optional)...") + form_layout.addRow("Description:", self.desc_input) + + layout.addLayout(form_layout) + layout.addSpacing(10) + + # Button box + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_project_data(self) -> tuple: + """Get the entered project data.""" + return self.name_input.text().strip(), self.desc_input.text().strip() + + def accept(self): + """Validate before accepting.""" + name = self.name_input.text().strip() + if not name: + QMessageBox.warning(self, "Validation Error", "Project name is required.") + return + super().accept() + + +class ProjectStatsDialog(QDialog): + """Dialog for displaying project statistics.""" + + def __init__(self, project: Project, parent=None): + super().__init__(parent) + self.project = project + self.setWindowTitle(f"Project Statistics - {project.name}") + self.setMinimumWidth(350) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + # Stats display + stats_group = QGroupBox("Project Information") + stats_layout = QFormLayout(stats_group) + + stats_layout.addRow("ID:", QLabel(str(self.project.id))) + stats_layout.addRow("Name:", QLabel(self.project.name)) + stats_layout.addRow("Description:", QLabel(self.project.description or "N/A")) + + created = self.project.created_at.strftime("%Y-%m-%d %H:%M") if self.project.created_at else "N/A" + stats_layout.addRow("Created:", QLabel(created)) + + stats_layout.addRow("Total Sessions:", QLabel(str(self.project.session_count))) + + last = self.project.last_session.strftime("%Y-%m-%d %H:%M") if self.project.last_session else "Never" + stats_layout.addRow("Last Session:", QLabel(last)) + + layout.addWidget(stats_group) + + # Close button + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + +class SettingsDialog(QDialog): + """Dialog for application settings.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Settings") + self.setMinimumWidth(400) + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + + info_label = QLabel("Settings configuration would go here.") + info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(info_label) + + layout.addStretch() + + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + +# ============================================================================ +# Main Window +# ============================================================================ + +class MainWindow(QMainWindow): + """ + Main application window for Lemontropia Suite. + + Provides project management, session control, and log viewing capabilities. + """ + + # Signals + session_started = pyqtSignal(int) # project_id + session_stopped = pyqtSignal() + session_paused = pyqtSignal() + session_resumed = pyqtSignal() + + def __init__(self): + super().__init__() + + # Window configuration + self.setWindowTitle("Lemontropia Suite") + self.setMinimumSize(1200, 800) + self.resize(1400, 900) + + # Initialize components + self.hud = HUDOverlay() + self.project_manager = ProjectManager() + self.log_watcher = LogWatcher() + + # State + self.current_project: Optional[Project] = None + self.session_state = SessionState.IDLE + self.current_session_id: Optional[int] = None + + # Setup UI + self.setup_ui() + self.apply_dark_theme() + self.create_menu_bar() + self.create_status_bar() + + # Connect log watcher + self.log_watcher.register_callback(self.on_log_event) + + # Load initial data + self.refresh_project_list() + + # Welcome message + self.log_info("Application", "Lemontropia Suite initialized") + + # ======================================================================== + # UI Setup + # ======================================================================== + + def setup_ui(self): + """Setup the main UI layout.""" + # Central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(10) + + # Main splitter (horizontal: left panels | log panel) + self.main_splitter = QSplitter(Qt.Orientation.Horizontal) + main_layout.addWidget(self.main_splitter) + + # Left side container + left_container = QWidget() + left_layout = QVBoxLayout(left_container) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.setSpacing(10) + + # Left splitter (vertical: projects | session control) + left_splitter = QSplitter(Qt.Orientation.Vertical) + left_layout.addWidget(left_splitter) + + # Project panel + self.project_panel = self.create_project_panel() + left_splitter.addWidget(self.project_panel) + + # Session control panel + self.session_panel = self.create_session_panel() + left_splitter.addWidget(self.session_panel) + + # Set splitter proportions + left_splitter.setSizes([400, 300]) + + # Add left container to main splitter + self.main_splitter.addWidget(left_container) + + # Log output panel + self.log_panel = self.create_log_panel() + self.main_splitter.addWidget(self.log_panel) + + # Set main splitter proportions (30% left, 70% log) + self.main_splitter.setSizes([400, 900]) + + def create_project_panel(self) -> QGroupBox: + """Create the project management panel.""" + panel = QGroupBox("Project Management") + layout = QVBoxLayout(panel) + layout.setSpacing(8) + + # Project list + self.project_list = QTreeWidget() + self.project_list.setHeaderLabels(["ID", "Name", "Sessions"]) + self.project_list.setAlternatingRowColors(True) + self.project_list.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection) + self.project_list.setRootIsDecorated(False) + self.project_list.itemSelectionChanged.connect(self.on_project_selected) + self.project_list.itemDoubleClicked.connect(self.on_project_double_clicked) + + # Adjust column widths + header = self.project_list.header() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + header.resizeSection(0, 50) + header.resizeSection(2, 70) + + layout.addWidget(self.project_list) + + # Button row + button_layout = QHBoxLayout() + + self.new_project_btn = QPushButton("βž• New Project") + self.new_project_btn.setToolTip("Create a new project") + self.new_project_btn.clicked.connect(self.on_new_project) + button_layout.addWidget(self.new_project_btn) + + self.view_stats_btn = QPushButton("πŸ“Š View Stats") + self.view_stats_btn.setToolTip("View selected project statistics") + self.view_stats_btn.clicked.connect(self.on_view_stats) + self.view_stats_btn.setEnabled(False) + button_layout.addWidget(self.view_stats_btn) + + self.refresh_btn = QPushButton("πŸ”„ Refresh") + self.refresh_btn.setToolTip("Refresh project list") + self.refresh_btn.clicked.connect(self.refresh_project_list) + button_layout.addWidget(self.refresh_btn) + + layout.addLayout(button_layout) + + return panel + + def create_session_panel(self) -> QGroupBox: + """Create the session control panel.""" + panel = QGroupBox("Session Control") + layout = QVBoxLayout(panel) + layout.setSpacing(10) + + # Current project display + project_info_layout = QFormLayout() + self.current_project_label = QLabel("No project selected") + self.current_project_label.setStyleSheet("font-weight: bold; color: #888;") + project_info_layout.addRow("Selected Project:", self.current_project_label) + layout.addLayout(project_info_layout) + + # Separator line + separator = QFrame() + separator.setFrameShape(QFrame.Shape.HLine) + separator.setStyleSheet("background-color: #444;") + layout.addWidget(separator) + + # Session status + status_layout = QHBoxLayout() + status_layout.addWidget(QLabel("Status:")) + self.session_status_label = QLabel("Idle") + self.session_status_label.setStyleSheet(""" + QLabel { + font-weight: bold; + color: #888; + padding: 5px 15px; + background-color: #2a2a2a; + border-radius: 4px; + border: 1px solid #444; + } + """) + status_layout.addWidget(self.session_status_label) + status_layout.addStretch() + layout.addLayout(status_layout) + + # Control buttons + button_layout = QHBoxLayout() + + self.start_session_btn = QPushButton("▢️ Start Session") + self.start_session_btn.setToolTip("Start a new session with selected project") + self.start_session_btn.clicked.connect(self.on_start_session) + self.start_session_btn.setEnabled(False) + button_layout.addWidget(self.start_session_btn) + + self.stop_session_btn = QPushButton("⏹️ Stop") + self.stop_session_btn.setToolTip("Stop current session") + self.stop_session_btn.clicked.connect(self.on_stop_session) + self.stop_session_btn.setEnabled(False) + button_layout.addWidget(self.stop_session_btn) + + self.pause_session_btn = QPushButton("⏸️ Pause") + self.pause_session_btn.setToolTip("Pause/Resume current session") + self.pause_session_btn.clicked.connect(self.on_pause_session) + self.pause_session_btn.setEnabled(False) + button_layout.addWidget(self.pause_session_btn) + + layout.addLayout(button_layout) + + # Session info + self.session_info_label = QLabel("Ready to start") + self.session_info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.session_info_label.setStyleSheet("color: #666; padding: 10px;") + layout.addWidget(self.session_info_label) + + layout.addStretch() + + return panel + + def create_log_panel(self) -> QGroupBox: + """Create the log output panel.""" + panel = QGroupBox("Event Log") + layout = QVBoxLayout(panel) + layout.setSpacing(8) + + # Log text edit + self.log_output = QTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setFont(QFont("Consolas", 10)) + self.log_output.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) + layout.addWidget(self.log_output) + + # Log controls + controls_layout = QHBoxLayout() + + self.clear_log_btn = QPushButton("πŸ—‘οΈ Clear") + self.clear_log_btn.setToolTip("Clear log output") + self.clear_log_btn.clicked.connect(self.log_output.clear) + controls_layout.addWidget(self.clear_log_btn) + + controls_layout.addStretch() + + self.auto_scroll_check = QLabel("βœ“ Auto-scroll") + self.auto_scroll_check.setStyleSheet("color: #888;") + controls_layout.addWidget(self.auto_scroll_check) + + layout.addLayout(controls_layout) + + return panel + + def create_menu_bar(self): + """Create the application menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + new_project_action = QAction("&New Project", self) + new_project_action.setShortcut("Ctrl+N") + new_project_action.triggered.connect(self.on_new_project) + file_menu.addAction(new_project_action) + + open_project_action = QAction("&Open Project", self) + open_project_action.setShortcut("Ctrl+O") + open_project_action.triggered.connect(self.on_open_project) + file_menu.addAction(open_project_action) + + file_menu.addSeparator() + + exit_action = QAction("E&xit", self) + exit_action.setShortcut("Alt+F4") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Session menu + session_menu = menubar.addMenu("&Session") + + start_action = QAction("&Start", self) + start_action.setShortcut("F5") + start_action.triggered.connect(self.on_start_session) + session_menu.addAction(start_action) + self.start_action = start_action + + stop_action = QAction("St&op", self) + stop_action.setShortcut("Shift+F5") + stop_action.triggered.connect(self.on_stop_session) + session_menu.addAction(stop_action) + self.stop_action = stop_action + + pause_action = QAction("&Pause", self) + pause_action.setShortcut("F6") + pause_action.triggered.connect(self.on_pause_session) + session_menu.addAction(pause_action) + self.pause_action = pause_action + + # View menu + view_menu = menubar.addMenu("&View") + + show_hud_action = QAction("Show &HUD", self) + show_hud_action.setShortcut("F9") + show_hud_action.triggered.connect(self.on_show_hud) + view_menu.addAction(show_hud_action) + + hide_hud_action = QAction("&Hide HUD", self) + hide_hud_action.setShortcut("F10") + hide_hud_action.triggered.connect(self.on_hide_hud) + view_menu.addAction(hide_hud_action) + + view_menu.addSeparator() + + settings_action = QAction("&Settings", self) + settings_action.setShortcut("Ctrl+,") + settings_action.triggered.connect(self.on_settings) + view_menu.addAction(settings_action) + + # Help menu + help_menu = menubar.addMenu("&Help") + + about_action = QAction("&About", self) + about_action.triggered.connect(self.on_about) + help_menu.addAction(about_action) + + def create_status_bar(self): + """Create the status bar.""" + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + + # Permanent widgets + self.status_state_label = QLabel("● Idle") + self.status_state_label.setStyleSheet("color: #888; padding: 0 10px;") + self.status_bar.addPermanentWidget(self.status_state_label) + + self.status_project_label = QLabel("No project") + self.status_project_label.setStyleSheet("color: #888; padding: 0 10px;") + self.status_bar.addPermanentWidget(self.status_project_label) + + # Message area + self.status_bar.showMessage("Ready") + + # ======================================================================== + # Theme + # ======================================================================== + + def apply_dark_theme(self): + """Apply dark theme styling.""" + dark_stylesheet = """ + QMainWindow { + background-color: #1e1e1e; + } + + QWidget { + background-color: #1e1e1e; + color: #e0e0e0; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 10pt; + } + + QGroupBox { + font-weight: bold; + border: 1px solid #444; + border-radius: 6px; + margin-top: 10px; + padding-top: 10px; + padding: 10px; + } + + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + color: #888; + } + + QPushButton { + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 4px; + padding: 8px 16px; + color: #e0e0e0; + } + + QPushButton:hover { + background-color: #3d3d3d; + border-color: #555; + } + + QPushButton:pressed { + background-color: #4d4d4d; + } + + QPushButton:disabled { + background-color: #252525; + color: #666; + border-color: #333; + } + + QPushButton#start_button { + background-color: #1b5e20; + border-color: #2e7d32; + } + + QPushButton#start_button:hover { + background-color: #2e7d32; + } + + QPushButton#stop_button { + background-color: #b71c1c; + border-color: #c62828; + } + + QPushButton#stop_button:hover { + background-color: #c62828; + } + + QTreeWidget { + background-color: #252525; + border: 1px solid #444; + border-radius: 4px; + outline: none; + } + + QTreeWidget::item { + padding: 6px; + border-bottom: 1px solid #333; + } + + QTreeWidget::item:selected { + background-color: #0d47a1; + color: white; + } + + QTreeWidget::item:alternate { + background-color: #2a2a2a; + } + + QHeaderView::section { + background-color: #2d2d2d; + padding: 6px; + border: none; + border-right: 1px solid #444; + font-weight: bold; + } + + QTextEdit { + background-color: #151515; + border: 1px solid #444; + border-radius: 4px; + padding: 8px; + color: #d0d0d0; + } + + QLineEdit { + background-color: #252525; + border: 1px solid #444; + border-radius: 4px; + padding: 6px; + color: #e0e0e0; + } + + QLineEdit:focus { + border-color: #0d47a1; + } + + QMenuBar { + background-color: #1e1e1e; + border-bottom: 1px solid #444; + } + + QMenuBar::item { + background-color: transparent; + padding: 6px 12px; + } + + QMenuBar::item:selected { + background-color: #2d2d2d; + } + + QMenu { + background-color: #2d2d2d; + border: 1px solid #444; + padding: 4px; + } + + QMenu::item { + padding: 6px 24px; + border-radius: 2px; + } + + QMenu::item:selected { + background-color: #0d47a1; + } + + QMenu::separator { + height: 1px; + background-color: #444; + margin: 4px 8px; + } + + QStatusBar { + background-color: #1e1e1e; + border-top: 1px solid #444; + } + + QSplitter::handle { + background-color: #444; + } + + QSplitter::handle:horizontal { + width: 2px; + } + + QSplitter::handle:vertical { + height: 2px; + } + + QDialog { + background-color: #1e1e1e; + } + + QLabel { + color: #e0e0e0; + } + + QFormLayout QLabel { + color: #888; + } + """ + self.setStyleSheet(dark_stylesheet) + + # ======================================================================== + # Project Management + # ======================================================================== + + def refresh_project_list(self): + """Refresh the project list display.""" + self.project_list.clear() + projects = self.project_manager.get_all_projects() + + for project in projects: + item = QTreeWidgetItem([ + str(project.id), + project.name, + str(project.session_count) + ]) + item.setData(0, Qt.ItemDataRole.UserRole, project.id) + self.project_list.addTopLevelItem(item) + + self.log_debug("ProjectManager", f"Loaded {len(projects)} projects") + + def on_project_selected(self): + """Handle project selection change.""" + selected = self.project_list.selectedItems() + if selected: + project_id = selected[0].data(0, Qt.ItemDataRole.UserRole) + self.current_project = self.project_manager.get_project(project_id) + + if self.current_project: + self.current_project_label.setText(self.current_project.name) + self.current_project_label.setStyleSheet("font-weight: bold; color: #4caf50;") + self.view_stats_btn.setEnabled(True) + self.start_session_btn.setEnabled(self.session_state == SessionState.IDLE) + self.status_project_label.setText(f"Project: {self.current_project.name}") + self.log_debug("ProjectManager", f"Selected project: {self.current_project.name}") + else: + self.current_project = None + self.current_project_label.setText("No project selected") + self.current_project_label.setStyleSheet("font-weight: bold; color: #888;") + self.view_stats_btn.setEnabled(False) + self.start_session_btn.setEnabled(False) + self.status_project_label.setText("No project") + + def on_project_double_clicked(self, item: QTreeWidgetItem, column: int): + """Handle double-click on project.""" + project_id = item.data(0, Qt.ItemDataRole.UserRole) + project = self.project_manager.get_project(project_id) + if project: + self.show_project_stats(project) + + def on_new_project(self): + """Handle new project creation.""" + dialog = NewProjectDialog(self) + if dialog.exec() == QDialog.DialogCode.Accepted: + name, description = dialog.get_project_data() + project = self.project_manager.create_project(name, description) + self.refresh_project_list() + self.log_info("ProjectManager", f"Created project: {project.name}") + self.status_bar.showMessage(f"Project '{name}' created", 3000) + + def on_open_project(self): + """Handle open project action.""" + # For now, just focus the project list + self.project_list.setFocus() + self.status_bar.showMessage("Select a project from the list", 3000) + + def on_view_stats(self): + """Handle view stats button.""" + if self.current_project: + self.show_project_stats(self.current_project) + + def show_project_stats(self, project: Project): + """Show project statistics dialog.""" + dialog = ProjectStatsDialog(project, self) + dialog.exec() + + # ======================================================================== + # Session Control + # ======================================================================== + + def start_session(self, project_id: int): + """ + Start a new session with the given project. + + Args: + project_id: The ID of the project to start session for + """ + project = self.project_manager.get_project(project_id) + if not project: + self.log_error("Session", f"Project {project_id} not found") + return + + if self.session_state != SessionState.IDLE: + self.log_warning("Session", "Cannot start: session already active") + return + + # Update state + self.set_session_state(SessionState.RUNNING) + self.current_session_id = project_id + + # Emit signal + self.session_started.emit(project_id) + + # Log + self.log_info("Session", f"Started session for project: {project.name}") + self.session_info_label.setText(f"Session active: {project.name}") + + # Show HUD + self.hud.show() + + def on_start_session(self): + """Handle start session button.""" + if self.current_project and self.session_state == SessionState.IDLE: + self.start_session(self.current_project.id) + + def on_stop_session(self): + """Handle stop session button.""" + if self.session_state in (SessionState.RUNNING, SessionState.PAUSED): + self.set_session_state(SessionState.IDLE) + self.current_session_id = None + + self.session_stopped.emit() + + self.log_info("Session", "Session stopped") + self.session_info_label.setText("Session stopped") + + # Hide HUD + self.hud.hide() + + def on_pause_session(self): + """Handle pause/resume session button.""" + if self.session_state == SessionState.RUNNING: + self.set_session_state(SessionState.PAUSED) + self.session_paused.emit() + self.log_info("Session", "Session paused") + self.session_info_label.setText("Session paused") + self.pause_session_btn.setText("▢️ Resume") + elif self.session_state == SessionState.PAUSED: + self.set_session_state(SessionState.RUNNING) + self.session_resumed.emit() + self.log_info("Session", "Session resumed") + self.session_info_label.setText("Session resumed") + self.pause_session_btn.setText("⏸️ Pause") + + def set_session_state(self, state: SessionState): + """ + Update the session state and UI. + + Args: + state: New session state + """ + self.session_state = state + + # Update status label + colors = { + SessionState.IDLE: "#888", + SessionState.RUNNING: "#4caf50", + SessionState.PAUSED: "#ff9800", + SessionState.ERROR: "#f44336", + SessionState.STOPPING: "#ff5722" + } + + self.session_status_label.setText(state.value) + self.session_status_label.setStyleSheet(f""" + QLabel {{ + font-weight: bold; + color: {colors.get(state, '#888')}; + padding: 5px 15px; + background-color: #2a2a2a; + border-radius: 4px; + border: 1px solid #444; + }} + """) + + # Update status bar + self.status_state_label.setText(f"● {state.value}") + self.status_state_label.setStyleSheet(f"color: {colors.get(state, '#888')}; padding: 0 10px;") + + # Update buttons + self.start_session_btn.setEnabled( + state == SessionState.IDLE and self.current_project is not None + ) + self.stop_session_btn.setEnabled(state in (SessionState.RUNNING, SessionState.PAUSED)) + self.pause_session_btn.setEnabled(state in (SessionState.RUNNING, SessionState.PAUSED)) + + # Update menu actions + self.start_action.setEnabled(self.start_session_btn.isEnabled()) + self.stop_action.setEnabled(self.stop_session_btn.isEnabled()) + self.pause_action.setEnabled(self.pause_session_btn.isEnabled()) + + if state == SessionState.IDLE: + self.pause_session_btn.setText("⏸️ Pause") + + # ======================================================================== + # Log Handling + # ======================================================================== + + def on_log_event(self, event: LogEvent): + """ + Handle incoming log events. + + Args: + event: The log event to display + """ + # Color mapping + colors = { + "DEBUG": "#888", + "INFO": "#4fc3f7", + "WARNING": "#ff9800", + "ERROR": "#f44336", + "CRITICAL": "#e91e63" + } + + color = colors.get(event.level, "#e0e0e0") + html = f'{self.escape_html(str(event))}' + + self.log_output.append(html) + + # Auto-scroll to bottom + scrollbar = self.log_output.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def log_debug(self, source: str, message: str): + """Log a debug message.""" + self.log_watcher.emit(LogEvent( + timestamp=datetime.now(), + level="DEBUG", + source=source, + message=message + )) + + def log_info(self, source: str, message: str): + """Log an info message.""" + self.log_watcher.emit(LogEvent( + timestamp=datetime.now(), + level="INFO", + source=source, + message=message + )) + + def log_warning(self, source: str, message: str): + """Log a warning message.""" + self.log_watcher.emit(LogEvent( + timestamp=datetime.now(), + level="WARNING", + source=source, + message=message + )) + + def log_error(self, source: str, message: str): + """Log an error message.""" + self.log_watcher.emit(LogEvent( + timestamp=datetime.now(), + level="ERROR", + source=source, + message=message + )) + + def escape_html(self, text: str) -> str: + """Escape HTML special characters.""" + return (text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">")) + + # ======================================================================== + # Menu Actions + # ======================================================================== + + def on_show_hud(self): + """Show the HUD overlay.""" + self.hud.show() + self.log_info("HUD", "HUD overlay shown") + + def on_hide_hud(self): + """Hide the HUD overlay.""" + self.hud.hide() + self.log_info("HUD", "HUD overlay hidden") + + def on_settings(self): + """Open settings dialog.""" + dialog = SettingsDialog(self) + dialog.exec() + + def on_about(self): + """Show about dialog.""" + QMessageBox.about( + self, + "About Lemontropia Suite", + """

Lemontropia Suite

+

Version 1.0.0

+

A PyQt6-based GUI for game automation and session management.

+

Features:

+ + """ + ) + + # ======================================================================== + # Event Overrides + # ======================================================================== + + def closeEvent(self, event): + """Handle window close event.""" + if self.session_state == SessionState.RUNNING: + reply = QMessageBox.question( + self, + "Confirm Exit", + "A session is currently running. Are you sure you want to exit?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + self.on_stop_session() + event.accept() + else: + event.ignore() + else: + event.accept() + + +# ============================================================================ +# Test Entry Point +# ============================================================================ + +def main(): + """Main entry point for testing.""" + app = QApplication(sys.argv) + + # Set application-wide font + font = QFont("Segoe UI", 10) + app.setFont(font) + + # Create and show main window + window = MainWindow() + window.show() + + # Simulate some log activity for demonstration + def simulate_logs(): + import random + sources = ["Engine", "Input", "Vision", "Network", "Session"] + levels = ["DEBUG", "INFO", "INFO", "INFO", "WARNING"] + messages = [ + "Initializing component...", + "Connection established", + "Processing frame #1234", + "Waiting for input", + "Buffer cleared", + "Sync complete" + ] + + if window.session_state == SessionState.RUNNING: + if random.random() < 0.3: # 30% chance each tick + event = LogEvent( + timestamp=datetime.now(), + level=random.choice(levels), + source=random.choice(sources), + message=random.choice(messages) + ) + window.log_watcher.emit(event) + + # Timer to simulate log activity + timer = QTimer() + timer.timeout.connect(simulate_logs) + timer.start(1000) # Every second + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main()