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): 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 # Plugin metadata - override in subclass
name: str = "Unnamed Plugin" name: str = "Unnamed Plugin"
@ -25,7 +46,8 @@ class BasePlugin(ABC):
icon: Optional[str] = None icon: Optional[str] = None
# Plugin settings # 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 enabled: bool = True
# Dependencies - override in subclass # Dependencies - override in subclass

View File

@ -518,32 +518,118 @@ class SettingsPlugin(BasePlugin):
dialog.exec() dialog.exec()
def _create_hotkeys_tab(self): def _create_hotkeys_tab(self):
"""Create hotkeys configuration tab.""" """Create hotkeys configuration tab - dynamically discovers hotkeys from plugins."""
tab = QWidget() tab = QWidget()
layout = QVBoxLayout(tab) layout = QVBoxLayout(tab)
layout.setSpacing(15) layout.setSpacing(15)
hotkeys_group = QGroupBox("Global Hotkeys") # Info label
hotkeys_group.setStyleSheet(self._group_style()) info = QLabel("Hotkeys are advertised by plugins. Changes apply on next restart.")
hotkeys_layout = QVBoxLayout(hotkeys_group) 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 = {} self.hotkey_inputs = {}
hotkeys = [ # Collect hotkeys from all plugins
("Toggle Overlay", "hotkey_toggle", "ctrl+shift+u"), plugin_hotkeys = self._collect_plugin_hotkeys()
("Universal Search", "hotkey_search", "ctrl+shift+f"),
("Calculator", "hotkey_calculator", "ctrl+shift+c"), # Group by plugin
("Spotify", "hotkey_music", "ctrl+shift+m"), for plugin_name, hotkeys in sorted(plugin_hotkeys.items()):
("Game Reader", "hotkey_scan", "ctrl+shift+r"), # Plugin group
("Skill Scanner", "hotkey_skills", "ctrl+shift+s"), 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 = 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 = 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(""" input_field.setStyleSheet("""
QLineEdit { QLineEdit {
background-color: rgba(30, 35, 45, 200); background-color: rgba(30, 35, 45, 200);
@ -553,17 +639,102 @@ class SettingsPlugin(BasePlugin):
min-width: 150px; 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.addWidget(input_field)
row.addStretch() reset_btn = QPushButton("")
hotkeys_layout.addLayout(row) 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) row.addStretch()
layout.addStretch() core_layout.addLayout(row)
hotkeys_layout.addWidget(core_group)
hotkeys_layout.addStretch()
scroll.setWidget(scroll_content)
layout.addWidget(scroll)
return tab 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): def _create_overlay_tab(self):
"""Create overlay widgets configuration tab.""" """Create overlay widgets configuration tab."""
tab = QWidget() tab = QWidget()
@ -698,9 +869,14 @@ class SettingsPlugin(BasePlugin):
self.settings.set('minimize_to_tray', self.minimize_cb.isChecked()) self.settings.set('minimize_to_tray', self.minimize_cb.isChecked())
self.settings.set('show_tooltips', self.tooltips_cb.isChecked()) self.settings.set('show_tooltips', self.tooltips_cb.isChecked())
# Hotkeys # Hotkeys - new structure with dict values
for key, input_field in self.hotkey_inputs.items(): for config_key, hotkey_data in self.hotkey_inputs.items():
self.settings.set(key, input_field.text()) 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!") print("Settings saved!")