feat: Fix system tray and add configurable hotkeys

SYSTEM TRAY FIXES:
- Added fallback icon search paths
- Falls back to standard system icon if no custom icon found
- Better error handling for missing icon files
- System tray should now show in Windows taskbar

HOTKEY MANAGEMENT:
- New HotkeyManager class (core/hotkey_manager.py)
  - JSON-based configuration storage
  - 10 configurable global hotkeys
  - 2 local hotkeys
  - Validation for key combinations
  - Conflict detection
  - Reset to defaults

HOTKEYS TAB:
- Added 'Hotkeys' tab to Settings dialog
- Categorized by scope: Global/Local/Overlay
- Shows current key bindings
- Enable/disable toggles
- Visual styling with color-coded sections
- Reset to defaults button

DEFAULT HOTKEYS:
  Global:
  - Ctrl+Shift+U: Toggle overlay
  - Ctrl+Shift+H: Hide overlays
  - Ctrl+Shift+F: Universal search
  - Ctrl+Shift+N: Nexus search
  - Ctrl+Shift+C: Calculator
  - Ctrl+Shift+M: Spotify
  - Ctrl+Shift+R: Game reader (OCR)
  - Ctrl+Shift+S: Skill scanner
  - Ctrl+Shift+L: Loot tracker
  - Ctrl+Shift+P: Screenshot

  Local:
  - Ctrl+T: Toggle theme

  Overlay:
  - ESC: Close overlay
This commit is contained in:
LemonNexus 2026-02-14 19:17:42 +00:00
parent db0eb5bf65
commit 92e528b5b6
2 changed files with 447 additions and 4 deletions

253
core/hotkey_manager.py Normal file
View File

@ -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

View File

@ -454,14 +454,33 @@ class OverlayWindow(QMainWindow):
return content return content
def _setup_tray(self): def _setup_tray(self):
"""Setup system tray icon.""" """Setup system tray icon with better fallback handling."""
c = get_all_colors() c = get_all_colors()
self.tray_icon = QSystemTrayIcon(self) self.tray_icon = QSystemTrayIcon(self)
icon_path = Path("assets/icon.ico") # Try multiple icon sources
if icon_path.exists(): icon_paths = [
self.tray_icon.setIcon(QIcon(str(icon_path))) 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 = QMenu()
tray_menu.setStyleSheet(f""" tray_menu.setStyleSheet(f"""
@ -722,6 +741,10 @@ class OverlayWindow(QMainWindow):
plugins_tab = self._create_plugins_settings_tab() plugins_tab = self._create_plugins_settings_tab()
tabs.addTab(plugins_tab, "Plugins") tabs.addTab(plugins_tab, "Plugins")
# Hotkeys tab
hotkeys_tab = self._create_hotkeys_settings_tab()
tabs.addTab(hotkeys_tab, "Hotkeys")
# Appearance tab # Appearance tab
appearance_tab = self._create_appearance_settings_tab() appearance_tab = self._create_appearance_settings_tab()
tabs.addTab(appearance_tab, "Appearance") tabs.addTab(appearance_tab, "Appearance")
@ -1002,6 +1025,173 @@ class OverlayWindow(QMainWindow):
except Exception as e: except Exception as e:
print(f"[Overlay] Error creating settings for {plugin_id}: {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: def _create_appearance_settings_tab(self) -> QWidget:
"""Create appearance settings tab.""" """Create appearance settings tab."""
c = get_all_colors() c = get_all_colors()