From b2856639dd3660c33623f2ee785267f88ff25c14 Mon Sep 17 00:00:00 2001 From: devmatrix Date: Tue, 17 Feb 2026 00:38:45 +0000 Subject: [PATCH] feat: Add system tray, settings window, and fix window detection for 'Entropia Universe Client' --- premium/__init__.py | 155 +++++++++--- premium/eu_integration/window_tracker.py | 23 +- premium/ui/tray.py | 291 +++++++++++++++++++++++ 3 files changed, 431 insertions(+), 38 deletions(-) create mode 100644 premium/ui/tray.py diff --git a/premium/__init__.py b/premium/__init__.py index 0130566..f6349ea 100644 --- a/premium/__init__.py +++ b/premium/__init__.py @@ -13,7 +13,7 @@ This package provides the premium/enterprise layer with: Quick Start: from premium import EUUtilityApp - + app = EUUtilityApp() app.run() @@ -45,81 +45,175 @@ from premium.widgets.dashboard_widget import DashboardWidget from premium.eu_integration.game_client import GameClient from premium.eu_integration.log_parser import LogParser from premium.eu_integration.events import GameEvent, LootEvent, SkillEvent +from premium.eu_integration.window_tracker import WindowTracker + +# UI +from premium.ui.tray import TrayIcon, SettingsWindow class EUUtilityApp: """Main application class for EU-Utility Premium. - + This is the high-level API for the entire premium system. - + Example: app = EUUtilityApp() app.initialize() app.run() """ - + def __init__(self, config_path=None): self.config_path = config_path + self.config = {} self.plugin_manager = None self.state_store = None self.event_bus = None self.game_client = None self.dashboard = None + self.tray_icon = None + self.settings_window = None + self.qt_app = None self._initialized = False - + self._running = False + def initialize(self): """Initialize all subsystems.""" from pathlib import Path - + import json + + # Load config + if self.config_path: + try: + with open(self.config_path, 'r') as f: + self.config = json.load(f) + except: + self.config = {} + # Create event bus self.event_bus = EventBus() - + # Create state store self.state_store = StateStore( reducer=self._root_reducer, initial_state=self._get_initial_state() ) - + # Create plugin manager plugin_dirs = [ Path(__file__).parent.parent / "plugins" / "builtin", Path(__file__).parent.parent / "plugins" / "user", ] data_dir = Path.home() / ".eu-utility" / "data" - + self.plugin_manager = PluginManager( plugin_dirs=plugin_dirs, data_dir=data_dir, event_bus=self.event_bus, state_store=self.state_store ) - + # Create game client - self.game_client = GameClient(event_bus=self.event_bus) - + self.game_client = GameClient( + game_path=Path(self.config.get('game_path', '')) if self.config.get('game_path') else None, + event_bus=self.event_bus + ) + self._initialized = True - + def run(self): - """Run the application.""" + """Run the application with Qt GUI.""" + from PyQt6.QtWidgets import QApplication + import sys + if not self._initialized: self.initialize() - - # Start event bus first (captures main event loop) + + # Create Qt Application + self.qt_app = QApplication(sys.argv) + self.qt_app.setQuitOnLastWindowClosed(False) # Keep running when window closed + + # Start event bus self.event_bus.start() - + + # Create system tray icon + self.tray_icon = TrayIcon() + self.tray_icon.toggle_overlay.connect(self._toggle_overlay) + self.tray_icon.show_settings.connect(self._show_settings) + self.tray_icon.show_dashboard.connect(self._show_dashboard) + self.tray_icon.quit_requested.connect(self._quit) + self.tray_icon.show() + # Discover and load plugins self.plugin_manager.discover_all() self.plugin_manager.load_all(auto_activate=True) - + # Start game client self.game_client.start() - - # Run main loop + + self._running = True + + # Update tray status periodically + self._update_status_timer() + + # Run Qt event loop try: - self._main_loop() - except KeyboardInterrupt: + sys.exit(self.qt_app.exec()) + except: self.shutdown() - + + def _update_status_timer(self): + """Update tray status periodically.""" + if not self._running: + return + + # Update status + if self.game_client.is_connected: + status = "Connected" + else: + status = "Waiting for game..." + + if self.tray_icon: + self.tray_icon.update_status(status) + + # Schedule next update + from PyQt6.QtCore import QTimer + QTimer.singleShot(5000, self._update_status_timer) + + def _toggle_overlay(self): + """Toggle overlay visibility.""" + # TODO: Implement overlay toggle + if self.tray_icon: + self.tray_icon.notify( + "EU-Utility", + "Overlay toggle not yet implemented", + QSystemTrayIcon.MessageIcon.Warning + ) + + def _show_settings(self): + """Show settings window.""" + if not self.settings_window: + self.settings_window = SettingsWindow(self.config) + self.settings_window.show() + self.settings_window.raise_() + + def _show_dashboard(self): + """Show dashboard window.""" + if self.tray_icon: + self.tray_icon.notify( + "EU-Utility", + "Dashboard not yet implemented", + QSystemTrayIcon.MessageIcon.Warning + ) + + def _quit(self): + """Quit the application.""" + from PyQt6.QtWidgets import QSystemTrayIcon + + self._running = False + self.shutdown() + if self.qt_app: + self.qt_app.quit() + def shutdown(self): """Shutdown the application gracefully.""" if self.game_client: @@ -128,13 +222,11 @@ class EUUtilityApp: self.plugin_manager.shutdown() if self.event_bus: self.event_bus.stop() - + def _root_reducer(self, state, action): """Root state reducer.""" - # Default reducer - just returns state - # Plugins can register their own reducers return state or {} - + def _get_initial_state(self): """Get initial application state.""" return { @@ -150,12 +242,6 @@ class EUUtilityApp: }, 'plugins': {}, } - - def _main_loop(self): - """Main application loop.""" - import time - while True: - time.sleep(0.1) __all__ = [ @@ -175,4 +261,7 @@ __all__ = [ 'Widget', 'WidgetConfig', 'Dashboard', # EU Integration 'GameClient', 'LogParser', 'GameEvent', 'LootEvent', 'SkillEvent', + 'WindowTracker', + # UI + 'TrayIcon', 'SettingsWindow', ] diff --git a/premium/eu_integration/window_tracker.py b/premium/eu_integration/window_tracker.py index 0723056..e8d9815 100644 --- a/premium/eu_integration/window_tracker.py +++ b/premium/eu_integration/window_tracker.py @@ -87,11 +87,24 @@ class WindowTracker: if self._process_id: _, pid = win32process.GetWindowThreadProcessId(hwnd) - return pid == self._process_id - else: - # Check window title - title = win32gui.GetWindowText(hwnd) - return 'entropia' in title.lower() + if pid == self._process_id: + return True + + # Check window title - supports multiple patterns + title = win32gui.GetWindowText(hwnd) + title_lower = title.lower() + + # Match various EU window titles: + # - "Entropia Universe Client (64 bit)" + # - "Entropia Universe Client (64 bit) - Planet Calypso" + # - "Entropia.exe" + patterns = [ + 'entropia universe client', + 'entropia.exe', + 'entropia universe' + ] + + return any(pattern in title_lower for pattern in patterns) except ImportError: pass diff --git a/premium/ui/tray.py b/premium/ui/tray.py new file mode 100644 index 0000000..10c4020 --- /dev/null +++ b/premium/ui/tray.py @@ -0,0 +1,291 @@ +""" +System Tray Integration for EU-Utility +======================================= + +Provides system tray icon with menu for controlling the application. +""" + +import sys +from typing import Callable, Optional +from PyQt6.QtWidgets import ( + QSystemTrayIcon, QMenu, QAction, QWidget, QApplication, + QMessageBox, QInputDialog +) +from PyQt6.QtCore import pyqtSignal, QObject +from PyQt6.QtGui import QIcon, QAction as QActionGui + + +class TrayIcon(QObject): + """System tray icon with menu. + + Signals: + toggle_overlay: Emitted when overlay toggle is clicked + show_settings: Emitted when settings is clicked + show_dashboard: Emitted when dashboard is clicked + quit: Emitted when quit is clicked + """ + + toggle_overlay = pyqtSignal() + show_settings = pyqtSignal() + show_dashboard = pyqtSignal() + quit_requested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + + self.tray_icon: Optional[QSystemTrayIcon] = None + self.menu: Optional[QMenu] = None + + self._setup_tray() + + def _setup_tray(self): + """Setup the system tray icon.""" + # Create tray icon + self.tray_icon = QSystemTrayIcon(self.parent()) + + # Try to use a custom icon, fallback to system icon + icon = self._load_icon() + self.tray_icon.setIcon(icon) + self.tray_icon.setToolTip("EU-Utility - Entropia Universe Overlay") + + # Create menu + self.menu = QMenu() + self._build_menu() + + self.tray_icon.setContextMenu(self.menu) + + # Handle left click + self.tray_icon.activated.connect(self._on_activated) + + def _load_icon(self) -> QIcon: + """Load tray icon.""" + from pathlib import Path + + # Try custom icon first + icon_paths = [ + Path(__file__).parent.parent.parent / "assets" / "icon.ico", + Path(__file__).parent.parent.parent / "assets" / "icon.png", + ] + + for path in icon_paths: + if path.exists(): + return QIcon(str(path)) + + # Fallback to system icon + return QIcon.fromTheme("applications-games") + + def _build_menu(self): + """Build the context menu.""" + # Toggle Overlay + toggle_action = QActionGui("Toggle Overlay (Ctrl+Shift+B)", self) + toggle_action.triggered.connect(self.toggle_overlay.emit) + self.menu.addAction(toggle_action) + + self.menu.addSeparator() + + # Dashboard + dashboard_action = QActionGui("Open Dashboard", self) + dashboard_action.triggered.connect(self.show_dashboard.emit) + self.menu.addAction(dashboard_action) + + # Settings + settings_action = QActionGui("Settings...", self) + settings_action.triggered.connect(self.show_settings.emit) + self.menu.addAction(settings_action) + + self.menu.addSeparator() + + # Status + self.status_action = QActionGui("Status: Running", self) + self.status_action.setEnabled(False) + self.menu.addAction(self.status_action) + + self.menu.addSeparator() + + # Quit + quit_action = QActionGui("Quit", self) + quit_action.triggered.connect(self.quit_requested.emit) + self.menu.addAction(quit_action) + + def _on_activated(self, reason: QSystemTrayIcon.ActivationReason): + """Handle tray icon activation.""" + if reason == QSystemTrayIcon.ActivationReason.Trigger: + # Left click - toggle overlay + self.toggle_overlay.emit() + elif reason == QSystemTrayIcon.ActivationReason.Context: + # Right click - show menu (handled automatically) + pass + + def show(self): + """Show the tray icon.""" + if self.tray_icon: + self.tray_icon.show() + self.tray_icon.showMessage( + "EU-Utility", + "Application is running in system tray.\n" + "Press Ctrl+Shift+B to toggle overlay.", + QSystemTrayIcon.MessageIcon.Information, + 3000 + ) + + def hide(self): + """Hide the tray icon.""" + if self.tray_icon: + self.tray_icon.hide() + + def update_status(self, status: str): + """Update the status menu item.""" + if hasattr(self, 'status_action'): + self.status_action.setText(f"Status: {status}") + + def notify(self, title: str, message: str, icon=QSystemTrayIcon.MessageIcon.Information): + """Show a tray notification.""" + if self.tray_icon and self.tray_icon.isVisible(): + self.tray_icon.showMessage(title, message, icon, 3000) + + +class SettingsWindow(QWidget): + """Desktop settings window.""" + + def __init__(self, config: dict, parent=None): + super().__init__(parent) + self.config = config + self._setup_ui() + + def _setup_ui(self): + """Setup the settings UI.""" + from PyQt6.QtWidgets import ( + QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QCheckBox, QComboBox, QSpinBox, + QGroupBox, QTabWidget, QFileDialog + ) + + self.setWindowTitle("EU-Utility Settings") + self.setMinimumSize(500, 400) + + layout = QVBoxLayout(self) + + # Tabs + tabs = QTabWidget() + layout.addWidget(tabs) + + # General tab + general_tab = QWidget() + general_layout = QVBoxLayout(general_tab) + + # Game Path + path_group = QGroupBox("Game Path") + path_layout = QHBoxLayout(path_group) + + self.path_edit = QLineEdit(self.config.get('game_path', '')) + path_layout.addWidget(self.path_edit) + + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._browse_game_path) + path_layout.addWidget(browse_btn) + + general_layout.addWidget(path_group) + + # Overlay Settings + overlay_group = QGroupBox("Overlay Settings") + overlay_layout = QVBoxLayout(overlay_group) + + self.overlay_checkbox = QCheckBox("Enable Overlay") + self.overlay_checkbox.setChecked(self.config.get('overlay_enabled', True)) + overlay_layout.addWidget(self.overlay_checkbox) + + # Hotkey + hotkey_layout = QHBoxLayout() + hotkey_layout.addWidget(QLabel("Toggle Hotkey:")) + self.hotkey_edit = QLineEdit(self.config.get('hotkey', 'ctrl+shift+b')) + hotkey_layout.addWidget(self.hotkey_edit) + overlay_layout.addLayout(hotkey_layout) + + # Overlay Mode + mode_layout = QHBoxLayout() + mode_layout.addWidget(QLabel("Overlay Mode:")) + self.mode_combo = QComboBox() + self.mode_combo.addItems([ + 'overlay_toggle', + 'overlay_game', + 'overlay_always', + 'overlay_temp' + ]) + self.mode_combo.setCurrentText(self.config.get('overlay_mode', 'overlay_toggle')) + mode_layout.addWidget(self.mode_combo) + overlay_layout.addLayout(mode_layout) + + general_layout.addWidget(overlay_group) + + # Theme Settings + theme_group = QGroupBox("Appearance") + theme_layout = QVBoxLayout(theme_group) + + theme_layout.addWidget(QLabel("Theme:")) + self.theme_combo = QComboBox() + self.theme_combo.addItems(['dark', 'light']) + self.theme_combo.setCurrentText(self.config.get('ui', {}).get('theme', 'dark')) + theme_layout.addWidget(self.theme_combo) + + general_layout.addWidget(theme_group) + + general_layout.addStretch() + + tabs.addTab(general_tab, "General") + + # Plugins tab + plugins_tab = QWidget() + plugins_layout = QVBoxLayout(plugins_tab) + plugins_layout.addWidget(QLabel("Plugin settings will appear here")) + plugins_layout.addStretch() + tabs.addTab(plugins_tab, "Plugins") + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + save_btn = QPushButton("Save") + save_btn.clicked.connect(self._save_settings) + button_layout.addWidget(save_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.close) + button_layout.addWidget(cancel_btn) + + layout.addLayout(button_layout) + + def _browse_game_path(self): + """Browse for game path.""" + from PyQt6.QtWidgets import QFileDialog + + path = QFileDialog.getExistingDirectory( + self, + "Select Entropia Universe Directory", + self.path_edit.text() or "C:/" + ) + + if path: + self.path_edit.setText(path) + + def _save_settings(self): + """Save settings to config file.""" + self.config['game_path'] = self.path_edit.text() + self.config['overlay_enabled'] = self.overlay_checkbox.isChecked() + self.config['hotkey'] = self.hotkey_edit.text() + self.config['overlay_mode'] = self.mode_combo.currentText() + + if 'ui' not in self.config: + self.config['ui'] = {} + self.config['ui']['theme'] = self.theme_combo.currentText() + + # Save to file + import json + from pathlib import Path + + config_path = Path.home() / '.eu-utility' / 'config.json' + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(self.config, f, indent=2) + + self.close()