feat: Dynamic hotkey discovery from plugins

REFACTOR: Hotkeys are no longer hardcoded in Settings UI

NEW SYSTEM:

1. Plugins advertise their hotkeys via class attributes:

   Legacy (single hotkey):
     hotkey = 'ctrl+shift+s'

   New format (multiple hotkeys with descriptions):
     hotkeys = [
         {
             'action': 'toggle',
             'description': 'Toggle Skill Scanner',
             'default': 'ctrl+shift+s',
             'config_key': 'skillscanner_toggle'  # optional
         },
         {
             'action': 'quick_scan',
             'description': 'Quick Scan',
             'default': 'f12',
         }
     ]

2. Settings UI dynamically discovers hotkeys:
   - Scans all plugins for hotkey/hotkeys attributes
   - Groups hotkeys by plugin name
   - Shows description + input field + reset button
   - Core system hotkeys in separate 'Core System' group

3. Visual improvements:
   - Scrollable hotkey list
   - Reset button (↺) for each hotkey
   - Tooltips showing default value
   - Plugin grouping for organization

4. Backward compatible:
   - Still supports legacy 'hotkey' attribute
   - Converts to new format automatically
   - Existing settings preserved

BASE PLUGIN:
- Added hotkeys attribute documentation
- Added docstring with usage examples
- Shows both formats in class docstring

SETTINGS PLUGIN:
- _create_hotkeys_tab() now dynamic
- _collect_plugin_hotkeys() scans all plugins
- Groups by plugin with QGroupBox
- Reset buttons restore defaults
- ScrollArea for long lists

This allows plugins to define multiple hotkeys with
descriptions, and they'll automatically appear in
Settings without hardcoding them in the core.
This commit is contained in:
LemonNexus 2026-02-15 01:28:21 +00:00
parent b63763b528
commit 09ad30c223
2 changed files with 221 additions and 23 deletions

View File

@ -15,7 +15,28 @@ if TYPE_CHECKING:
class BasePlugin(ABC):
"""Base class for all EU-Utility plugins."""
"""Base class for all EU-Utility plugins.
To define hotkeys for your plugin, use either:
1. Legacy single hotkey (simple toggle):
hotkey = "ctrl+shift+n"
2. New multi-hotkey format (recommended):
hotkeys = [
{
'action': 'toggle', # Unique action identifier
'description': 'Toggle My Plugin', # Display name in settings
'default': 'ctrl+shift+m', # Default hotkey combination
'config_key': 'myplugin_toggle' # Settings key (optional)
},
{
'action': 'quick_action',
'description': 'Quick Scan',
'default': 'ctrl+shift+s',
}
]
"""
# Plugin metadata - override in subclass
name: str = "Unnamed Plugin"
@ -25,7 +46,8 @@ class BasePlugin(ABC):
icon: Optional[str] = None
# Plugin settings
hotkey: Optional[str] = None # e.g., "ctrl+shift+n"
hotkey: Optional[str] = None # Legacy single hotkey (e.g., "ctrl+shift+n")
hotkeys: Optional[List[Dict[str, str]]] = None # New multi-hotkey format
enabled: bool = True
# Dependencies - override in subclass

View File

@ -518,32 +518,118 @@ class SettingsPlugin(BasePlugin):
dialog.exec()
def _create_hotkeys_tab(self):
"""Create hotkeys configuration tab."""
"""Create hotkeys configuration tab - dynamically discovers hotkeys from plugins."""
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(15)
hotkeys_group = QGroupBox("Global Hotkeys")
hotkeys_group.setStyleSheet(self._group_style())
hotkeys_layout = QVBoxLayout(hotkeys_group)
# Info label
info = QLabel("Hotkeys are advertised by plugins. Changes apply on next restart.")
info.setStyleSheet("color: rgba(255,255,255,150);")
layout.addWidget(info)
# Scroll area for hotkeys
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent; border: none;")
scroll_content = QWidget()
hotkeys_layout = QVBoxLayout(scroll_content)
hotkeys_layout.setSpacing(10)
hotkeys_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.hotkey_inputs = {}
hotkeys = [
("Toggle Overlay", "hotkey_toggle", "ctrl+shift+u"),
("Universal Search", "hotkey_search", "ctrl+shift+f"),
("Calculator", "hotkey_calculator", "ctrl+shift+c"),
("Spotify", "hotkey_music", "ctrl+shift+m"),
("Game Reader", "hotkey_scan", "ctrl+shift+r"),
("Skill Scanner", "hotkey_skills", "ctrl+shift+s"),
# Collect hotkeys from all plugins
plugin_hotkeys = self._collect_plugin_hotkeys()
# Group by plugin
for plugin_name, hotkeys in sorted(plugin_hotkeys.items()):
# Plugin group
group = QGroupBox(plugin_name)
group.setStyleSheet(self._group_style())
group_layout = QVBoxLayout(group)
for hotkey_info in hotkeys:
row = QHBoxLayout()
# Description
desc = hotkey_info.get('description', hotkey_info['action'])
desc_label = QLabel(f"{desc}:")
desc_label.setStyleSheet("color: white; min-width: 150px;")
row.addWidget(desc_label)
# Hotkey input
input_field = QLineEdit()
input_field.setText(hotkey_info['current'])
input_field.setPlaceholderText(hotkey_info['default'])
input_field.setStyleSheet("""
QLineEdit {
background-color: rgba(30, 35, 45, 200);
color: white;
border: 1px solid rgba(100, 110, 130, 80);
padding: 5px;
min-width: 150px;
}
""")
# Store reference with config key
config_key = hotkey_info['config_key']
self.hotkey_inputs[config_key] = {
'input': input_field,
'default': hotkey_info['default'],
'plugin': plugin_name,
'action': hotkey_info['action']
}
row.addWidget(input_field)
# Reset button
reset_btn = QPushButton("")
reset_btn.setFixedSize(28, 28)
reset_btn.setStyleSheet("""
QPushButton {
background-color: rgba(255,255,255,20);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
}
QPushButton:hover {
background-color: rgba(255,255,255,40);
}
""")
reset_btn.setToolTip(f"Reset to default: {hotkey_info['default']}")
reset_btn.clicked.connect(lambda checked, inp=input_field, default=hotkey_info['default']: inp.setText(default))
row.addWidget(reset_btn)
row.addStretch()
group_layout.addLayout(row)
hotkeys_layout.addWidget(group)
# Core hotkeys section (always present)
core_group = QGroupBox("Core System")
core_group.setStyleSheet(self._group_style())
core_layout = QVBoxLayout(core_group)
core_hotkeys = [
("Toggle Overlay", "hotkey_toggle", "ctrl+shift+u", "Show/hide the EU-Utility overlay"),
("Universal Search", "hotkey_search", "ctrl+shift+f", "Quick search across all plugins"),
]
for label, key, default in hotkeys:
for label, config_key, default, description in core_hotkeys:
row = QHBoxLayout()
row.addWidget(QLabel(label + ":"))
desc_label = QLabel(f"{label}:")
desc_label.setStyleSheet("color: white; min-width: 150px;")
row.addWidget(desc_label)
input_field = QLineEdit()
input_field.setText(self.settings.get(key, default))
current = self.settings.get(config_key, default)
input_field.setText(current)
input_field.setPlaceholderText(default)
input_field.setStyleSheet("""
QLineEdit {
background-color: rgba(30, 35, 45, 200);
@ -553,17 +639,102 @@ class SettingsPlugin(BasePlugin):
min-width: 150px;
}
""")
self.hotkey_inputs[key] = input_field
self.hotkey_inputs[config_key] = {
'input': input_field,
'default': default,
'plugin': 'Core',
'action': label
}
row.addWidget(input_field)
row.addStretch()
hotkeys_layout.addLayout(row)
reset_btn = QPushButton("")
reset_btn.setFixedSize(28, 28)
reset_btn.setStyleSheet("""
QPushButton {
background-color: rgba(255,255,255,20);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
}
QPushButton:hover {
background-color: rgba(255,255,255,40);
}
""")
reset_btn.setToolTip(f"Reset to default: {default}")
reset_btn.clicked.connect(lambda checked, inp=input_field, default=default: inp.setText(default))
row.addWidget(reset_btn)
layout.addWidget(hotkeys_group)
layout.addStretch()
row.addStretch()
core_layout.addLayout(row)
hotkeys_layout.addWidget(core_group)
hotkeys_layout.addStretch()
scroll.setWidget(scroll_content)
layout.addWidget(scroll)
return tab
def _collect_plugin_hotkeys(self) -> dict:
"""Collect hotkeys from all discovered plugins.
Returns:
Dict mapping plugin name to list of hotkey info dicts
"""
plugin_hotkeys = {}
if not hasattr(self.overlay, 'plugin_manager'):
return plugin_hotkeys
plugin_manager = self.overlay.plugin_manager
all_plugins = plugin_manager.get_all_discovered_plugins()
for plugin_id, plugin_class in all_plugins.items():
hotkeys = getattr(plugin_class, 'hotkeys', None)
if not hotkeys:
# Try legacy single hotkey attribute
single_hotkey = getattr(plugin_class, 'hotkey', None)
if single_hotkey:
hotkeys = [{
'action': 'toggle',
'description': f"Toggle {plugin_class.name}",
'default': single_hotkey,
'config_key': f"hotkey_{plugin_id.split('.')[-1]}"
}]
if hotkeys:
plugin_name = plugin_class.name
plugin_hotkeys[plugin_name] = []
for i, hk in enumerate(hotkeys):
# Support both dict format and simple string
if isinstance(hk, dict):
hotkey_info = {
'action': hk.get('action', f'action_{i}'),
'description': hk.get('description', hk.get('action', f'Action {i}')),
'default': hk.get('default', ''),
'config_key': hk.get('config_key', f"hotkey_{plugin_id.split('.')[-1]}_{i}")
}
else:
# Simple string format - legacy
hotkey_info = {
'action': f'hotkey_{i}',
'description': f"Hotkey {i+1}",
'default': str(hk),
'config_key': f"hotkey_{plugin_id.split('.')[-1]}_{i}"
}
# Get current value from settings
hotkey_info['current'] = self.settings.get(hotkey_info['config_key'], hotkey_info['default'])
plugin_hotkeys[plugin_name].append(hotkey_info)
return plugin_hotkeys
def _create_overlay_tab(self):
"""Create overlay widgets configuration tab."""
tab = QWidget()
@ -698,9 +869,14 @@ class SettingsPlugin(BasePlugin):
self.settings.set('minimize_to_tray', self.minimize_cb.isChecked())
self.settings.set('show_tooltips', self.tooltips_cb.isChecked())
# Hotkeys
for key, input_field in self.hotkey_inputs.items():
self.settings.set(key, input_field.text())
# Hotkeys - new structure with dict values
for config_key, hotkey_data in self.hotkey_inputs.items():
if isinstance(hotkey_data, dict):
input_field = hotkey_data['input']
self.settings.set(config_key, input_field.text())
else:
# Legacy format - direct QLineEdit reference
self.settings.set(config_key, hotkey_data.text())
print("Settings saved!")