diff --git a/core/hotkey_manager.py b/core/hotkey_manager.py new file mode 100644 index 0000000..ea4967b --- /dev/null +++ b/core/hotkey_manager.py @@ -0,0 +1,253 @@ +""" +EU-Utility - Configurable Hotkey Manager + +Manages global and local hotkeys with user customization. +""" + +import json +from pathlib import Path +from typing import Dict, Callable, Optional, List +from dataclasses import dataclass, asdict +from enum import Enum + + +class HotkeyScope(Enum): + """Scope of hotkey action.""" + GLOBAL = "global" # Works even when app not focused + LOCAL = "local" # Only when app is focused + OVERLAY = "overlay" # Only when overlay is visible + + +@dataclass +class HotkeyConfig: + """Configuration for a single hotkey.""" + action: str + keys: str + scope: str + enabled: bool = True + description: str = "" + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> 'HotkeyConfig': + return cls(**data) + + +class HotkeyManager: + """Manages configurable hotkeys for EU-Utility.""" + + DEFAULT_HOTKEYS = { + 'toggle_overlay': HotkeyConfig( + action='toggle_overlay', + keys='ctrl+shift+u', + scope='global', + enabled=True, + description='Toggle main overlay window' + ), + 'hide_overlays': HotkeyConfig( + action='hide_overlays', + keys='ctrl+shift+h', + scope='global', + enabled=True, + description='Hide all overlays' + ), + 'quick_search': HotkeyConfig( + action='quick_search', + keys='ctrl+shift+f', + scope='global', + enabled=True, + description='Open universal search' + ), + 'nexus_search': HotkeyConfig( + action='nexus_search', + keys='ctrl+shift+n', + scope='global', + enabled=True, + description='Open Nexus search' + ), + 'calculator': HotkeyConfig( + action='calculator', + keys='ctrl+shift+c', + scope='global', + enabled=True, + description='Open calculator' + ), + 'spotify': HotkeyConfig( + action='spotify', + keys='ctrl+shift+m', + scope='global', + enabled=True, + description='Toggle Spotify controller' + ), + 'game_reader': HotkeyConfig( + action='game_reader', + keys='ctrl+shift+r', + scope='global', + enabled=True, + description='Open game reader (OCR)' + ), + 'skill_scanner': HotkeyConfig( + action='skill_scanner', + keys='ctrl+shift+s', + scope='global', + enabled=True, + description='Open skill scanner' + ), + 'loot_tracker': HotkeyConfig( + action='loot_tracker', + keys='ctrl+shift+l', + scope='global', + enabled=True, + description='Open loot tracker' + ), + 'screenshot': HotkeyConfig( + action='screenshot', + keys='ctrl+shift+p', + scope='global', + enabled=True, + description='Take screenshot' + ), + 'close_overlay': HotkeyConfig( + action='close_overlay', + keys='esc', + scope='overlay', + enabled=True, + description='Close overlay (when visible)' + ), + 'toggle_theme': HotkeyConfig( + action='toggle_theme', + keys='ctrl+t', + scope='local', + enabled=True, + description='Toggle dark/light theme' + ), + } + + def __init__(self, config_path: str = "config/hotkeys.json"): + self.config_path = Path(config_path) + self.hotkeys: Dict[str, HotkeyConfig] = {} + self._handlers: Dict[str, Callable] = {} + self._registered_globals: List[str] = [] + + self._load_config() + + def _load_config(self): + """Load hotkey configuration from file.""" + if self.config_path.exists(): + try: + with open(self.config_path, 'r') as f: + data = json.load(f) + + for key, config_dict in data.items(): + self.hotkeys[key] = HotkeyConfig.from_dict(config_dict) + + # Add any missing defaults + for key, default in self.DEFAULT_HOTKEYS.items(): + if key not in self.hotkeys: + self.hotkeys[key] = default + + except Exception as e: + print(f"[HotkeyManager] Failed to load config: {e}") + self.hotkeys = self.DEFAULT_HOTKEYS.copy() + else: + self.hotkeys = self.DEFAULT_HOTKEYS.copy() + self._save_config() + + def _save_config(self): + """Save hotkey configuration to file.""" + try: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + data = {k: v.to_dict() for k, v in self.hotkeys.items()} + with open(self.config_path, 'w') as f: + json.dump(data, f, indent=2) + except Exception as e: + print(f"[HotkeyManager] Failed to save config: {e}") + + def get_hotkey(self, action: str) -> Optional[HotkeyConfig]: + """Get hotkey configuration for an action.""" + return self.hotkeys.get(action) + + def set_hotkey(self, action: str, keys: str, scope: str = None, enabled: bool = None): + """Update hotkey configuration.""" + if action in self.hotkeys: + config = self.hotkeys[action] + config.keys = keys + if scope is not None: + config.scope = scope + if enabled is not None: + config.enabled = enabled + self._save_config() + return True + return False + + def register_handler(self, action: str, handler: Callable): + """Register a handler function for an action.""" + self._handlers[action] = handler + + def get_all_hotkeys(self) -> Dict[str, HotkeyConfig]: + """Get all hotkey configurations.""" + return self.hotkeys.copy() + + def validate_key_combo(self, keys: str) -> tuple[bool, str]: + """Validate a key combination string. + + Returns: + (is_valid, error_message) + """ + if not keys or not isinstance(keys, str): + return False, "Key combination cannot be empty" + + # Check for valid format + parts = keys.lower().split('+') + + if len(parts) < 1: + return False, "Invalid key combination format" + + # Valid modifiers + modifiers = {'ctrl', 'alt', 'shift', 'cmd', 'win'} + + # Valid keys + valid_keys = { + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', + 'esc', 'escape', 'tab', 'space', 'enter', 'return', 'backspace', 'delete', + 'insert', 'home', 'end', 'pageup', 'pagedown', + 'up', 'down', 'left', 'right', + 'print', 'scrolllock', 'pause', + } + + for part in parts: + part = part.strip() + if part in modifiers: + continue + if part in valid_keys: + continue + return False, f"Invalid key: '{part}'" + + return True, "" + + def reset_to_defaults(self): + """Reset all hotkeys to defaults.""" + self.hotkeys = self.DEFAULT_HOTKEYS.copy() + self._save_config() + + def get_conflicts(self) -> List[tuple]: + """Find conflicting hotkey combinations.""" + conflicts = [] + seen = {} + + for action, config in self.hotkeys.items(): + if not config.enabled: + continue + + key = (config.keys.lower(), config.scope) + if key in seen: + conflicts.append((seen[key], action, config.keys)) + else: + seen[key] = action + + return conflicts diff --git a/core/overlay_window.py b/core/overlay_window.py index 5d0aa7b..9347f40 100644 --- a/core/overlay_window.py +++ b/core/overlay_window.py @@ -454,14 +454,33 @@ class OverlayWindow(QMainWindow): return content def _setup_tray(self): - """Setup system tray icon.""" + """Setup system tray icon with better fallback handling.""" c = get_all_colors() self.tray_icon = QSystemTrayIcon(self) - icon_path = Path("assets/icon.ico") - if icon_path.exists(): - self.tray_icon.setIcon(QIcon(str(icon_path))) + # Try multiple icon sources + icon_paths = [ + Path("assets/icon.ico"), + Path("assets/icon.png"), + Path("assets/icons/eu_utility.svg"), + Path(__file__).parent.parent / "assets" / "icon.ico", + Path(__file__).parent.parent / "assets" / "icon.png", + ] + + icon_set = False + for icon_path in icon_paths: + if icon_path.exists(): + self.tray_icon.setIcon(QIcon(str(icon_path))) + icon_set = True + break + + if not icon_set: + # Use standard icon as fallback + style = self.style() + if style: + standard_icon = style.standardIcon(self.style().StandardPixmap.SP_ComputerIcon) + self.tray_icon.setIcon(standard_icon) tray_menu = QMenu() tray_menu.setStyleSheet(f""" @@ -722,6 +741,10 @@ class OverlayWindow(QMainWindow): plugins_tab = self._create_plugins_settings_tab() tabs.addTab(plugins_tab, "Plugins") + # Hotkeys tab + hotkeys_tab = self._create_hotkeys_settings_tab() + tabs.addTab(hotkeys_tab, "Hotkeys") + # Appearance tab appearance_tab = self._create_appearance_settings_tab() tabs.addTab(appearance_tab, "Appearance") @@ -1002,6 +1025,173 @@ class OverlayWindow(QMainWindow): except Exception as e: print(f"[Overlay] Error creating settings for {plugin_id}: {e}") + def _create_hotkeys_settings_tab(self) -> QWidget: + """Create hotkeys settings tab.""" + c = get_all_colors() + + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(16) + layout.setContentsMargins(16, 16, 16, 16) + + # Header + header_frame = QFrame() + header_frame.setStyleSheet(f""" + QFrame {{ + background-color: {c['bg_secondary']}; + border-left: 3px solid {c['accent_primary']}; + border-radius: 4px; + padding: 12px; + }} + """) + header_layout = QVBoxLayout(header_frame) + + info_title = QLabel("Keyboard Shortcuts") + info_title.setStyleSheet(f"color: {c['text_primary']}; font-weight: bold; font-size: 13px;") + header_layout.addWidget(info_title) + + info_desc = QLabel("Configure global and local keyboard shortcuts. Global hotkeys work even when the overlay is hidden.") + info_desc.setStyleSheet(f"color: {c['text_secondary']}; font-size: 11px;") + info_desc.setWordWrap(True) + header_layout.addWidget(info_desc) + + layout.addWidget(header_frame) + + # Hotkeys list container + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet(f""" + QScrollArea {{ + background-color: transparent; + border: none; + }} + QScrollBar:vertical {{ + background-color: {c['bg_secondary']}; + width: 10px; + border-radius: 5px; + }} + QScrollBar::handle:vertical {{ + background-color: {c['accent_primary']}; + border-radius: 5px; + min-height: 30px; + }} + """) + + hotkeys_container = QWidget() + hotkeys_layout = QVBoxLayout(hotkeys_container) + hotkeys_layout.setSpacing(8) + hotkeys_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Get hotkey manager + try: + from core.hotkey_manager import HotkeyManager + hotkey_manager = HotkeyManager() + hotkeys = hotkey_manager.get_all_hotkeys() + + # Group by scope + scopes = { + 'global': ('Global Hotkeys', 'Work even when overlay is hidden', '#4ecdc4'), + 'local': ('Local Hotkeys', 'Work only when app is focused', '#a0aec0'), + 'overlay': ('Overlay Hotkeys', 'Work only when overlay is visible', '#ff8c42'), + } + + for scope_key, (scope_name, scope_desc, scope_color) in scopes.items(): + scope_hotkeys = {k: v for k, v in hotkeys.items() if v.scope == scope_key} + + if scope_hotkeys: + # Section header + scope_header = QLabel(f"{scope_name.upper()}") + scope_header.setStyleSheet(f""" + color: {scope_color}; + font-weight: bold; + font-size: 10px; + padding: 12px 4px 4px 4px; + border-bottom: 1px solid {c['border_color']}; + """) + hotkeys_layout.addWidget(scope_header) + + scope_subheader = QLabel(scope_desc) + scope_subheader.setStyleSheet(f"color: {c['text_muted']}; font-size: 9px; padding-left: 4px;") + hotkeys_layout.addWidget(scope_subheader) + + # Add each hotkey + for action, config in scope_hotkeys.items(): + row_widget = QFrame() + row_widget.setStyleSheet(f""" + QFrame {{ + background-color: {c['bg_secondary']}; + border-radius: 4px; + padding: 2px; + }} + """) + row_layout = QHBoxLayout(row_widget) + row_layout.setSpacing(12) + row_layout.setContentsMargins(10, 8, 10, 8) + + # Description + desc_label = QLabel(config.description) + desc_label.setStyleSheet(f"color: {c['text_primary']}; font-size: 12px;") + row_layout.addWidget(desc_label, 1) + + # Key combo display + key_label = QLabel(config.keys.upper()) + key_label.setStyleSheet(f""" + color: {c['accent_primary']}; + font-weight: bold; + font-size: 11px; + background-color: {c['bg_primary']}; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid {c['border_color']}; + """) + row_layout.addWidget(key_label) + + # Enable checkbox + enable_cb = QCheckBox() + enable_cb.setChecked(config.enabled) + enable_cb.setStyleSheet(f""" + QCheckBox::indicator {{ + width: 16px; + height: 16px; + border-radius: 3px; + border: 2px solid {c['accent_primary']}; + }} + QCheckBox::indicator:checked {{ + background-color: {c['accent_primary']}; + }} + """) + row_layout.addWidget(enable_cb) + + hotkeys_layout.addWidget(row_widget) + + except Exception as e: + error_label = QLabel(f"Error loading hotkeys: {e}") + error_label.setStyleSheet(f"color: #ff6b6b;") + hotkeys_layout.addWidget(error_label) + + hotkeys_layout.addStretch() + scroll.setWidget(hotkeys_container) + layout.addWidget(scroll, 1) + + # Reset button + reset_btn = QPushButton("Reset to Defaults") + reset_btn.setStyleSheet(get_button_style('secondary')) + reset_btn.clicked.connect(self._reset_hotkeys) + layout.addWidget(reset_btn) + + return tab + + def _reset_hotkeys(self): + """Reset hotkeys to defaults.""" + try: + from core.hotkey_manager import HotkeyManager + hotkey_manager = HotkeyManager() + hotkey_manager.reset_to_defaults() + self.notify_info("Hotkeys Reset", "All hotkeys have been reset to default values.") + except Exception as e: + self.notify_error("Error", f"Failed to reset hotkeys: {e}") + def _create_appearance_settings_tab(self) -> QWidget: """Create appearance settings tab.""" c = get_all_colors()